From 514686d9d5f6c549a8ad862279107f1fe31aa35a Mon Sep 17 00:00:00 2001
From: Dheeraj Kumar Ketireddy
Date: Fri, 25 Jul 2025 00:17:05 +0530
Subject: [PATCH 001/178] [WEB-4045] feat: restructuring of the external APIs
for better maintainability (#7477)
* Basic setup for drf-spectacular
* Updated to only handle /api/v1 endpoints
* feat: add asset and user endpoints with URL routing
- Introduced new asset-related endpoints for user assets and server assets, allowing for asset uploads and management.
- Added user endpoint to retrieve current user information.
- Updated URL routing to include new asset and user patterns.
- Enhanced issue handling with a new search endpoint for issues across multiple fields.
- Expanded member management with a new endpoint for workspace members.
* Group endpoints by tags
* Detailed schema definitions and examples for asset endpoints
* Removed unnecessary extension
* Specify avatar_url field separately
* chore: add project docs
* chore: correct all errors
* chore: added open spec in work items
* feat: enhance cycle API endpoints with detailed OpenAPI specifications
- Updated CycleAPIEndpoint and CycleIssueAPIEndpoint to include detailed OpenAPI schema definitions for GET, POST, PATCH, and DELETE operations.
- Specified allowed HTTP methods for each endpoint in the URL routing.
- Improved documentation for cycle creation, updating, and deletion, including request and response examples.
* chore: added open spec in labels
* chore: work item properties
* feat: enhance API endpoints with OpenAPI specifications and HTTP method definitions
- Added detailed OpenAPI schema definitions for various API endpoints including Intake, Module, and State.
- Specified allowed HTTP methods for each endpoint in the URL routing for better clarity and documentation.
- Improved request and response examples for better understanding of API usage.
- Introduced unarchive functionality for cycles and modules with appropriate endpoint definitions.
* chore: run formatter
* Removed unnecessary settings for authentication
* Refactors OpenAPI documentation structure
Improves the organization and maintainability of the OpenAPI documentation by modularizing the `openapi_spec_helpers.py` file.
The changes include:
- Migrates common parameters, responses, examples, and authentication extensions to separate modules.
- Introduces helper decorators for different endpoint types.
- Updates view imports to use the new module paths.
- Removes the legacy `openapi_spec_helpers.py` file.
This refactoring results in a more structured and easier-to-maintain OpenAPI documentation setup.
* Refactor OpenAPI endpoint specifications
- Removed unnecessary parameters from the OpenAPI documentation for various endpoints in the asset, cycle, and project views.
- Updated request structures to improve clarity and consistency across the API documentation.
- Enhanced response formatting for better readability and maintainability.
* Enhance API documentation with detailed endpoint descriptions
Updated various API endpoints across the application to include comprehensive docstrings that clarify their functionality. Each endpoint now features a summary and detailed description, improving the overall understanding of their purpose and usage. This change enhances the OpenAPI specifications for better developer experience and documentation clarity.
* Enhance API serializers and views with new request structures
- Added new serializers for handling cycle and module issue requests, including `CycleIssueRequestSerializer`, `TransferCycleIssueRequestSerializer`, `ModuleIssueRequestSerializer`, and intake issue creation/updating serializers.
- Updated existing serializers to improve clarity and maintainability, including the `UserAssetUploadSerializer` and `IssueAttachmentUploadSerializer`.
- Refactored API views to utilize the new serializers, enhancing the request handling for cycle and intake issue endpoints.
- Improved OpenAPI documentation by replacing inline request definitions with serializer references for better consistency and readability.
* Refactor OpenAPI documentation and endpoint specifications
- Replaced inline schema definitions with dedicated decorators for various endpoint types, enhancing clarity and maintainability.
- Updated API views to utilize new decorators for user, cycle, intake, module, and project endpoints, improving consistency in OpenAPI documentation.
- Removed unnecessary parameters and responses from endpoint specifications, streamlining the documentation for better readability.
- Enhanced the organization of OpenAPI documentation by modularizing endpoint-specific decorators and parameters.
* chore: correct formatting
* chore: correct formatting for all api folder files
* refactor: clean up serializer imports and test setup
- Removed unused `StateLiteSerializer` import from the serializer module.
- Updated test setup to include a noqa comment for the `django_db_setup` fixture, ensuring clarity in the code.
- Added missing commas in user data dictionary for consistency.
* feat: add project creation and update serializers with validation
- Introduced `ProjectCreateSerializer` and `ProjectUpdateSerializer` to handle project creation and updates, respectively.
- Implemented validation to ensure project leads and default assignees are members of the workspace.
- Updated API views to utilize the new serializers for creating and updating projects, enhancing request handling.
- Added OpenAPI documentation references for the new serializers in the project API endpoints.
* feat: update serializers to include additional read-only fields
* refactor: rename intake issue serializers and enhance structure
- Renamed `CreateIntakeIssueRequestSerializer` to `IntakeIssueCreateSerializer` and `UpdateIntakeIssueRequestSerializer` to `IntakeIssueUpdateSerializer` for clarity.
- Introduced `IssueSerializer` for nested issue data in intake requests, improving the organization of serializer logic.
- Updated API views to utilize the new serializer names, ensuring consistency across the codebase.
* refactor: rename issue serializer for intake and enhance API documentation
- Renamed `IssueSerializer` to `IssueForIntakeSerializer` for better clarity in the context of intake issues.
- Updated references in `IntakeIssueCreateSerializer` and `IntakeIssueUpdateSerializer` to use the new `IssueForIntakeSerializer`.
- Added OpenAPI documentation for the `get_workspace_work_item` endpoint, detailing parameters and responses for improved clarity.
* chore: modules and cycles serializers
* feat: add new serializers for label and issue link management
- Introduced `LabelCreateUpdateSerializer`, `IssueLinkCreateSerializer`, `IssueLinkUpdateSerializer`, and `IssueCommentCreateSerializer` to enhance the handling of label and issue link data.
- Updated existing API views to utilize the new serializers for creating and updating labels, issue links, and comments, improving request handling and validation.
- Added `IssueSearchSerializer` for searching issues, streamlining the search functionality in the API.
* Don't consider read only fields as required
* Add setting to separate request and response definitions
* Fixed avatar_url warning on openapi spec generation
* Made spectacular disabled by default
* Moved spectacular settings into separate file and added detailed descriptions to tags
* Specify methods for asset urls
* Better server names
* Enhance API documentation with summaries for various endpoints
- Added summary descriptions for user asset, cycle, intake, issue, member, module, project, state, and user API endpoints to improve clarity and usability of the API documentation.
- Updated the OpenAPI specifications to reflect these changes, ensuring better understanding for developers interacting with the API.
* Add contact information to OpenAPI settings
- Included contact details for Plane in the OpenAPI settings to enhance API documentation and provide developers with a direct point of contact for support.
- This addition aims to improve the overall usability and accessibility of the API documentation.
* Reordered tags and improved description relavancy
* Enhance OpenAPI documentation for cycle and issue endpoints
- Added response definitions for the `get_cycle_issues` and `delete_cycle_issue` methods in the CycleIssueAPIEndpoint to clarify expected outcomes.
- Included additional response codes for the IssueSearchEndpoint to handle various error scenarios, improving the overall API documentation and usability.
* Enhance serializer documentation across multiple files
- Updated docstrings for various serializers including UserAssetUploadSerializer, AssetUpdateSerializer, and others to provide clearer descriptions of their functionality and usage.
- Improved consistency in formatting and language across serializer classes to enhance readability and maintainability.
- Added detailed explanations for new serializers related to project, module, and cycle management, ensuring comprehensive documentation for developers.
* Refactor API endpoints for cycles, intake, modules, projects, and states
- Replaced existing API endpoint classes with more descriptive names such as CycleListCreateAPIEndpoint, CycleDetailAPIEndpoint, IntakeIssueListCreateAPIEndpoint, and others to enhance clarity.
- Updated URL patterns to reflect the new endpoint names, ensuring consistency across the API.
- Improved documentation and method summaries for better understanding of endpoint functionalities.
- Enhanced query handling in the new endpoint classes to streamline data retrieval and improve performance.
* Refactor issue and label API endpoints for clarity and functionality
- Renamed existing API endpoint classes to more descriptive names such as IssueListCreateAPIEndpoint, IssueDetailAPIEndpoint, LabelListCreateAPIEndpoint, and LabelDetailAPIEndpoint to enhance clarity.
- Updated URL patterns to reflect the new endpoint names, ensuring consistency across the API.
- Improved method summaries and documentation for better understanding of endpoint functionalities.
- Streamlined query handling in the new endpoint classes to enhance data retrieval and performance.
* Refactor asset API endpoint methods and introduce new status enums
- Updated the GenericAssetEndpoint to only allow POST requests for asset creation, removing the GET method.
- Modified the get method to require asset_id, ensuring that asset retrieval is always tied to a specific asset.
- Added new IntakeIssueStatus and ModuleStatus enums to improve clarity and management of asset and module states.
- Enhanced OpenAPI settings to include these new enums for better documentation and usability.
* enforce naming convention
* Added LICENSE to openapi spec
* Enhance OpenAPI documentation for various API endpoints
- Updated API endpoints in asset, cycle, intake, issue, module, project, and state views to include OpenApiRequest and OpenApiExample for better request documentation.
- Added example requests for creating and updating resources, improving clarity for API consumers.
- Ensured consistent use of OpenApi utilities across all relevant endpoints to enhance overall API documentation quality.
* Enhance OpenAPI documentation for various API endpoints
- Added detailed descriptions to multiple API endpoints across asset, cycle, intake, issue, module, project, state, and user views to improve clarity for API consumers.
- Ensured consistent documentation practices by including descriptions that outline the purpose and functionality of each endpoint.
- This update aims to enhance the overall usability and understanding of the API documentation.
* Update OpenAPI examples and enhance project queryset logic
- Changed example fields in OpenAPI documentation for issue comments from "content" to "comment_html" to reflect the correct structure.
- Introduced a new `get_queryset` method in the ProjectDetailAPIEndpoint to filter projects based on user membership and workspace, while also annotating additional project-related data such as total members, cycles, and modules.
- Updated permission checks to use the correct attribute name for project identifiers, ensuring accurate permission handling.
* Enhance OpenAPI documentation and add response examples
- Updated multiple API endpoints across asset, cycle, intake, issue, module, project, state, and user views to include new OpenApiResponse examples for better clarity on expected outcomes.
- Introduced new parameters for project and issue identifiers to improve request handling and documentation consistency.
- Enhanced existing responses with detailed examples to aid API consumers in understanding the expected data structure and error handling.
- This update aims to improve the overall usability and clarity of the API documentation.
* refactor: update terminology from 'issues' to 'work items' across multiple API endpoints for consistency and clarity
* use common timezones from pytz for choices
* Moved the openapi utils to the new folder structure
* Added exception logging in GenericAssetEndpoint to improve error handling
* Fixed code rabbit suggestions
* Refactored IssueDetailAPIEndpoint to streamline issue retrieval and response handling, removing redundant external ID checks and custom ordering logic.
---------
Co-authored-by: pablohashescobar
Co-authored-by: NarayanBavisetti
---
apps/api/plane/api/apps.py | 7 +
apps/api/plane/api/serializers/__init__.py | 45 +-
apps/api/plane/api/serializers/asset.py | 123 ++
apps/api/plane/api/serializers/base.py | 7 +
apps/api/plane/api/serializers/cycle.py | 114 +-
apps/api/plane/api/serializers/estimate.py | 7 +
apps/api/plane/api/serializers/intake.py | 118 +-
apps/api/plane/api/serializers/issue.py | 222 ++-
apps/api/plane/api/serializers/module.py | 127 +-
apps/api/plane/api/serializers/project.py | 143 +-
apps/api/plane/api/serializers/state.py | 15 +
apps/api/plane/api/serializers/user.py | 14 +
apps/api/plane/api/serializers/workspace.py | 7 +-
apps/api/plane/api/urls/__init__.py | 4 +
apps/api/plane/api/urls/asset.py | 40 +
apps/api/plane/api/urls/cycle.py | 25 +-
apps/api/plane/api/urls/intake.py | 11 +-
apps/api/plane/api/urls/issue.py | 56 +-
apps/api/plane/api/urls/member.py | 13 +-
apps/api/plane/api/urls/module.py | 31 +-
apps/api/plane/api/urls/project.py | 16 +-
apps/api/plane/api/urls/schema.py | 20 +
apps/api/plane/api/urls/state.py | 9 +-
apps/api/plane/api/urls/user.py | 11 +
apps/api/plane/api/views/__init__.py | 53 +-
apps/api/plane/api/views/asset.py | 629 ++++++++
apps/api/plane/api/views/base.py | 4 +-
apps/api/plane/api/views/cycle.py | 515 ++++++-
apps/api/plane/api/views/intake.py | 219 ++-
apps/api/plane/api/views/issue.py | 1333 +++++++++++++++--
apps/api/plane/api/views/member.py | 201 +--
apps/api/plane/api/views/module.py | 646 +++++++-
apps/api/plane/api/views/project.py | 544 +++++--
apps/api/plane/api/views/state.py | 179 ++-
apps/api/plane/api/views/user.py | 37 +
apps/api/plane/app/permissions/project.py | 4 +-
apps/api/plane/app/views/analytic/advance.py | 2 -
apps/api/plane/app/views/workspace/cycle.py | 1 -
.../authentication/provider/oauth/github.py | 2 +-
.../plane/bgtasks/issue_activities_task.py | 1 -
.../commands/update_deleted_workspace_slug.py | 1 -
apps/api/plane/db/models/cycle.py | 2 +-
apps/api/plane/db/models/intake.py | 8 +
apps/api/plane/db/models/module.py | 9 +
apps/api/plane/db/models/project.py | 2 +-
apps/api/plane/db/models/user.py | 2 +-
apps/api/plane/db/models/workspace.py | 7 +-
apps/api/plane/settings/common.py | 9 +
apps/api/plane/settings/openapi.py | 272 ++++
apps/api/plane/space/serializer/__init__.py | 4 +-
apps/api/plane/tests/conftest.py | 4 +-
apps/api/plane/tests/conftest_external.py | 1 -
.../tests/contract/app/test_authentication.py | 2 +-
.../tests/unit/models/test_workspace_model.py | 2 +-
apps/api/plane/urls.py | 19 +
apps/api/plane/utils/openapi/README.md | 102 ++
apps/api/plane/utils/openapi/__init__.py | 315 ++++
apps/api/plane/utils/openapi/auth.py | 29 +
apps/api/plane/utils/openapi/decorators.py | 264 ++++
apps/api/plane/utils/openapi/examples.py | 816 ++++++++++
apps/api/plane/utils/openapi/hooks.py | 56 +
apps/api/plane/utils/openapi/parameters.py | 493 ++++++
apps/api/plane/utils/openapi/responses.py | 492 ++++++
apps/api/requirements/base.txt | 2 +
64 files changed, 7800 insertions(+), 668 deletions(-)
create mode 100644 apps/api/plane/api/serializers/asset.py
create mode 100644 apps/api/plane/api/urls/asset.py
create mode 100644 apps/api/plane/api/urls/schema.py
create mode 100644 apps/api/plane/api/urls/user.py
create mode 100644 apps/api/plane/api/views/asset.py
create mode 100644 apps/api/plane/api/views/user.py
create mode 100644 apps/api/plane/settings/openapi.py
create mode 100644 apps/api/plane/utils/openapi/README.md
create mode 100644 apps/api/plane/utils/openapi/__init__.py
create mode 100644 apps/api/plane/utils/openapi/auth.py
create mode 100644 apps/api/plane/utils/openapi/decorators.py
create mode 100644 apps/api/plane/utils/openapi/examples.py
create mode 100644 apps/api/plane/utils/openapi/hooks.py
create mode 100644 apps/api/plane/utils/openapi/parameters.py
create mode 100644 apps/api/plane/utils/openapi/responses.py
diff --git a/apps/api/plane/api/apps.py b/apps/api/plane/api/apps.py
index 6ba36e7e5..b48a9a949 100644
--- a/apps/api/plane/api/apps.py
+++ b/apps/api/plane/api/apps.py
@@ -3,3 +3,10 @@ from django.apps import AppConfig
class ApiConfig(AppConfig):
name = "plane.api"
+
+ def ready(self):
+ # Import authentication extensions to register them with drf-spectacular
+ try:
+ import plane.utils.openapi.auth # noqa
+ except ImportError:
+ pass
\ No newline at end of file
diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py
index 8c84b2328..7596915eb 100644
--- a/apps/api/plane/api/serializers/__init__.py
+++ b/apps/api/plane/api/serializers/__init__.py
@@ -1,8 +1,14 @@
from .user import UserLiteSerializer
from .workspace import WorkspaceLiteSerializer
-from .project import ProjectSerializer, ProjectLiteSerializer
+from .project import (
+ ProjectSerializer,
+ ProjectLiteSerializer,
+ ProjectCreateSerializer,
+ ProjectUpdateSerializer,
+)
from .issue import (
IssueSerializer,
+ LabelCreateUpdateSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueCommentSerializer,
@@ -10,9 +16,40 @@ from .issue import (
IssueActivitySerializer,
IssueExpandSerializer,
IssueLiteSerializer,
+ IssueAttachmentUploadSerializer,
+ IssueSearchSerializer,
+ IssueCommentCreateSerializer,
+ IssueLinkCreateSerializer,
+ IssueLinkUpdateSerializer,
)
from .state import StateLiteSerializer, StateSerializer
-from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
-from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
-from .intake import IntakeIssueSerializer
+from .cycle import (
+ CycleSerializer,
+ CycleIssueSerializer,
+ CycleLiteSerializer,
+ CycleIssueRequestSerializer,
+ TransferCycleIssueRequestSerializer,
+ CycleCreateSerializer,
+ CycleUpdateSerializer,
+)
+from .module import (
+ ModuleSerializer,
+ ModuleIssueSerializer,
+ ModuleLiteSerializer,
+ ModuleIssueRequestSerializer,
+ ModuleCreateSerializer,
+ ModuleUpdateSerializer,
+)
+from .intake import (
+ IntakeIssueSerializer,
+ IntakeIssueCreateSerializer,
+ IntakeIssueUpdateSerializer,
+)
from .estimate import EstimatePointSerializer
+from .asset import (
+ UserAssetUploadSerializer,
+ AssetUpdateSerializer,
+ GenericAssetUploadSerializer,
+ GenericAssetUpdateSerializer,
+ FileAssetSerializer,
+)
diff --git a/apps/api/plane/api/serializers/asset.py b/apps/api/plane/api/serializers/asset.py
new file mode 100644
index 000000000..b63dc7ebb
--- /dev/null
+++ b/apps/api/plane/api/serializers/asset.py
@@ -0,0 +1,123 @@
+# Third party imports
+from rest_framework import serializers
+
+# Module imports
+from .base import BaseSerializer
+from plane.db.models import FileAsset
+
+
+class UserAssetUploadSerializer(serializers.Serializer):
+ """
+ Serializer for user asset upload requests.
+
+ This serializer validates the metadata required to generate a presigned URL
+ for uploading user profile assets (avatar or cover image) directly to S3 storage.
+ Supports JPEG, PNG, WebP, JPG, and GIF image formats with size validation.
+ """
+
+ name = serializers.CharField(help_text="Original filename of the asset")
+ type = serializers.ChoiceField(
+ choices=[
+ ("image/jpeg", "JPEG"),
+ ("image/png", "PNG"),
+ ("image/webp", "WebP"),
+ ("image/jpg", "JPG"),
+ ("image/gif", "GIF"),
+ ],
+ default="image/jpeg",
+ help_text="MIME type of the file",
+ style={"placeholder": "image/jpeg"},
+ )
+ size = serializers.IntegerField(help_text="File size in bytes")
+ entity_type = serializers.ChoiceField(
+ choices=[
+ (FileAsset.EntityTypeContext.USER_AVATAR, "User Avatar"),
+ (FileAsset.EntityTypeContext.USER_COVER, "User Cover"),
+ ],
+ help_text="Type of user asset",
+ )
+
+
+class AssetUpdateSerializer(serializers.Serializer):
+ """
+ Serializer for asset status updates after successful upload completion.
+
+ Handles post-upload asset metadata updates including attribute modifications
+ and upload confirmation for S3-based file storage workflows.
+ """
+
+ attributes = serializers.JSONField(
+ required=False, help_text="Additional attributes to update for the asset"
+ )
+
+
+class GenericAssetUploadSerializer(serializers.Serializer):
+ """
+ Serializer for generic asset upload requests with project association.
+
+ Validates metadata for generating presigned URLs for workspace assets including
+ project association, external system tracking, and file validation for
+ document management and content storage workflows.
+ """
+
+ name = serializers.CharField(help_text="Original filename of the asset")
+ type = serializers.CharField(required=False, help_text="MIME type of the file")
+ size = serializers.IntegerField(help_text="File size in bytes")
+ project_id = serializers.UUIDField(
+ required=False,
+ help_text="UUID of the project to associate with the asset",
+ style={"placeholder": "123e4567-e89b-12d3-a456-426614174000"},
+ )
+ external_id = serializers.CharField(
+ required=False,
+ help_text="External identifier for the asset (for integration tracking)",
+ )
+ external_source = serializers.CharField(
+ required=False, help_text="External source system (for integration tracking)"
+ )
+
+
+class GenericAssetUpdateSerializer(serializers.Serializer):
+ """
+ Serializer for generic asset upload confirmation and status management.
+
+ Handles post-upload status updates for workspace assets including
+ upload completion marking and metadata finalization.
+ """
+
+ is_uploaded = serializers.BooleanField(
+ default=True, help_text="Whether the asset has been successfully uploaded"
+ )
+
+
+class FileAssetSerializer(BaseSerializer):
+ """
+ Comprehensive file asset serializer with complete metadata and URL generation.
+
+ Provides full file asset information including storage metadata, access URLs,
+ relationship data, and upload status for complete asset management workflows.
+ """
+
+ asset_url = serializers.CharField(read_only=True)
+
+ class Meta:
+ model = FileAsset
+ fields = "__all__"
+ read_only_fields = [
+ "id",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "workspace",
+ "project",
+ "issue",
+ "comment",
+ "page",
+ "draft_issue",
+ "user",
+ "is_deleted",
+ "deleted_at",
+ "storage_metadata",
+ "asset_url",
+ ]
diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py
index 4b1e54707..4f89a98c7 100644
--- a/apps/api/plane/api/serializers/base.py
+++ b/apps/api/plane/api/serializers/base.py
@@ -3,6 +3,13 @@ from rest_framework import serializers
class BaseSerializer(serializers.ModelSerializer):
+ """
+ Base serializer providing common functionality for all model serializers.
+
+ Features field filtering, dynamic expansion of related fields, and standardized
+ primary key handling for consistent API responses across the application.
+ """
+
id = serializers.PrimaryKeyRelatedField(read_only=True)
def __init__(self, *args, **kwargs):
diff --git a/apps/api/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py
index 7a78b6664..cf057d842 100644
--- a/apps/api/plane/api/serializers/cycle.py
+++ b/apps/api/plane/api/serializers/cycle.py
@@ -8,16 +8,13 @@ from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
-class CycleSerializer(BaseSerializer):
- total_issues = serializers.IntegerField(read_only=True)
- cancelled_issues = serializers.IntegerField(read_only=True)
- completed_issues = serializers.IntegerField(read_only=True)
- started_issues = serializers.IntegerField(read_only=True)
- unstarted_issues = serializers.IntegerField(read_only=True)
- backlog_issues = serializers.IntegerField(read_only=True)
- total_estimates = serializers.FloatField(read_only=True)
- completed_estimates = serializers.FloatField(read_only=True)
- started_estimates = serializers.FloatField(read_only=True)
+class CycleCreateSerializer(BaseSerializer):
+ """
+ Serializer for creating cycles with timezone handling and date validation.
+
+ Manages cycle creation including project timezone conversion, date range validation,
+ and UTC normalization for time-bound iteration planning and sprint management.
+ """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -27,6 +24,29 @@ class CycleSerializer(BaseSerializer):
self.fields["start_date"].timezone = project_timezone
self.fields["end_date"].timezone = project_timezone
+ class Meta:
+ model = Cycle
+ fields = [
+ "name",
+ "description",
+ "start_date",
+ "end_date",
+ "owned_by",
+ "external_source",
+ "external_id",
+ "timezone",
+ ]
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ ]
+
def validate(self, data):
if (
data.get("start_date", None) is not None
@@ -59,6 +79,40 @@ class CycleSerializer(BaseSerializer):
)
return data
+
+class CycleUpdateSerializer(CycleCreateSerializer):
+ """
+ Serializer for updating cycles with enhanced ownership management.
+
+ Extends cycle creation with update-specific features including ownership
+ assignment and modification tracking for cycle lifecycle management.
+ """
+
+ class Meta(CycleCreateSerializer.Meta):
+ model = Cycle
+ fields = CycleCreateSerializer.Meta.fields + [
+ "owned_by",
+ ]
+
+
+class CycleSerializer(BaseSerializer):
+ """
+ Cycle serializer with comprehensive project metrics and time tracking.
+
+ Provides cycle details including work item counts by status, progress estimates,
+ and time-bound iteration data for project management and sprint planning.
+ """
+
+ total_issues = serializers.IntegerField(read_only=True)
+ cancelled_issues = serializers.IntegerField(read_only=True)
+ completed_issues = serializers.IntegerField(read_only=True)
+ started_issues = serializers.IntegerField(read_only=True)
+ unstarted_issues = serializers.IntegerField(read_only=True)
+ backlog_issues = serializers.IntegerField(read_only=True)
+ total_estimates = serializers.FloatField(read_only=True)
+ completed_estimates = serializers.FloatField(read_only=True)
+ started_estimates = serializers.FloatField(read_only=True)
+
class Meta:
model = Cycle
fields = "__all__"
@@ -76,6 +130,13 @@ class CycleSerializer(BaseSerializer):
class CycleIssueSerializer(BaseSerializer):
+ """
+ Serializer for cycle-issue relationships with sub-issue counting.
+
+ Manages the association between cycles and work items, including
+ hierarchical issue tracking for nested work item structures.
+ """
+
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -85,6 +146,39 @@ class CycleIssueSerializer(BaseSerializer):
class CycleLiteSerializer(BaseSerializer):
+ """
+ Lightweight cycle serializer for minimal data transfer.
+
+ Provides essential cycle information without computed metrics,
+ optimized for list views and reference lookups.
+ """
+
class Meta:
model = Cycle
fields = "__all__"
+
+
+class CycleIssueRequestSerializer(serializers.Serializer):
+ """
+ Serializer for bulk work item assignment to cycles.
+
+ Validates work item ID lists for batch operations including
+ cycle assignment and sprint planning workflows.
+ """
+
+ issues = serializers.ListField(
+ child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle"
+ )
+
+
+class TransferCycleIssueRequestSerializer(serializers.Serializer):
+ """
+ Serializer for transferring work items between cycles.
+
+ Handles work item migration between cycles including validation
+ and relationship updates for sprint reallocation workflows.
+ """
+
+ new_cycle_id = serializers.UUIDField(
+ help_text="ID of the target cycle to transfer issues to"
+ )
diff --git a/apps/api/plane/api/serializers/estimate.py b/apps/api/plane/api/serializers/estimate.py
index 0d9235dad..b670006d5 100644
--- a/apps/api/plane/api/serializers/estimate.py
+++ b/apps/api/plane/api/serializers/estimate.py
@@ -4,6 +4,13 @@ from .base import BaseSerializer
class EstimatePointSerializer(BaseSerializer):
+ """
+ Serializer for project estimation points and story point values.
+
+ Handles numeric estimation data for work item sizing and sprint planning,
+ providing standardized point values for project velocity calculations.
+ """
+
class Meta:
model = EstimatePoint
fields = ["id", "value"]
diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py
index 69c85ed61..32f8bf2da 100644
--- a/apps/api/plane/api/serializers/intake.py
+++ b/apps/api/plane/api/serializers/intake.py
@@ -1,11 +1,77 @@
# Module improts
from .base import BaseSerializer
from .issue import IssueExpandSerializer
-from plane.db.models import IntakeIssue
+from plane.db.models import IntakeIssue, Issue
from rest_framework import serializers
+class IssueForIntakeSerializer(BaseSerializer):
+ """
+ Serializer for work item data within intake submissions.
+
+ Handles essential work item fields for intake processing including
+ content validation and priority assignment for triage workflows.
+ """
+
+ class Meta:
+ model = Issue
+ fields = [
+ "name",
+ "description",
+ "description_html",
+ "priority",
+ ]
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ ]
+
+
+class IntakeIssueCreateSerializer(BaseSerializer):
+ """
+ Serializer for creating intake work items with embedded issue data.
+
+ Manages intake work item creation including nested issue creation,
+ status assignment, and source tracking for issue queue management.
+ """
+
+ issue = IssueForIntakeSerializer(help_text="Issue data for the intake issue")
+
+ class Meta:
+ model = IntakeIssue
+ fields = [
+ "issue",
+ "intake",
+ "status",
+ "snoozed_till",
+ "duplicate_to",
+ "source",
+ "source_email",
+ ]
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ ]
+
+
class IntakeIssueSerializer(BaseSerializer):
+ """
+ Comprehensive serializer for intake work items with expanded issue details.
+
+ Provides full intake work item data including embedded issue information,
+ status tracking, and triage metadata for issue queue management.
+ """
+
issue_detail = IssueExpandSerializer(read_only=True, source="issue")
inbox = serializers.UUIDField(source="intake.id", read_only=True)
@@ -22,3 +88,53 @@ class IntakeIssueSerializer(BaseSerializer):
"created_at",
"updated_at",
]
+
+
+class IntakeIssueUpdateSerializer(BaseSerializer):
+ """
+ Serializer for updating intake work items and their associated issues.
+
+ Handles intake work item modifications including status changes, triage decisions,
+ and embedded issue updates for issue queue processing workflows.
+ """
+
+ issue = IssueForIntakeSerializer(
+ required=False, help_text="Issue data to update in the intake issue"
+ )
+
+ class Meta:
+ model = IntakeIssue
+ fields = [
+ "status",
+ "snoozed_till",
+ "duplicate_to",
+ "source",
+ "source_email",
+ "issue",
+ ]
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ ]
+
+
+class IssueDataSerializer(serializers.Serializer):
+ """
+ Serializer for nested work item data in intake request payloads.
+
+ Validates core work item fields within intake requests including
+ content formatting, priority levels, and metadata for issue creation.
+ """
+
+ name = serializers.CharField(max_length=255, help_text="Issue name")
+ description_html = serializers.CharField(
+ required=False, allow_null=True, help_text="Issue description HTML"
+ )
+ priority = serializers.ChoiceField(
+ choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority"
+ )
diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py
index f906d4085..e23a356da 100644
--- a/apps/api/plane/api/serializers/issue.py
+++ b/apps/api/plane/api/serializers/issue.py
@@ -34,6 +34,13 @@ from django.core.validators import URLValidator
class IssueSerializer(BaseSerializer):
+ """
+ Comprehensive work item serializer with full relationship management.
+
+ Handles complete work item lifecycle including assignees, labels, validation,
+ and related model updates. Supports dynamic field expansion and HTML content processing.
+ """
+
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(
queryset=User.objects.values_list("id", flat=True)
@@ -300,13 +307,58 @@ class IssueSerializer(BaseSerializer):
class IssueLiteSerializer(BaseSerializer):
+ """
+ Lightweight work item serializer for minimal data transfer.
+
+ Provides essential work item identifiers optimized for list views,
+ references, and performance-critical operations.
+ """
+
class Meta:
model = Issue
fields = ["id", "sequence_id", "project_id"]
read_only_fields = fields
+class LabelCreateUpdateSerializer(BaseSerializer):
+ """
+ Serializer for creating and updating work item labels.
+
+ Manages label metadata including colors, descriptions, hierarchy,
+ and sorting for work item categorization and filtering.
+ """
+
+ class Meta:
+ model = Label
+ fields = [
+ "name",
+ "color",
+ "description",
+ "external_source",
+ "external_id",
+ "parent",
+ "sort_order",
+ ]
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ ]
+
+
class LabelSerializer(BaseSerializer):
+ """
+ Full serializer for work item labels with complete metadata.
+
+ Provides comprehensive label information including hierarchical relationships,
+ visual properties, and organizational data for work item tagging.
+ """
+
class Meta:
model = Label
fields = "__all__"
@@ -322,10 +374,17 @@ class LabelSerializer(BaseSerializer):
]
-class IssueLinkSerializer(BaseSerializer):
+class IssueLinkCreateSerializer(BaseSerializer):
+ """
+ Serializer for creating work item external links with validation.
+
+ Handles URL validation, format checking, and duplicate prevention
+ for attaching external resources to work items.
+ """
+
class Meta:
model = IssueLink
- fields = "__all__"
+ fields = ["url", "issue_id"]
read_only_fields = [
"id",
"workspace",
@@ -361,6 +420,22 @@ class IssueLinkSerializer(BaseSerializer):
)
return IssueLink.objects.create(**validated_data)
+
+class IssueLinkUpdateSerializer(IssueLinkCreateSerializer):
+ """
+ Serializer for updating work item external links.
+
+ Extends link creation with update-specific validation to prevent
+ URL conflicts and maintain link integrity during modifications.
+ """
+
+ class Meta(IssueLinkCreateSerializer.Meta):
+ model = IssueLink
+ fields = IssueLinkCreateSerializer.Meta.fields + [
+ "issue_id",
+ ]
+ read_only_fields = IssueLinkCreateSerializer.Meta.read_only_fields
+
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
@@ -376,7 +451,37 @@ class IssueLinkSerializer(BaseSerializer):
return super().update(instance, validated_data)
+class IssueLinkSerializer(BaseSerializer):
+ """
+ Full serializer for work item external links.
+
+ Provides complete link information including metadata and timestamps
+ for managing external resource associations with work items.
+ """
+
+ class Meta:
+ model = IssueLink
+ fields = "__all__"
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "issue",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ ]
+
+
class IssueAttachmentSerializer(BaseSerializer):
+ """
+ Serializer for work item file attachments.
+
+ Manages file asset associations with work items including metadata,
+ storage information, and access control for document management.
+ """
+
class Meta:
model = FileAsset
fields = "__all__"
@@ -390,7 +495,47 @@ class IssueAttachmentSerializer(BaseSerializer):
]
+class IssueCommentCreateSerializer(BaseSerializer):
+ """
+ Serializer for creating work item comments.
+
+ Handles comment creation with JSON and HTML content support,
+ access control, and external integration tracking.
+ """
+
+ class Meta:
+ model = IssueComment
+ fields = [
+ "comment_json",
+ "comment_html",
+ "access",
+ "external_source",
+ "external_id",
+ ]
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "issue",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ "actor",
+ "comment_stripped",
+ "edited_at",
+ ]
+
+
class IssueCommentSerializer(BaseSerializer):
+ """
+ Full serializer for work item comments with membership context.
+
+ Provides complete comment data including member status, content formatting,
+ and edit tracking for collaborative work item discussions.
+ """
+
is_member = serializers.BooleanField(read_only=True)
class Meta:
@@ -420,12 +565,26 @@ class IssueCommentSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer):
+ """
+ Serializer for work item activity and change history.
+
+ Tracks and represents work item modifications, state changes,
+ and user interactions for audit trails and activity feeds.
+ """
+
class Meta:
model = IssueActivity
exclude = ["created_by", "updated_by"]
class CycleIssueSerializer(BaseSerializer):
+ """
+ Serializer for work items within cycles.
+
+ Provides cycle context for work items including cycle metadata
+ and timing information for sprint and iteration management.
+ """
+
cycle = CycleSerializer(read_only=True)
class Meta:
@@ -433,6 +592,13 @@ class CycleIssueSerializer(BaseSerializer):
class ModuleIssueSerializer(BaseSerializer):
+ """
+ Serializer for work items within modules.
+
+ Provides module context for work items including module metadata
+ and organizational information for feature-based work grouping.
+ """
+
module = ModuleSerializer(read_only=True)
class Meta:
@@ -440,12 +606,26 @@ class ModuleIssueSerializer(BaseSerializer):
class LabelLiteSerializer(BaseSerializer):
+ """
+ Lightweight label serializer for minimal data transfer.
+
+ Provides essential label information with visual properties,
+ optimized for UI display and performance-critical operations.
+ """
+
class Meta:
model = Label
fields = ["id", "name", "color"]
class IssueExpandSerializer(BaseSerializer):
+ """
+ Extended work item serializer with full relationship expansion.
+
+ Provides work items with expanded related data including cycles, modules,
+ labels, assignees, and states for comprehensive data representation.
+ """
+
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
@@ -484,3 +664,41 @@ class IssueExpandSerializer(BaseSerializer):
"created_at",
"updated_at",
]
+
+
+class IssueAttachmentUploadSerializer(serializers.Serializer):
+ """
+ Serializer for work item attachment upload request validation.
+
+ Handles file upload metadata validation including size, type, and external
+ integration tracking for secure work item document attachment workflows.
+ """
+
+ name = serializers.CharField(help_text="Original filename of the asset")
+ type = serializers.CharField(required=False, help_text="MIME type of the file")
+ size = serializers.IntegerField(help_text="File size in bytes")
+ external_id = serializers.CharField(
+ required=False,
+ help_text="External identifier for the asset (for integration tracking)",
+ )
+ external_source = serializers.CharField(
+ required=False, help_text="External source system (for integration tracking)"
+ )
+
+
+class IssueSearchSerializer(serializers.Serializer):
+ """
+ Serializer for work item search result data formatting.
+
+ Provides standardized search result structure including work item identifiers,
+ project context, and workspace information for search API responses.
+ """
+
+ id = serializers.CharField(required=True, help_text="Issue ID")
+ name = serializers.CharField(required=True, help_text="Issue name")
+ sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID")
+ project__identifier = serializers.CharField(
+ required=True, help_text="Project identifier"
+ )
+ project_id = serializers.CharField(required=True, help_text="Project ID")
+ workspace__slug = serializers.CharField(required=True, help_text="Workspace slug")
diff --git a/apps/api/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py
index ace4e15c8..167386997 100644
--- a/apps/api/plane/api/serializers/module.py
+++ b/apps/api/plane/api/serializers/module.py
@@ -13,24 +13,33 @@ from plane.db.models import (
)
-class ModuleSerializer(BaseSerializer):
+class ModuleCreateSerializer(BaseSerializer):
+ """
+ Serializer for creating modules with member validation and date checking.
+
+ Handles module creation including member assignment validation, date range verification,
+ and duplicate name prevention for feature-based project organization setup.
+ """
+
members = serializers.ListField(
- child=serializers.PrimaryKeyRelatedField(
- queryset=User.objects.values_list("id", flat=True)
- ),
+ child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
- total_issues = serializers.IntegerField(read_only=True)
- cancelled_issues = serializers.IntegerField(read_only=True)
- completed_issues = serializers.IntegerField(read_only=True)
- started_issues = serializers.IntegerField(read_only=True)
- unstarted_issues = serializers.IntegerField(read_only=True)
- backlog_issues = serializers.IntegerField(read_only=True)
class Meta:
model = Module
- fields = "__all__"
+ fields = [
+ "name",
+ "description",
+ "start_date",
+ "target_date",
+ "status",
+ "lead",
+ "members",
+ "external_source",
+ "external_id",
+ ]
read_only_fields = [
"id",
"workspace",
@@ -42,11 +51,6 @@ class ModuleSerializer(BaseSerializer):
"deleted_at",
]
- def to_representation(self, instance):
- data = super().to_representation(instance)
- data["members"] = [str(member.id) for member in instance.members.all()]
- return data
-
def validate(self, data):
if (
data.get("start_date", None) is not None
@@ -96,6 +100,22 @@ class ModuleSerializer(BaseSerializer):
return module
+
+class ModuleUpdateSerializer(ModuleCreateSerializer):
+ """
+ Serializer for updating modules with enhanced validation and member management.
+
+ Extends module creation with update-specific validations including member reassignment,
+ name conflict checking, and relationship management for module modifications.
+ """
+
+ class Meta(ModuleCreateSerializer.Meta):
+ model = Module
+ fields = ModuleCreateSerializer.Meta.fields + [
+ "members",
+ ]
+ read_only_fields = ModuleCreateSerializer.Meta.read_only_fields
+
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
module_name = validated_data.get("name")
@@ -131,7 +151,54 @@ class ModuleSerializer(BaseSerializer):
return super().update(instance, validated_data)
+class ModuleSerializer(BaseSerializer):
+ """
+ Comprehensive module serializer with work item metrics and member management.
+
+ Provides complete module data including work item counts by status, member relationships,
+ and progress tracking for feature-based project organization.
+ """
+
+ members = serializers.ListField(
+ child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
+ write_only=True,
+ required=False,
+ )
+ total_issues = serializers.IntegerField(read_only=True)
+ cancelled_issues = serializers.IntegerField(read_only=True)
+ completed_issues = serializers.IntegerField(read_only=True)
+ started_issues = serializers.IntegerField(read_only=True)
+ unstarted_issues = serializers.IntegerField(read_only=True)
+ backlog_issues = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = Module
+ fields = "__all__"
+ read_only_fields = [
+ "id",
+ "workspace",
+ "project",
+ "created_by",
+ "updated_by",
+ "created_at",
+ "updated_at",
+ "deleted_at",
+ ]
+
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ data["members"] = [str(member.id) for member in instance.members.all()]
+ return data
+
+
class ModuleIssueSerializer(BaseSerializer):
+ """
+ Serializer for module-work item relationships with sub-item counting.
+
+ Manages the association between modules and work items, including
+ hierarchical issue tracking for nested work item structures.
+ """
+
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -149,6 +216,13 @@ class ModuleIssueSerializer(BaseSerializer):
class ModuleLinkSerializer(BaseSerializer):
+ """
+ Serializer for module external links with URL validation.
+
+ Handles external resource associations with modules including
+ URL validation and duplicate prevention for reference management.
+ """
+
class Meta:
model = ModuleLink
fields = "__all__"
@@ -174,6 +248,27 @@ class ModuleLinkSerializer(BaseSerializer):
class ModuleLiteSerializer(BaseSerializer):
+ """
+ Lightweight module serializer for minimal data transfer.
+
+ Provides essential module information without computed metrics,
+ optimized for list views and reference lookups.
+ """
+
class Meta:
model = Module
fields = "__all__"
+
+
+class ModuleIssueRequestSerializer(serializers.Serializer):
+ """
+ Serializer for bulk work item assignment to modules.
+
+ Validates work item ID lists for batch operations including
+ module assignment and work item organization workflows.
+ """
+
+ issues = serializers.ListField(
+ child=serializers.UUIDField(),
+ help_text="List of issue IDs to add to the module",
+ )
diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py
index c76652e1e..e0b624840 100644
--- a/apps/api/plane/api/serializers/project.py
+++ b/apps/api/plane/api/serializers/project.py
@@ -2,12 +2,146 @@
from rest_framework import serializers
# Module imports
-from plane.db.models import Project, ProjectIdentifier, WorkspaceMember
+from plane.db.models import (
+ Project,
+ ProjectIdentifier,
+ WorkspaceMember,
+ State,
+ Estimate,
+)
from .base import BaseSerializer
+class ProjectCreateSerializer(BaseSerializer):
+ """
+ Serializer for creating projects with workspace validation.
+
+ Handles project creation including identifier validation, member verification,
+ and workspace association for new project initialization.
+ """
+
+ class Meta:
+ model = Project
+ fields = [
+ "name",
+ "description",
+ "project_lead",
+ "default_assignee",
+ "identifier",
+ "icon_prop",
+ "emoji",
+ "cover_image",
+ "module_view",
+ "cycle_view",
+ "issue_views_view",
+ "page_view",
+ "intake_view",
+ "guest_view_all_features",
+ "archive_in",
+ "close_in",
+ "timezone",
+ ]
+
+ read_only_fields = [
+ "id",
+ "workspace",
+ "created_at",
+ "updated_at",
+ "created_by",
+ "updated_by",
+ ]
+
+ def validate(self, data):
+ if data.get("project_lead", None) is not None:
+ # Check if the project lead is a member of the workspace
+ if not WorkspaceMember.objects.filter(
+ workspace_id=self.context["workspace_id"],
+ member_id=data.get("project_lead"),
+ ).exists():
+ raise serializers.ValidationError(
+ "Project lead should be a user in the workspace"
+ )
+
+ if data.get("default_assignee", None) is not None:
+ # Check if the default assignee is a member of the workspace
+ if not WorkspaceMember.objects.filter(
+ workspace_id=self.context["workspace_id"],
+ member_id=data.get("default_assignee"),
+ ).exists():
+ raise serializers.ValidationError(
+ "Default assignee should be a user in the workspace"
+ )
+
+ return data
+
+ def create(self, validated_data):
+ identifier = validated_data.get("identifier", "").strip().upper()
+ if identifier == "":
+ raise serializers.ValidationError(detail="Project Identifier is required")
+
+ if ProjectIdentifier.objects.filter(
+ name=identifier, workspace_id=self.context["workspace_id"]
+ ).exists():
+ raise serializers.ValidationError(detail="Project Identifier is taken")
+
+ project = Project.objects.create(
+ **validated_data, workspace_id=self.context["workspace_id"]
+ )
+ return project
+
+
+class ProjectUpdateSerializer(ProjectCreateSerializer):
+ """
+ Serializer for updating projects with enhanced state and estimation management.
+
+ Extends project creation with update-specific validations including default state
+ assignment, estimation configuration, and project setting modifications.
+ """
+
+ class Meta(ProjectCreateSerializer.Meta):
+ model = Project
+ fields = ProjectCreateSerializer.Meta.fields + [
+ "default_state",
+ "estimate",
+ ]
+
+ read_only_fields = ProjectCreateSerializer.Meta.read_only_fields
+
+ def update(self, instance, validated_data):
+ """Update a project"""
+ if (
+ validated_data.get("default_state", None) is not None
+ and not State.objects.filter(
+ project=instance, id=validated_data.get("default_state")
+ ).exists()
+ ):
+ # Check if the default state is a state in the project
+ raise serializers.ValidationError(
+ "Default state should be a state in the project"
+ )
+
+ if (
+ validated_data.get("estimate", None) is not None
+ and not Estimate.objects.filter(
+ project=instance, id=validated_data.get("estimate")
+ ).exists()
+ ):
+ # Check if the estimate is a estimate in the project
+ raise serializers.ValidationError(
+ "Estimate should be a estimate in the project"
+ )
+ return super().update(instance, validated_data)
+
+
class ProjectSerializer(BaseSerializer):
+ """
+ Comprehensive project serializer with metrics and member context.
+
+ Provides complete project data including member counts, cycle/module totals,
+ deployment status, and user-specific context for project management.
+ """
+
total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True)
total_modules = serializers.IntegerField(read_only=True)
@@ -81,6 +215,13 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer):
+ """
+ Lightweight project serializer for minimal data transfer.
+
+ Provides essential project information including identifiers, visual properties,
+ and basic metadata optimized for list views and references.
+ """
+
cover_image_url = serializers.CharField(read_only=True)
class Meta:
diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py
index 85b4c41ed..150c238fc 100644
--- a/apps/api/plane/api/serializers/state.py
+++ b/apps/api/plane/api/serializers/state.py
@@ -4,6 +4,13 @@ from plane.db.models import State
class StateSerializer(BaseSerializer):
+ """
+ Serializer for work item states with default state management.
+
+ Handles state creation and updates including default state validation
+ and automatic default state switching for workflow management.
+ """
+
def validate(self, data):
# If the default is being provided then make all other states default False
if data.get("default", False):
@@ -24,10 +31,18 @@ class StateSerializer(BaseSerializer):
"workspace",
"project",
"deleted_at",
+ "slug",
]
class StateLiteSerializer(BaseSerializer):
+ """
+ Lightweight state serializer for minimal data transfer.
+
+ Provides essential state information including visual properties
+ and grouping data optimized for UI display and filtering.
+ """
+
class Meta:
model = State
fields = ["id", "name", "color", "group"]
diff --git a/apps/api/plane/api/serializers/user.py b/apps/api/plane/api/serializers/user.py
index b266d7d54..805eb9fe1 100644
--- a/apps/api/plane/api/serializers/user.py
+++ b/apps/api/plane/api/serializers/user.py
@@ -1,3 +1,5 @@
+from rest_framework import serializers
+
# Module imports
from plane.db.models import User
@@ -5,6 +7,18 @@ from .base import BaseSerializer
class UserLiteSerializer(BaseSerializer):
+ """
+ Lightweight user serializer for minimal data transfer.
+
+ Provides essential user information including names, avatar, and contact details
+ optimized for member lists, assignee displays, and user references.
+ """
+
+ avatar_url = serializers.CharField(
+ help_text="Avatar URL",
+ read_only=True,
+ )
+
class Meta:
model = User
fields = [
diff --git a/apps/api/plane/api/serializers/workspace.py b/apps/api/plane/api/serializers/workspace.py
index 84453b8e0..e98683c2f 100644
--- a/apps/api/plane/api/serializers/workspace.py
+++ b/apps/api/plane/api/serializers/workspace.py
@@ -4,7 +4,12 @@ from .base import BaseSerializer
class WorkspaceLiteSerializer(BaseSerializer):
- """Lite serializer with only required fields"""
+ """
+ Lightweight workspace serializer for minimal data transfer.
+
+ Provides essential workspace identifiers including name, slug, and ID
+ optimized for navigation, references, and performance-critical operations.
+ """
class Meta:
model = Workspace
diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py
index d9b55e20e..ed187549d 100644
--- a/apps/api/plane/api/urls/__init__.py
+++ b/apps/api/plane/api/urls/__init__.py
@@ -5,8 +5,11 @@ from .cycle import urlpatterns as cycle_patterns
from .module import urlpatterns as module_patterns
from .intake import urlpatterns as intake_patterns
from .member import urlpatterns as member_patterns
+from .asset import urlpatterns as asset_patterns
+from .user import urlpatterns as user_patterns
urlpatterns = [
+ *asset_patterns,
*project_patterns,
*state_patterns,
*issue_patterns,
@@ -14,4 +17,5 @@ urlpatterns = [
*module_patterns,
*intake_patterns,
*member_patterns,
+ *user_patterns,
]
diff --git a/apps/api/plane/api/urls/asset.py b/apps/api/plane/api/urls/asset.py
new file mode 100644
index 000000000..5bdd4d914
--- /dev/null
+++ b/apps/api/plane/api/urls/asset.py
@@ -0,0 +1,40 @@
+from django.urls import path
+
+from plane.api.views import (
+ UserAssetEndpoint,
+ UserServerAssetEndpoint,
+ GenericAssetEndpoint,
+)
+
+urlpatterns = [
+ path(
+ "assets/user-assets/",
+ UserAssetEndpoint.as_view(http_method_names=["post"]),
+ name="user-assets",
+ ),
+ path(
+ "assets/user-assets//",
+ UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
+ name="user-assets-detail",
+ ),
+ path(
+ "assets/user-assets/server/",
+ UserServerAssetEndpoint.as_view(http_method_names=["post"]),
+ name="user-server-assets",
+ ),
+ path(
+ "assets/user-assets//server/",
+ UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
+ name="user-server-assets-detail",
+ ),
+ path(
+ "workspaces//assets/",
+ GenericAssetEndpoint.as_view(http_method_names=["post"]),
+ name="generic-asset",
+ ),
+ path(
+ "workspaces//assets//",
+ GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]),
+ name="generic-asset-detail",
+ ),
+]
diff --git a/apps/api/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py
index b0ae21174..bd7136aa2 100644
--- a/apps/api/plane/api/urls/cycle.py
+++ b/apps/api/plane/api/urls/cycle.py
@@ -1,8 +1,10 @@
from django.urls import path
from plane.api.views.cycle import (
- CycleAPIEndpoint,
- CycleIssueAPIEndpoint,
+ CycleListCreateAPIEndpoint,
+ CycleDetailAPIEndpoint,
+ CycleIssueListCreateAPIEndpoint,
+ CycleIssueDetailAPIEndpoint,
TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint,
)
@@ -10,37 +12,42 @@ from plane.api.views.cycle import (
urlpatterns = [
path(
"workspaces//projects//cycles/",
- CycleAPIEndpoint.as_view(),
+ CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="cycles",
),
path(
"workspaces//projects//cycles//",
- CycleAPIEndpoint.as_view(),
+ CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="cycles",
),
path(
"workspaces//projects//cycles//cycle-issues/",
- CycleIssueAPIEndpoint.as_view(),
+ CycleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="cycle-issues",
),
path(
"workspaces//projects//cycles//cycle-issues//",
- CycleIssueAPIEndpoint.as_view(),
+ CycleIssueDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
name="cycle-issues",
),
path(
"workspaces//projects//cycles//transfer-issues/",
- TransferCycleIssueAPIEndpoint.as_view(),
+ TransferCycleIssueAPIEndpoint.as_view(http_method_names=["post"]),
name="transfer-issues",
),
path(
"workspaces//projects//cycles//archive/",
- CycleArchiveUnarchiveAPIEndpoint.as_view(),
+ CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
name="cycle-archive-unarchive",
),
path(
"workspaces//projects//archived-cycles/",
- CycleArchiveUnarchiveAPIEndpoint.as_view(),
+ CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
+ name="cycle-archive-unarchive",
+ ),
+ path(
+ "workspaces//projects//archived-cycles//unarchive/",
+ CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
name="cycle-archive-unarchive",
),
]
diff --git a/apps/api/plane/api/urls/intake.py b/apps/api/plane/api/urls/intake.py
index 4ef41d5f0..6af4aa4a8 100644
--- a/apps/api/plane/api/urls/intake.py
+++ b/apps/api/plane/api/urls/intake.py
@@ -1,17 +1,22 @@
from django.urls import path
-from plane.api.views import IntakeIssueAPIEndpoint
+from plane.api.views import (
+ IntakeIssueListCreateAPIEndpoint,
+ IntakeIssueDetailAPIEndpoint,
+)
urlpatterns = [
path(
"workspaces//projects//intake-issues/",
- IntakeIssueAPIEndpoint.as_view(),
+ IntakeIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="intake-issue",
),
path(
"workspaces//projects//intake-issues//",
- IntakeIssueAPIEndpoint.as_view(),
+ IntakeIssueDetailAPIEndpoint.as_view(
+ http_method_names=["get", "patch", "delete"]
+ ),
name="intake-issue",
),
]
diff --git a/apps/api/plane/api/urls/issue.py b/apps/api/plane/api/urls/issue.py
index 71ab39855..c8d1ea4af 100644
--- a/apps/api/plane/api/urls/issue.py
+++ b/apps/api/plane/api/urls/issue.py
@@ -1,79 +1,95 @@
from django.urls import path
from plane.api.views import (
- IssueAPIEndpoint,
- LabelAPIEndpoint,
- IssueLinkAPIEndpoint,
- IssueCommentAPIEndpoint,
- IssueActivityAPIEndpoint,
+ IssueListCreateAPIEndpoint,
+ IssueDetailAPIEndpoint,
+ LabelListCreateAPIEndpoint,
+ LabelDetailAPIEndpoint,
+ IssueLinkListCreateAPIEndpoint,
+ IssueLinkDetailAPIEndpoint,
+ IssueCommentListCreateAPIEndpoint,
+ IssueCommentDetailAPIEndpoint,
+ IssueActivityListAPIEndpoint,
+ IssueActivityDetailAPIEndpoint,
+ IssueAttachmentListCreateAPIEndpoint,
+ IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
- IssueAttachmentEndpoint,
+ IssueSearchEndpoint,
)
urlpatterns = [
path(
- "workspaces//issues/-/",
- WorkspaceIssueAPIEndpoint.as_view(),
+ "workspaces//issues/search/",
+ IssueSearchEndpoint.as_view(http_method_names=["get"]),
+ name="issue-search",
+ ),
+ path(
+ "workspaces//issues/-/",
+ WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
name="issue-by-identifier",
),
path(
"workspaces//projects//issues/",
- IssueAPIEndpoint.as_view(),
+ IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="issue",
),
path(
"workspaces//projects//issues//",
- IssueAPIEndpoint.as_view(),
+ IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="issue",
),
path(
"workspaces//projects//labels/",
- LabelAPIEndpoint.as_view(),
+ LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="label",
),
path(
"workspaces//projects//labels//",
- LabelAPIEndpoint.as_view(),
+ LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="label",
),
path(
"workspaces//projects//issues//links/",
- IssueLinkAPIEndpoint.as_view(),
+ IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="link",
),
path(
"workspaces//projects//issues//links//",
- IssueLinkAPIEndpoint.as_view(),
+ IssueLinkDetailAPIEndpoint.as_view(
+ http_method_names=["get", "patch", "delete"]
+ ),
name="link",
),
path(
"workspaces//projects//issues//comments/",
- IssueCommentAPIEndpoint.as_view(),
+ IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="comment",
),
path(
"workspaces//projects//issues//comments//",
- IssueCommentAPIEndpoint.as_view(),
+ IssueCommentDetailAPIEndpoint.as_view(
+ http_method_names=["get", "patch", "delete"]
+ ),
name="comment",
),
path(
"workspaces//projects//issues//activities/",
- IssueActivityAPIEndpoint.as_view(),
+ IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
name="activity",
),
path(
"workspaces//projects//issues//activities//",
- IssueActivityAPIEndpoint.as_view(),
+ IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
name="activity",
),
path(
"workspaces//projects//issues//issue-attachments/",
- IssueAttachmentEndpoint.as_view(),
+ IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="attachment",
),
path(
"workspaces//projects//issues//issue-attachments//",
- IssueAttachmentEndpoint.as_view(),
+ IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
name="issue-attachment",
),
]
diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py
index 1ec9cddb3..14a09c832 100644
--- a/apps/api/plane/api/urls/member.py
+++ b/apps/api/plane/api/urls/member.py
@@ -1,11 +1,16 @@
from django.urls import path
-from plane.api.views import ProjectMemberAPIEndpoint
+from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [
path(
"workspaces//projects//members/",
- ProjectMemberAPIEndpoint.as_view(),
- name="users",
- )
+ ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
+ name="project-members",
+ ),
+ path(
+ "workspaces//members/",
+ WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
+ name="workspace-members",
+ ),
]
diff --git a/apps/api/plane/api/urls/module.py b/apps/api/plane/api/urls/module.py
index a131f4d4f..578f5c860 100644
--- a/apps/api/plane/api/urls/module.py
+++ b/apps/api/plane/api/urls/module.py
@@ -1,40 +1,47 @@
from django.urls import path
from plane.api.views import (
- ModuleAPIEndpoint,
- ModuleIssueAPIEndpoint,
+ ModuleListCreateAPIEndpoint,
+ ModuleDetailAPIEndpoint,
+ ModuleIssueListCreateAPIEndpoint,
+ ModuleIssueDetailAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
urlpatterns = [
path(
"workspaces//projects//modules/",
- ModuleAPIEndpoint.as_view(),
+ ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="modules",
),
path(
"workspaces//projects//modules//",
- ModuleAPIEndpoint.as_view(),
- name="modules",
+ ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
+ name="modules-detail",
),
path(
"workspaces//projects//modules//module-issues/",
- ModuleIssueAPIEndpoint.as_view(),
+ ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="module-issues",
),
path(
"workspaces//projects//modules//module-issues//",
- ModuleIssueAPIEndpoint.as_view(),
- name="module-issues",
+ ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]),
+ name="module-issues-detail",
),
path(
"workspaces//projects//modules//archive/",
- ModuleArchiveUnarchiveAPIEndpoint.as_view(),
- name="module-archive-unarchive",
+ ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
+ name="module-archive",
),
path(
"workspaces//projects//archived-modules/",
- ModuleArchiveUnarchiveAPIEndpoint.as_view(),
- name="module-archive-unarchive",
+ ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
+ name="module-archive-list",
+ ),
+ path(
+ "workspaces//projects//archived-modules//unarchive/",
+ ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
+ name="module-unarchive",
),
]
diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py
index d35c2cdd5..4cfc5a198 100644
--- a/apps/api/plane/api/urls/project.py
+++ b/apps/api/plane/api/urls/project.py
@@ -1,19 +1,27 @@
from django.urls import path
-from plane.api.views import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
+from plane.api.views import (
+ ProjectListCreateAPIEndpoint,
+ ProjectDetailAPIEndpoint,
+ ProjectArchiveUnarchiveAPIEndpoint,
+)
urlpatterns = [
path(
- "workspaces//projects/", ProjectAPIEndpoint.as_view(), name="project"
+ "workspaces//projects/",
+ ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
+ name="project",
),
path(
"workspaces//projects//",
- ProjectAPIEndpoint.as_view(),
+ ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="project",
),
path(
"workspaces//projects//archive/",
- ProjectArchiveUnarchiveAPIEndpoint.as_view(),
+ ProjectArchiveUnarchiveAPIEndpoint.as_view(
+ http_method_names=["post", "delete"]
+ ),
name="project-archive-unarchive",
),
]
diff --git a/apps/api/plane/api/urls/schema.py b/apps/api/plane/api/urls/schema.py
new file mode 100644
index 000000000..781dbe9de
--- /dev/null
+++ b/apps/api/plane/api/urls/schema.py
@@ -0,0 +1,20 @@
+from drf_spectacular.views import (
+ SpectacularAPIView,
+ SpectacularRedocView,
+ SpectacularSwaggerView,
+)
+from django.urls import path
+
+urlpatterns = [
+ path("schema/", SpectacularAPIView.as_view(), name="schema"),
+ path(
+ "schema/swagger-ui/",
+ SpectacularSwaggerView.as_view(url_name="schema"),
+ name="swagger-ui",
+ ),
+ path(
+ "schema/redoc/",
+ SpectacularRedocView.as_view(url_name="schema"),
+ name="redoc",
+ ),
+]
diff --git a/apps/api/plane/api/urls/state.py b/apps/api/plane/api/urls/state.py
index b03f386e6..e35012a20 100644
--- a/apps/api/plane/api/urls/state.py
+++ b/apps/api/plane/api/urls/state.py
@@ -1,16 +1,19 @@
from django.urls import path
-from plane.api.views import StateAPIEndpoint
+from plane.api.views import (
+ StateListCreateAPIEndpoint,
+ StateDetailAPIEndpoint,
+)
urlpatterns = [
path(
"workspaces//projects//states/",
- StateAPIEndpoint.as_view(),
+ StateListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="states",
),
path(
"workspaces//projects//states//",
- StateAPIEndpoint.as_view(),
+ StateDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="states",
),
]
diff --git a/apps/api/plane/api/urls/user.py b/apps/api/plane/api/urls/user.py
new file mode 100644
index 000000000..461b08333
--- /dev/null
+++ b/apps/api/plane/api/urls/user.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from plane.api.views import UserEndpoint
+
+urlpatterns = [
+ path(
+ "users/me/",
+ UserEndpoint.as_view(http_method_names=["get"]),
+ name="users",
+ ),
+]
diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py
index 2299f7ec5..8535d4858 100644
--- a/apps/api/plane/api/views/__init__.py
+++ b/apps/api/plane/api/views/__init__.py
@@ -1,30 +1,55 @@
-from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint
+from .project import (
+ ProjectListCreateAPIEndpoint,
+ ProjectDetailAPIEndpoint,
+ ProjectArchiveUnarchiveAPIEndpoint,
+)
-from .state import StateAPIEndpoint
+from .state import (
+ StateListCreateAPIEndpoint,
+ StateDetailAPIEndpoint,
+)
from .issue import (
WorkspaceIssueAPIEndpoint,
- IssueAPIEndpoint,
- LabelAPIEndpoint,
- IssueLinkAPIEndpoint,
- IssueCommentAPIEndpoint,
- IssueActivityAPIEndpoint,
- IssueAttachmentEndpoint,
+ IssueListCreateAPIEndpoint,
+ IssueDetailAPIEndpoint,
+ LabelListCreateAPIEndpoint,
+ LabelDetailAPIEndpoint,
+ IssueLinkListCreateAPIEndpoint,
+ IssueLinkDetailAPIEndpoint,
+ IssueCommentListCreateAPIEndpoint,
+ IssueCommentDetailAPIEndpoint,
+ IssueActivityListAPIEndpoint,
+ IssueActivityDetailAPIEndpoint,
+ IssueAttachmentListCreateAPIEndpoint,
+ IssueAttachmentDetailAPIEndpoint,
+ IssueSearchEndpoint,
)
from .cycle import (
- CycleAPIEndpoint,
- CycleIssueAPIEndpoint,
+ CycleListCreateAPIEndpoint,
+ CycleDetailAPIEndpoint,
+ CycleIssueListCreateAPIEndpoint,
+ CycleIssueDetailAPIEndpoint,
TransferCycleIssueAPIEndpoint,
CycleArchiveUnarchiveAPIEndpoint,
)
from .module import (
- ModuleAPIEndpoint,
- ModuleIssueAPIEndpoint,
+ ModuleListCreateAPIEndpoint,
+ ModuleDetailAPIEndpoint,
+ ModuleIssueListCreateAPIEndpoint,
+ ModuleIssueDetailAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
-from .member import ProjectMemberAPIEndpoint
+from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
-from .intake import IntakeIssueAPIEndpoint
+from .intake import (
+ IntakeIssueListCreateAPIEndpoint,
+ IntakeIssueDetailAPIEndpoint,
+)
+
+from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
+
+from .user import UserEndpoint
diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py
new file mode 100644
index 000000000..061a79010
--- /dev/null
+++ b/apps/api/plane/api/views/asset.py
@@ -0,0 +1,629 @@
+# Python Imports
+import uuid
+
+# Django Imports
+from django.utils import timezone
+from django.conf import settings
+
+# Third party imports
+from rest_framework import status
+from rest_framework.response import Response
+from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes
+
+# Module Imports
+from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
+from plane.settings.storage import S3Storage
+from plane.db.models import FileAsset, User, Workspace
+from plane.api.views.base import BaseAPIView
+from plane.api.serializers import (
+ UserAssetUploadSerializer,
+ AssetUpdateSerializer,
+ GenericAssetUploadSerializer,
+ GenericAssetUpdateSerializer,
+)
+from plane.utils.openapi import (
+ ASSET_ID_PARAMETER,
+ WORKSPACE_SLUG_PARAMETER,
+ PRESIGNED_URL_SUCCESS_RESPONSE,
+ GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
+ GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
+ ASSET_CONFLICT_RESPONSE,
+ ASSET_DOWNLOAD_SUCCESS_RESPONSE,
+ ASSET_DOWNLOAD_ERROR_RESPONSE,
+ ASSET_UPDATED_RESPONSE,
+ ASSET_DELETED_RESPONSE,
+ VALIDATION_ERROR_RESPONSE,
+ ASSET_NOT_FOUND_RESPONSE,
+ NOT_FOUND_RESPONSE,
+ UNAUTHORIZED_RESPONSE,
+ asset_docs,
+)
+from plane.utils.exception_logger import log_exception
+
+
+class UserAssetEndpoint(BaseAPIView):
+ """This endpoint is used to upload user profile images."""
+
+ def asset_delete(self, asset_id):
+ asset = FileAsset.objects.filter(id=asset_id).first()
+ if asset is None:
+ return
+ asset.is_deleted = True
+ asset.deleted_at = timezone.now()
+ asset.save(update_fields=["is_deleted", "deleted_at"])
+ return
+
+ def entity_asset_delete(self, entity_type, asset, request):
+ # User Avatar
+ if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
+ user = User.objects.get(id=asset.user_id)
+ user.avatar_asset_id = None
+ user.save()
+ return
+ # User Cover
+ if entity_type == FileAsset.EntityTypeContext.USER_COVER:
+ user = User.objects.get(id=asset.user_id)
+ user.cover_image_asset_id = None
+ user.save()
+ return
+ return
+
+ @asset_docs(
+ operation_id="create_user_asset_upload",
+ summary="Generate presigned URL for user asset upload",
+ description="Generate presigned URL for user asset upload",
+ request=OpenApiRequest(
+ request=UserAssetUploadSerializer,
+ examples=[
+ OpenApiExample(
+ "User Avatar Upload",
+ value={
+ "name": "profile.jpg",
+ "type": "image/jpeg",
+ "size": 1024000,
+ "entity_type": "USER_AVATAR",
+ },
+ description="Example request for uploading a user avatar",
+ ),
+ OpenApiExample(
+ "User Cover Upload",
+ value={
+ "name": "cover.jpg",
+ "type": "image/jpeg",
+ "size": 1024000,
+ "entity_type": "USER_COVER",
+ },
+ description="Example request for uploading a user cover",
+ ),
+ ],
+ ),
+ responses={
+ 200: PRESIGNED_URL_SUCCESS_RESPONSE,
+ 400: VALIDATION_ERROR_RESPONSE,
+ 401: UNAUTHORIZED_RESPONSE,
+ },
+ )
+ def post(self, request):
+ """Generate presigned URL for user asset upload.
+
+ Create a presigned URL for uploading user profile assets (avatar or cover image).
+ This endpoint generates the necessary credentials for direct S3 upload.
+ """
+ # get the asset key
+ name = request.data.get("name")
+ type = request.data.get("type", "image/jpeg")
+ size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
+ entity_type = request.data.get("entity_type", False)
+
+ # Check if the file size is within the limit
+ size_limit = min(size, settings.FILE_SIZE_LIMIT)
+
+ # Check if the entity type is allowed
+ if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
+ return Response(
+ {"error": "Invalid entity type.", "status": False},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Check if the file type is allowed
+ allowed_types = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/jpg",
+ "image/gif",
+ ]
+ if type not in allowed_types:
+ return Response(
+ {
+ "error": "Invalid file type. Only JPEG and PNG files are allowed.",
+ "status": False,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # asset key
+ asset_key = f"{uuid.uuid4().hex}-{name}"
+
+ # Create a File Asset
+ asset = FileAsset.objects.create(
+ attributes={"name": name, "type": type, "size": size_limit},
+ asset=asset_key,
+ size=size_limit,
+ user=request.user,
+ created_by=request.user,
+ entity_type=entity_type,
+ )
+
+ # Get the presigned URL
+ storage = S3Storage(request=request)
+ # Generate a presigned URL to share an S3 object
+ presigned_url = storage.generate_presigned_post(
+ object_name=asset_key, file_type=type, file_size=size_limit
+ )
+ # Return the presigned URL
+ return Response(
+ {
+ "upload_data": presigned_url,
+ "asset_id": str(asset.id),
+ "asset_url": asset.asset_url,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ @asset_docs(
+ operation_id="update_user_asset",
+ summary="Mark user asset as uploaded",
+ description="Mark user asset as uploaded",
+ parameters=[ASSET_ID_PARAMETER],
+ request=OpenApiRequest(
+ request=AssetUpdateSerializer,
+ examples=[
+ OpenApiExample(
+ "Update Asset Attributes",
+ value={
+ "attributes": {
+ "name": "updated_profile.jpg",
+ "type": "image/jpeg",
+ "size": 1024000,
+ },
+ "entity_type": "USER_AVATAR",
+ },
+ description="Example request for updating asset attributes",
+ ),
+ ],
+ ),
+ responses={
+ 204: ASSET_UPDATED_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ )
+ def patch(self, request, asset_id):
+ """Update user asset after upload completion.
+
+ Update the asset status and attributes after the file has been uploaded to S3.
+ This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
+ """
+ # get the asset id
+ asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
+ # get the storage metadata
+ asset.is_uploaded = True
+ # get the storage metadata
+ if not asset.storage_metadata:
+ get_asset_object_metadata.delay(asset_id=str(asset_id))
+ # update the attributes
+ asset.attributes = request.data.get("attributes", asset.attributes)
+ # save the asset
+ asset.save(update_fields=["is_uploaded", "attributes"])
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+ @asset_docs(
+ operation_id="delete_user_asset",
+ summary="Delete user asset",
+ parameters=[ASSET_ID_PARAMETER],
+ responses={
+ 204: ASSET_DELETED_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ )
+ def delete(self, request, asset_id):
+ """Delete user asset.
+
+ Delete a user profile asset (avatar or cover image) and remove its reference from the user profile.
+ This performs a soft delete by marking the asset as deleted and updating the user's profile.
+ """
+ asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
+ asset.is_deleted = True
+ asset.deleted_at = timezone.now()
+ # get the entity and save the asset id for the request field
+ self.entity_asset_delete(
+ entity_type=asset.entity_type, asset=asset, request=request
+ )
+ asset.save(update_fields=["is_deleted", "deleted_at"])
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class UserServerAssetEndpoint(BaseAPIView):
+ """This endpoint is used to upload user profile images."""
+
+ def asset_delete(self, asset_id):
+ asset = FileAsset.objects.filter(id=asset_id).first()
+ if asset is None:
+ return
+ asset.is_deleted = True
+ asset.deleted_at = timezone.now()
+ asset.save(update_fields=["is_deleted", "deleted_at"])
+ return
+
+ def entity_asset_delete(self, entity_type, asset, request):
+ # User Avatar
+ if entity_type == FileAsset.EntityTypeContext.USER_AVATAR:
+ user = User.objects.get(id=asset.user_id)
+ user.avatar_asset_id = None
+ user.save()
+ return
+ # User Cover
+ if entity_type == FileAsset.EntityTypeContext.USER_COVER:
+ user = User.objects.get(id=asset.user_id)
+ user.cover_image_asset_id = None
+ user.save()
+ return
+ return
+
+ @asset_docs(
+ operation_id="create_user_server_asset_upload",
+ summary="Generate presigned URL for user server asset upload",
+ request=UserAssetUploadSerializer,
+ responses={
+ 200: PRESIGNED_URL_SUCCESS_RESPONSE,
+ 400: VALIDATION_ERROR_RESPONSE,
+ },
+ )
+ def post(self, request):
+ """Generate presigned URL for user server asset upload.
+
+ Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials.
+ This endpoint generates the necessary credentials for direct S3 upload with server-side authentication.
+ """
+ # get the asset key
+ name = request.data.get("name")
+ type = request.data.get("type", "image/jpeg")
+ size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
+ entity_type = request.data.get("entity_type", False)
+
+ # Check if the file size is within the limit
+ size_limit = min(size, settings.FILE_SIZE_LIMIT)
+
+ # Check if the entity type is allowed
+ if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]:
+ return Response(
+ {"error": "Invalid entity type.", "status": False},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Check if the file type is allowed
+ allowed_types = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/jpg",
+ "image/gif",
+ ]
+ if type not in allowed_types:
+ return Response(
+ {
+ "error": "Invalid file type. Only JPEG and PNG files are allowed.",
+ "status": False,
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # asset key
+ asset_key = f"{uuid.uuid4().hex}-{name}"
+
+ # Create a File Asset
+ asset = FileAsset.objects.create(
+ attributes={"name": name, "type": type, "size": size_limit},
+ asset=asset_key,
+ size=size_limit,
+ user=request.user,
+ created_by=request.user,
+ entity_type=entity_type,
+ )
+
+ # Get the presigned URL
+ storage = S3Storage(request=request, is_server=True)
+ # Generate a presigned URL to share an S3 object
+ presigned_url = storage.generate_presigned_post(
+ object_name=asset_key, file_type=type, file_size=size_limit
+ )
+ # Return the presigned URL
+ return Response(
+ {
+ "upload_data": presigned_url,
+ "asset_id": str(asset.id),
+ "asset_url": asset.asset_url,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ @asset_docs(
+ operation_id="update_user_server_asset",
+ summary="Mark user server asset as uploaded",
+ parameters=[ASSET_ID_PARAMETER],
+ request=AssetUpdateSerializer,
+ responses={
+ 204: ASSET_UPDATED_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ )
+ def patch(self, request, asset_id):
+ """Update user server asset after upload completion.
+
+ Update the asset status and attributes after the file has been uploaded to S3 using server credentials.
+ This endpoint should be called after completing the S3 upload to mark the asset as uploaded.
+ """
+ # get the asset id
+ asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
+ # get the storage metadata
+ asset.is_uploaded = True
+ # get the storage metadata
+ if not asset.storage_metadata:
+ get_asset_object_metadata.delay(asset_id=str(asset_id))
+ # update the attributes
+ asset.attributes = request.data.get("attributes", asset.attributes)
+ # save the asset
+ asset.save(update_fields=["is_uploaded", "attributes"])
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+ @asset_docs(
+ operation_id="delete_user_server_asset",
+ summary="Delete user server asset",
+ parameters=[ASSET_ID_PARAMETER],
+ responses={
+ 204: ASSET_DELETED_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ )
+ def delete(self, request, asset_id):
+ """Delete user server asset.
+
+ Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile.
+ This performs a soft delete by marking the asset as deleted and updating the user's profile.
+ """
+ asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
+ asset.is_deleted = True
+ asset.deleted_at = timezone.now()
+ # get the entity and save the asset id for the request field
+ self.entity_asset_delete(
+ entity_type=asset.entity_type, asset=asset, request=request
+ )
+ asset.save(update_fields=["is_deleted", "deleted_at"])
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class GenericAssetEndpoint(BaseAPIView):
+ """This endpoint is used to upload generic assets that can be later bound to entities."""
+
+ @asset_docs(
+ operation_id="get_generic_asset",
+ summary="Get presigned URL for asset download",
+ description="Get presigned URL for asset download",
+ parameters=[WORKSPACE_SLUG_PARAMETER],
+ responses={
+ 200: ASSET_DOWNLOAD_SUCCESS_RESPONSE,
+ 400: ASSET_DOWNLOAD_ERROR_RESPONSE,
+ 404: ASSET_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, asset_id):
+ """Get presigned URL for asset download.
+
+ Generate a presigned URL for downloading a generic asset.
+ The asset must be uploaded and associated with the specified workspace.
+ """
+ try:
+ # Get the workspace
+ workspace = Workspace.objects.get(slug=slug)
+
+ # Get the asset
+ asset = FileAsset.objects.get(
+ id=asset_id, workspace_id=workspace.id, is_deleted=False
+ )
+
+ # Check if the asset exists and is uploaded
+ if not asset.is_uploaded:
+ return Response(
+ {"error": "Asset not yet uploaded"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Generate presigned URL for GET
+ storage = S3Storage(request=request, is_server=True)
+ presigned_url = storage.generate_presigned_url(
+ object_name=asset.asset.name, filename=asset.attributes.get("name")
+ )
+
+ return Response(
+ {
+ "asset_id": str(asset.id),
+ "asset_url": presigned_url,
+ "asset_name": asset.attributes.get("name", ""),
+ "asset_type": asset.attributes.get("type", ""),
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ except Workspace.DoesNotExist:
+ return Response(
+ {"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND
+ )
+ except FileAsset.DoesNotExist:
+ return Response(
+ {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
+ )
+ except Exception as e:
+ log_exception(e)
+ return Response(
+ {"error": "Internal server error"},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ )
+
+ @asset_docs(
+ operation_id="create_generic_asset_upload",
+ summary="Generate presigned URL for generic asset upload",
+ description="Generate presigned URL for generic asset upload",
+ parameters=[WORKSPACE_SLUG_PARAMETER],
+ request=OpenApiRequest(
+ request=GenericAssetUploadSerializer,
+ examples=[
+ OpenApiExample(
+ "GenericAssetUploadSerializer",
+ value={
+ "name": "image.jpg",
+ "type": "image/jpeg",
+ "size": 1024000,
+ "project_id": "123e4567-e89b-12d3-a456-426614174000",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for uploading a generic asset",
+ ),
+ ],
+ ),
+ responses={
+ 200: GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
+ 400: GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ 409: ASSET_CONFLICT_RESPONSE,
+ },
+ )
+ def post(self, request, slug):
+ """Generate presigned URL for generic asset upload.
+
+ Create a presigned URL for uploading generic assets that can be bound to entities like work items.
+ Supports various file types and includes external source tracking for integrations.
+ """
+ name = request.data.get("name")
+ type = request.data.get("type")
+ size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
+ project_id = request.data.get("project_id")
+ external_id = request.data.get("external_id")
+ external_source = request.data.get("external_source")
+
+ # Check if the request is valid
+ if not name or not size:
+ return Response(
+ {"error": "Name and size are required fields.", "status": False},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Check if the file size is within the limit
+ size_limit = min(size, settings.FILE_SIZE_LIMIT)
+
+ # Check if the file type is allowed
+ if not type or type not in settings.ATTACHMENT_MIME_TYPES:
+ return Response(
+ {"error": "Invalid file type.", "status": False},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Get the workspace
+ workspace = Workspace.objects.get(slug=slug)
+
+ # asset key
+ asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
+
+ # Check for existing asset with same external details if provided
+ if external_id and external_source:
+ existing_asset = FileAsset.objects.filter(
+ workspace__slug=slug,
+ external_source=external_source,
+ external_id=external_id,
+ is_deleted=False,
+ ).first()
+
+ if existing_asset:
+ return Response(
+ {
+ "message": "Asset with same external id and source already exists",
+ "asset_id": str(existing_asset.id),
+ "asset_url": existing_asset.asset_url,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+
+ # Create a File Asset
+ asset = FileAsset.objects.create(
+ attributes={"name": name, "type": type, "size": size_limit},
+ asset=asset_key,
+ size=size_limit,
+ workspace_id=workspace.id,
+ project_id=project_id,
+ created_by=request.user,
+ external_id=external_id,
+ external_source=external_source,
+ entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues
+ )
+
+ # Get the presigned URL
+ storage = S3Storage(request=request, is_server=True)
+ presigned_url = storage.generate_presigned_post(
+ object_name=asset_key, file_type=type, file_size=size_limit
+ )
+
+ return Response(
+ {
+ "upload_data": presigned_url,
+ "asset_id": str(asset.id),
+ "asset_url": asset.asset_url,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ @asset_docs(
+ operation_id="update_generic_asset",
+ summary="Update generic asset after upload completion",
+ description="Update generic asset after upload completion",
+ parameters=[WORKSPACE_SLUG_PARAMETER, ASSET_ID_PARAMETER],
+ request=OpenApiRequest(
+ request=GenericAssetUpdateSerializer,
+ examples=[
+ OpenApiExample(
+ "GenericAssetUpdateSerializer",
+ value={"is_uploaded": True},
+ description="Example request for updating a generic asset",
+ )
+ ],
+ ),
+ responses={
+ 204: ASSET_UPDATED_RESPONSE,
+ 404: ASSET_NOT_FOUND_RESPONSE,
+ },
+ )
+ def patch(self, request, slug, asset_id):
+ """Update generic asset after upload completion.
+
+ Update the asset status after the file has been uploaded to S3.
+ This endpoint should be called after completing the S3 upload to mark the asset as uploaded
+ and trigger metadata extraction.
+ """
+ try:
+ asset = FileAsset.objects.get(
+ id=asset_id, workspace__slug=slug, is_deleted=False
+ )
+
+ # Update is_uploaded status
+ asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
+
+ # Update storage metadata if not present
+ if not asset.storage_metadata:
+ get_asset_object_metadata.delay(asset_id=str(asset_id))
+
+ asset.save(update_fields=["is_uploaded"])
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ except FileAsset.DoesNotExist:
+ return Response(
+ {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND
+ )
diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py
index c79c2f853..a4c14cf0d 100644
--- a/apps/api/plane/api/views/base.py
+++ b/apps/api/plane/api/views/base.py
@@ -13,7 +13,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
# Third party imports
-from rest_framework.views import APIView
+from rest_framework.generics import GenericAPIView
# Module imports
from plane.api.middleware.api_authentication import APIKeyAuthentication
@@ -36,7 +36,7 @@ class TimezoneMixin:
timezone.deactivate()
-class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
+class BaseAPIView(TimezoneMixin, GenericAPIView, BasePaginator):
authentication_classes = [APIKeyAuthentication]
permission_classes = [IsAuthenticated]
diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py
index 457671b93..e7a7b8fcc 100644
--- a/apps/api/plane/api/views/cycle.py
+++ b/apps/api/plane/api/views/cycle.py
@@ -23,9 +23,18 @@ from django.db import models
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
# Module imports
-from plane.api.serializers import CycleIssueSerializer, CycleSerializer, IssueSerializer
+from plane.api.serializers import (
+ CycleIssueSerializer,
+ CycleSerializer,
+ CycleIssueRequestSerializer,
+ TransferCycleIssueRequestSerializer,
+ CycleCreateSerializer,
+ CycleUpdateSerializer,
+ IssueSerializer,
+)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
@@ -42,14 +51,36 @@ from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
+from plane.utils.openapi.decorators import cycle_docs
+from plane.utils.openapi import (
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ CYCLE_VIEW_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ create_paginated_response,
+ # Request Examples
+ CYCLE_CREATE_EXAMPLE,
+ CYCLE_UPDATE_EXAMPLE,
+ CYCLE_ISSUE_REQUEST_EXAMPLE,
+ TRANSFER_CYCLE_ISSUE_EXAMPLE,
+ # Response Examples
+ CYCLE_EXAMPLE,
+ CYCLE_ISSUE_EXAMPLE,
+ TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
+ TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
+ TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
+ DELETED_RESPONSE,
+ ARCHIVED_RESPONSE,
+ CYCLE_CANNOT_ARCHIVE_RESPONSE,
+ UNARCHIVED_RESPONSE,
+ REQUIRED_FIELDS_RESPONSE,
+)
-class CycleAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to cycle.
-
- """
+class CycleListCreateAPIEndpoint(BaseAPIView):
+ """Cycle List and Create Endpoint"""
serializer_class = CycleSerializer
model = Cycle
@@ -136,17 +167,34 @@ class CycleAPIEndpoint(BaseAPIView):
.distinct()
)
- def get(self, request, slug, project_id, pk=None):
+ @cycle_docs(
+ operation_id="list_cycles",
+ summary="List cycles",
+ description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ CYCLE_VIEW_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ CycleSerializer,
+ "PaginatedCycleResponse",
+ "Paginated list of cycles",
+ "Paginated Cycles",
+ ),
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List cycles
+
+ Retrieve all cycles in a project.
+ Supports filtering by cycle status like current, upcoming, completed, or draft.
+ """
project = Project.objects.get(workspace__slug=slug, pk=project_id)
- if pk:
- queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
- data = CycleSerializer(
- queryset,
- fields=self.fields,
- expand=self.expand,
- context={"project": project},
- ).data
- return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
cycle_view = request.GET.get("cycle_view", "all")
@@ -237,7 +285,28 @@ class CycleAPIEndpoint(BaseAPIView):
).data,
)
+ @cycle_docs(
+ operation_id="create_cycle",
+ summary="Create cycle",
+ description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.",
+ request=OpenApiRequest(
+ request=CycleCreateSerializer,
+ examples=[CYCLE_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Cycle created",
+ response=CycleSerializer,
+ examples=[CYCLE_EXAMPLE],
+ ),
+ },
+ )
def post(self, request, slug, project_id):
+ """Create cycle
+
+ Create a new development cycle with specified name, description, and date range.
+ Supports external ID tracking for integration purposes.
+ """
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
@@ -245,7 +314,7 @@ class CycleAPIEndpoint(BaseAPIView):
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
):
- serializer = CycleSerializer(data=request.data)
+ serializer = CycleCreateSerializer(data=request.data)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -274,13 +343,16 @@ class CycleAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="cycle",
- model_id=str(serializer.data["id"]),
+ model_id=str(serializer.instance.id),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
+
+ cycle = Cycle.objects.get(pk=serializer.instance.id)
+ serializer = CycleSerializer(cycle)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
@@ -291,7 +363,147 @@ class CycleAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
+
+class CycleDetailAPIEndpoint(BaseAPIView):
+ """
+ This viewset automatically provides `retrieve`, `update` and `destroy` actions related to cycle.
+ """
+
+ serializer_class = CycleSerializer
+ model = Cycle
+ webhook_event = "cycle"
+ permission_classes = [ProjectEntityPermission]
+
+ def get_queryset(self):
+ return (
+ Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .select_related("project")
+ .select_related("workspace")
+ .select_related("owned_by")
+ .annotate(
+ total_issues=Count(
+ "issue_cycle",
+ filter=Q(
+ issue_cycle__issue__archived_at__isnull=True,
+ issue_cycle__issue__is_draft=False,
+ issue_cycle__deleted_at__isnull=True,
+ ),
+ )
+ )
+ .annotate(
+ completed_issues=Count(
+ "issue_cycle__issue__state__group",
+ filter=Q(
+ issue_cycle__issue__state__group="completed",
+ issue_cycle__issue__archived_at__isnull=True,
+ issue_cycle__issue__is_draft=False,
+ issue_cycle__deleted_at__isnull=True,
+ ),
+ )
+ )
+ .annotate(
+ cancelled_issues=Count(
+ "issue_cycle__issue__state__group",
+ filter=Q(
+ issue_cycle__issue__state__group="cancelled",
+ issue_cycle__issue__archived_at__isnull=True,
+ issue_cycle__issue__is_draft=False,
+ issue_cycle__deleted_at__isnull=True,
+ ),
+ )
+ )
+ .annotate(
+ started_issues=Count(
+ "issue_cycle__issue__state__group",
+ filter=Q(
+ issue_cycle__issue__state__group="started",
+ issue_cycle__issue__archived_at__isnull=True,
+ issue_cycle__issue__is_draft=False,
+ issue_cycle__deleted_at__isnull=True,
+ ),
+ )
+ )
+ .annotate(
+ unstarted_issues=Count(
+ "issue_cycle__issue__state__group",
+ filter=Q(
+ issue_cycle__issue__state__group="unstarted",
+ issue_cycle__issue__archived_at__isnull=True,
+ issue_cycle__issue__is_draft=False,
+ issue_cycle__deleted_at__isnull=True,
+ ),
+ )
+ )
+ .annotate(
+ backlog_issues=Count(
+ "issue_cycle__issue__state__group",
+ filter=Q(
+ issue_cycle__issue__state__group="backlog",
+ issue_cycle__issue__archived_at__isnull=True,
+ issue_cycle__issue__is_draft=False,
+ issue_cycle__deleted_at__isnull=True,
+ ),
+ )
+ )
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ .distinct()
+ )
+
+ @cycle_docs(
+ operation_id="retrieve_cycle",
+ summary="Retrieve cycle",
+ description="Retrieve details of a specific cycle by its ID. Supports cycle status filtering.",
+ responses={
+ 200: OpenApiResponse(
+ description="Cycles",
+ response=CycleSerializer,
+ examples=[CYCLE_EXAMPLE],
+ ),
+ },
+ )
+ def get(self, request, slug, project_id, pk):
+ """List or retrieve cycles
+
+ Retrieve all cycles in a project or get details of a specific cycle.
+ Supports filtering by cycle status like current, upcoming, completed, or draft.
+ """
+ project = Project.objects.get(workspace__slug=slug, pk=project_id)
+ queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
+ data = CycleSerializer(
+ queryset,
+ fields=self.fields,
+ expand=self.expand,
+ context={"project": project},
+ ).data
+ return Response(data, status=status.HTTP_200_OK)
+
+ @cycle_docs(
+ operation_id="update_cycle",
+ summary="Update cycle",
+ description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.",
+ request=OpenApiRequest(
+ request=CycleUpdateSerializer,
+ examples=[CYCLE_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Cycle updated",
+ response=CycleSerializer,
+ examples=[CYCLE_EXAMPLE],
+ ),
+ },
+ )
def patch(self, request, slug, project_id, pk):
+ """Update cycle
+
+ Modify an existing cycle's properties like name, description, or date range.
+ Completed cycles can only have their sort order changed.
+ """
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(
@@ -320,7 +532,7 @@ class CycleAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- serializer = CycleSerializer(cycle, data=request.data, partial=True)
+ serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -346,17 +558,32 @@ class CycleAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="cycle",
- model_id=str(serializer.data["id"]),
+ model_id=str(serializer.instance.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
+ cycle = Cycle.objects.get(pk=serializer.instance.id)
+ serializer = CycleSerializer(cycle)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ @cycle_docs(
+ operation_id="delete_cycle",
+ summary="Delete cycle",
+ description="Permanently remove a cycle and all its associated issue relationships",
+ responses={
+ 204: DELETED_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, pk):
+ """Delete cycle
+
+ Permanently remove a cycle and all its associated issue relationships.
+ Only admins or the cycle creator can perform this action.
+ """
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if cycle.owned_by_id != request.user.id and (
not ProjectMember.objects.filter(
@@ -403,6 +630,8 @@ class CycleAPIEndpoint(BaseAPIView):
class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
+ """Cycle Archive and Unarchive Endpoint"""
+
permission_classes = [ProjectEntityPermission]
def get_queryset(self):
@@ -509,7 +738,27 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.distinct()
)
+ @cycle_docs(
+ operation_id="list_archived_cycles",
+ description="Retrieve all cycles that have been archived in the project.",
+ summary="List archived cycles",
+ parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER],
+ request={},
+ responses={
+ 200: create_paginated_response(
+ CycleSerializer,
+ "PaginatedArchivedCycleResponse",
+ "Paginated list of archived cycles",
+ "Paginated Archived Cycles",
+ ),
+ },
+ )
def get(self, request, slug, project_id):
+ """List archived cycles
+
+ Retrieve all cycles that have been archived in the project.
+ Returns paginated results with cycle statistics and completion data.
+ """
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -518,7 +767,22 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data,
)
+ @cycle_docs(
+ operation_id="archive_cycle",
+ summary="Archive cycle",
+ description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.",
+ request={},
+ responses={
+ 204: ARCHIVED_RESPONSE,
+ 400: CYCLE_CANNOT_ARCHIVE_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id, cycle_id):
+ """Archive cycle
+
+ Move a completed cycle to archived status for historical tracking.
+ Only cycles that have ended can be archived.
+ """
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
@@ -537,7 +801,21 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+ @cycle_docs(
+ operation_id="unarchive_cycle",
+ summary="Unarchive cycle",
+ description="Restore an archived cycle to active status, making it available for regular use.",
+ request={},
+ responses={
+ 204: UNARCHIVED_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, cycle_id):
+ """Unarchive cycle
+
+ Restore an archived cycle to active status, making it available for regular use.
+ The cycle will reappear in active cycle lists.
+ """
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
@@ -546,17 +824,12 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class CycleIssueAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`,
- and `destroy` actions related to cycle issues.
-
- """
+class CycleIssueListCreateAPIEndpoint(BaseAPIView):
+ """Cycle Issue List and Create Endpoint"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
- bulk = True
permission_classes = [ProjectEntityPermission]
def get_queryset(self):
@@ -583,20 +856,27 @@ class CycleIssueAPIEndpoint(BaseAPIView):
.distinct()
)
- def get(self, request, slug, project_id, cycle_id, issue_id=None):
- # Get
- if issue_id:
- cycle_issue = CycleIssue.objects.get(
- workspace__slug=slug,
- project_id=project_id,
- cycle_id=cycle_id,
- issue_id=issue_id,
- )
- serializer = CycleIssueSerializer(
- cycle_issue, fields=self.fields, expand=self.expand
- )
- return Response(serializer.data, status=status.HTTP_200_OK)
+ @cycle_docs(
+ operation_id="list_cycle_work_items",
+ summary="List cycle work items",
+ description="Retrieve all work items assigned to a cycle.",
+ parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER],
+ request={},
+ responses={
+ 200: create_paginated_response(
+ CycleIssueSerializer,
+ "PaginatedCycleIssueResponse",
+ "Paginated list of cycle work items",
+ "Paginated Cycle Work Items",
+ ),
+ },
+ )
+ def get(self, request, slug, project_id, cycle_id):
+ """List or retrieve cycle work items
+ Retrieve all work items assigned to a cycle or get details of a specific cycle work item.
+ Returns paginated results with work item details, assignees, and labels.
+ """
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
@@ -644,19 +924,41 @@ class CycleIssueAPIEndpoint(BaseAPIView):
).data,
)
+ @cycle_docs(
+ operation_id="add_cycle_work_items",
+ summary="Add Work Items to Cycle",
+ description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.",
+ request=OpenApiRequest(
+ request=CycleIssueRequestSerializer,
+ examples=[CYCLE_ISSUE_REQUEST_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Cycle work items added",
+ response=CycleIssueSerializer,
+ examples=[CYCLE_ISSUE_EXAMPLE],
+ ),
+ 400: REQUIRED_FIELDS_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id, cycle_id):
+ """Add cycle issues
+
+ Assign multiple work items to a cycle or move them from another cycle.
+ Automatically handles bulk creation and updates with activity tracking.
+ """
issues = request.data.get("issues", [])
if not issues:
return Response(
- {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST
+ {"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
- # Get all CycleIssues already created
+ # Get all CycleWorkItems already created
cycle_issues = list(
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
)
@@ -730,7 +1032,87 @@ class CycleIssueAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
+
+class CycleIssueDetailAPIEndpoint(BaseAPIView):
+ """
+ This viewset automatically provides `list`, `create`,
+ and `destroy` actions related to cycle issues.
+
+ """
+
+ serializer_class = CycleIssueSerializer
+ model = CycleIssue
+ webhook_event = "cycle_issue"
+ bulk = True
+ permission_classes = [ProjectEntityPermission]
+
+ def get_queryset(self):
+ return (
+ CycleIssue.objects.annotate(
+ sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .filter(cycle_id=self.kwargs.get("cycle_id"))
+ .select_related("project")
+ .select_related("workspace")
+ .select_related("cycle")
+ .select_related("issue", "issue__state", "issue__project")
+ .prefetch_related("issue__assignees", "issue__labels")
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ .distinct()
+ )
+
+ @cycle_docs(
+ operation_id="retrieve_cycle_work_item",
+ summary="Retrieve cycle work item",
+ description="Retrieve details of a specific cycle work item.",
+ responses={
+ 200: OpenApiResponse(
+ description="Cycle work items",
+ response=CycleIssueSerializer,
+ examples=[CYCLE_ISSUE_EXAMPLE],
+ ),
+ },
+ )
+ def get(self, request, slug, project_id, cycle_id, issue_id):
+ """Retrieve cycle work item
+
+ Retrieve details of a specific cycle work item.
+ Returns paginated results with work item details, assignees, and labels.
+ """
+ cycle_issue = CycleIssue.objects.get(
+ workspace__slug=slug,
+ project_id=project_id,
+ cycle_id=cycle_id,
+ issue_id=issue_id,
+ )
+ serializer = CycleIssueSerializer(
+ cycle_issue, fields=self.fields, expand=self.expand
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @cycle_docs(
+ operation_id="delete_cycle_work_item",
+ summary="Delete cycle work item",
+ description="Remove a work item from a cycle while keeping the work item in the project.",
+ responses={
+ 204: DELETED_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, cycle_id, issue_id):
+ """Remove cycle work item
+
+ Remove a work item from a cycle while keeping the work item in the project.
+ Records the removal activity for tracking purposes.
+ """
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id,
workspace__slug=slug,
@@ -764,7 +1146,54 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
+ @cycle_docs(
+ operation_id="transfer_cycle_work_items",
+ summary="Transfer cycle work items",
+ description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.",
+ request=OpenApiRequest(
+ request=TransferCycleIssueRequestSerializer,
+ examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Work items transferred successfully",
+ response={
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "description": "Success message",
+ "example": "Success",
+ },
+ },
+ },
+ examples=[TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE],
+ ),
+ 400: OpenApiResponse(
+ description="Bad request",
+ response={
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Error message",
+ "example": "New Cycle Id is required",
+ },
+ },
+ },
+ examples=[
+ TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
+ TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
+ ],
+ ),
+ },
+ )
def post(self, request, slug, project_id, cycle_id):
+ """Transfer cycle issues
+
+ Move incomplete issues from the current cycle to a new target cycle.
+ Captures progress snapshot and transfers only unfinished work items.
+ """
new_cycle_id = request.data.get("new_cycle_id", False)
if not new_cycle_id:
diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py
index 93acb0664..3ee977d2a 100644
--- a/apps/api/plane/api/views/intake.py
+++ b/apps/api/plane/api/views/intake.py
@@ -12,30 +12,47 @@ from django.contrib.postgres.fields import ArrayField
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
# Module imports
-from plane.api.serializers import IntakeIssueSerializer, IssueSerializer
+from plane.api.serializers import (
+ IntakeIssueSerializer,
+ IssueSerializer,
+ IntakeIssueCreateSerializer,
+ IntakeIssueUpdateSerializer,
+)
from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
+from plane.utils.openapi import (
+ intake_docs,
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ create_paginated_response,
+ # Request Examples
+ INTAKE_ISSUE_CREATE_EXAMPLE,
+ INTAKE_ISSUE_UPDATE_EXAMPLE,
+ # Response Examples
+ INTAKE_ISSUE_EXAMPLE,
+ INVALID_REQUEST_RESPONSE,
+ DELETED_RESPONSE,
+)
-class IntakeIssueAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to intake issues.
-
- """
-
- permission_classes = [ProjectLitePermission]
+class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
+ """Intake Work Item List and Create Endpoint"""
serializer_class = IntakeIssueSerializer
- model = IntakeIssue
-
- filterset_fields = ["status"]
+ model = Intake
+ permission_classes = [ProjectLitePermission]
def get_queryset(self):
intake = Intake.objects.filter(
@@ -61,13 +78,33 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
- def get(self, request, slug, project_id, issue_id=None):
- if issue_id:
- intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
- intake_issue_data = IntakeIssueSerializer(
- intake_issue_queryset, fields=self.fields, expand=self.expand
- ).data
- return Response(intake_issue_data, status=status.HTTP_200_OK)
+ @intake_docs(
+ operation_id="get_intake_work_items_list",
+ summary="List intake work items",
+ description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.",
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IntakeIssueSerializer,
+ "PaginatedIntakeIssueResponse",
+ "Paginated list of intake work items",
+ "Paginated Intake Work Items",
+ ),
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List intake work items
+
+ Retrieve all work items in the project's intake queue.
+ Returns paginated results when listing all intake work items.
+ """
issue_queryset = self.get_queryset()
return self.paginate(
request=request,
@@ -77,7 +114,33 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
).data,
)
+ @intake_docs(
+ operation_id="create_intake_work_item",
+ summary="Create intake work item",
+ description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.",
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IntakeIssueCreateSerializer,
+ examples=[INTAKE_ISSUE_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Intake work item created",
+ response=IntakeIssueSerializer,
+ examples=[INTAKE_ISSUE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id):
+ """Create intake work item
+
+ Submit a new work item to the project's intake queue for review and triage.
+ Automatically creates the work item with default triage state and tracks activity.
+ """
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
@@ -141,9 +204,99 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
)
serializer = IntakeIssueSerializer(intake_issue)
- return Response(serializer.data, status=status.HTTP_200_OK)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+class IntakeIssueDetailAPIEndpoint(BaseAPIView):
+ """Intake Issue API Endpoint"""
+
+ permission_classes = [ProjectLitePermission]
+
+ serializer_class = IntakeIssueSerializer
+ model = IntakeIssue
+
+ filterset_fields = ["status"]
+
+ def get_queryset(self):
+ intake = Intake.objects.filter(
+ workspace__slug=self.kwargs.get("slug"),
+ project_id=self.kwargs.get("project_id"),
+ ).first()
+
+ project = Project.objects.get(
+ workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
+ )
+
+ if intake is None and not project.intake_view:
+ return IntakeIssue.objects.none()
+
+ return (
+ IntakeIssue.objects.filter(
+ Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
+ workspace__slug=self.kwargs.get("slug"),
+ project_id=self.kwargs.get("project_id"),
+ intake_id=intake.id,
+ )
+ .select_related("issue", "workspace", "project")
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ )
+
+ @intake_docs(
+ operation_id="retrieve_intake_work_item",
+ summary="Retrieve intake work item",
+ description="Retrieve details of a specific intake work item.",
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Intake work item",
+ response=IntakeIssueSerializer,
+ examples=[INTAKE_ISSUE_EXAMPLE],
+ ),
+ },
+ )
+ def get(self, request, slug, project_id, issue_id):
+ """Retrieve intake work item
+
+ Retrieve details of a specific intake work item.
+ """
+ intake_issue_queryset = self.get_queryset().get(issue_id=issue_id)
+ intake_issue_data = IntakeIssueSerializer(
+ intake_issue_queryset, fields=self.fields, expand=self.expand
+ ).data
+ return Response(intake_issue_data, status=status.HTTP_200_OK)
+
+ @intake_docs(
+ operation_id="update_intake_work_item",
+ summary="Update intake work item",
+ description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.",
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IntakeIssueUpdateSerializer,
+ examples=[INTAKE_ISSUE_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Intake work item updated",
+ response=IntakeIssueSerializer,
+ examples=[INTAKE_ISSUE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ },
+ )
def patch(self, request, slug, project_id, issue_id):
+ """Update intake work item
+
+ Modify an existing intake work item's properties or status for triage processing.
+ Supports status changes like accept, reject, or mark as duplicate.
+ """
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -180,7 +333,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
request.user.id
):
return Response(
- {"error": "You cannot edit intake issues"},
+ {"error": "You cannot edit intake work items"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -251,7 +404,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
- serializer = IntakeIssueSerializer(
+ serializer = IntakeIssueUpdateSerializer(
intake_issue, data=request.data, partial=True
)
current_instance = json.dumps(
@@ -301,7 +454,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
-
+ serializer = IntakeIssueSerializer(intake_issue)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
@@ -309,7 +462,25 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK
)
+ @intake_docs(
+ operation_id="delete_intake_work_item",
+ summary="Delete intake work item",
+ description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.",
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, issue_id):
+ """Delete intake work item
+
+ Permanently remove an intake work item from the triage queue.
+ Also deletes the underlying work item if it hasn't been accepted yet.
+ """
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
@@ -349,7 +520,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
).exists()
):
return Response(
- {"error": "Only admin or creator can delete the issue"},
+ {"error": "Only admin or creator can delete the work item"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py
index 6a5016bec..5ae15ea2e 100644
--- a/apps/api/plane/api/views/issue.py
+++ b/apps/api/plane/api/views/issue.py
@@ -1,6 +1,7 @@
# Python imports
import json
import uuid
+import re
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
@@ -26,6 +27,16 @@ from django.conf import settings
from rest_framework import status
from rest_framework.response import Response
+# drf-spectacular imports
+from drf_spectacular.utils import (
+ extend_schema,
+ OpenApiParameter,
+ OpenApiResponse,
+ OpenApiExample,
+ OpenApiRequest,
+)
+from drf_spectacular.types import OpenApiTypes
+
# Module imports
from plane.api.serializers import (
IssueAttachmentSerializer,
@@ -34,6 +45,12 @@ from plane.api.serializers import (
IssueLinkSerializer,
IssueSerializer,
LabelSerializer,
+ IssueAttachmentUploadSerializer,
+ IssueSearchSerializer,
+ IssueCommentCreateSerializer,
+ IssueLinkCreateSerializer,
+ IssueLinkUpdateSerializer,
+ LabelCreateUpdateSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@@ -58,6 +75,74 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
+
+from plane.utils.openapi import (
+ work_item_docs,
+ label_docs,
+ issue_link_docs,
+ issue_comment_docs,
+ issue_activity_docs,
+ issue_attachment_docs,
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_IDENTIFIER_PARAMETER,
+ ISSUE_IDENTIFIER_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ LABEL_ID_PARAMETER,
+ COMMENT_ID_PARAMETER,
+ LINK_ID_PARAMETER,
+ ATTACHMENT_ID_PARAMETER,
+ ACTIVITY_ID_PARAMETER,
+ PROJECT_ID_QUERY_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ EXTERNAL_ID_PARAMETER,
+ EXTERNAL_SOURCE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ SEARCH_PARAMETER,
+ SEARCH_PARAMETER_REQUIRED,
+ LIMIT_PARAMETER,
+ WORKSPACE_SEARCH_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ create_paginated_response,
+ # Request Examples
+ ISSUE_CREATE_EXAMPLE,
+ ISSUE_UPDATE_EXAMPLE,
+ ISSUE_UPSERT_EXAMPLE,
+ LABEL_CREATE_EXAMPLE,
+ LABEL_UPDATE_EXAMPLE,
+ ISSUE_LINK_CREATE_EXAMPLE,
+ ISSUE_LINK_UPDATE_EXAMPLE,
+ ISSUE_COMMENT_CREATE_EXAMPLE,
+ ISSUE_COMMENT_UPDATE_EXAMPLE,
+ ISSUE_ATTACHMENT_UPLOAD_EXAMPLE,
+ ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE,
+ # Response Examples
+ ISSUE_EXAMPLE,
+ LABEL_EXAMPLE,
+ ISSUE_LINK_EXAMPLE,
+ ISSUE_COMMENT_EXAMPLE,
+ ISSUE_ATTACHMENT_EXAMPLE,
+ ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE,
+ ISSUE_SEARCH_EXAMPLE,
+ WORK_ITEM_NOT_FOUND_RESPONSE,
+ ISSUE_NOT_FOUND_RESPONSE,
+ PROJECT_NOT_FOUND_RESPONSE,
+ EXTERNAL_ID_EXISTS_RESPONSE,
+ DELETED_RESPONSE,
+ ADMIN_ONLY_RESPONSE,
+ LABEL_NOT_FOUND_RESPONSE,
+ LABEL_NAME_EXISTS_RESPONSE,
+ INVALID_REQUEST_RESPONSE,
+ LINK_NOT_FOUND_RESPONSE,
+ COMMENT_NOT_FOUND_RESPONSE,
+ ATTACHMENT_NOT_FOUND_RESPONSE,
+ BAD_SEARCH_REQUEST_RESPONSE,
+ UNAUTHORIZED_RESPONSE,
+ FORBIDDEN_RESPONSE,
+ WORKSPACE_NOT_FOUND_RESPONSE,
+)
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
@@ -73,8 +158,8 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
serializer_class = IssueSerializer
@property
- def project__identifier(self):
- return self.kwargs.get("project__identifier", None)
+ def project_identifier(self):
+ return self.kwargs.get("project_identifier", None)
def get_queryset(self):
return (
@@ -85,7 +170,7 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
- .filter(project__identifier=self.kwargs.get("project__identifier"))
+ .filter(project__identifier=self.kwargs.get("project_identifier"))
.select_related("project")
.select_related("workspace")
.select_related("state")
@@ -95,8 +180,32 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
- def get(self, request, slug, project__identifier=None, issue__identifier=None):
- if issue__identifier and project__identifier:
+ @extend_schema(
+ operation_id="get_workspace_work_item",
+ summary="Retrieve work item by identifiers",
+ description="Retrieve a specific work item using workspace slug, project identifier, and issue identifier.",
+ tags=["Work Items"],
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_IDENTIFIER_PARAMETER,
+ ISSUE_IDENTIFIER_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Work item details",
+ response=IssueSerializer,
+ examples=[ISSUE_EXAMPLE],
+ ),
+ 404: WORK_ITEM_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_identifier=None, issue_identifier=None):
+ """Retrieve work item by identifiers
+
+ Retrieve a specific work item using workspace slug, project identifier, and issue identifier.
+ This endpoint provides workspace-level access to work items.
+ """
+ if issue_identifier and project_identifier:
issue = Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -104,8 +213,8 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
.values("count")
).get(
workspace__slug=slug,
- project__identifier=project__identifier,
- sequence_id=issue__identifier,
+ project__identifier=project_identifier,
+ sequence_id=issue_identifier,
)
return Response(
IssueSerializer(issue, fields=self.fields, expand=self.expand).data,
@@ -113,11 +222,9 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView):
)
-class IssueAPIEndpoint(BaseAPIView):
+class IssueListCreateAPIEndpoint(BaseAPIView):
"""
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to issue.
-
+ This viewset provides `list` and `create` on issue level
"""
model = Issue
@@ -144,7 +251,37 @@ class IssueAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
).distinct()
- def get(self, request, slug, project_id, pk=None):
+ @work_item_docs(
+ operation_id="list_work_items",
+ summary="List work items",
+ description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ EXTERNAL_ID_PARAMETER,
+ EXTERNAL_SOURCE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueSerializer,
+ "PaginatedWorkItemResponse",
+ "Paginated list of work items",
+ "Paginated Work Items",
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List work items
+
+ Retrieve a paginated list of all work items in a project.
+ Supports filtering, ordering, and field selection through query parameters.
+ """
+
external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")
@@ -160,18 +297,6 @@ class IssueAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
- if pk:
- issue = Issue.issue_objects.annotate(
- sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
- .order_by()
- .annotate(count=Func(F("id"), function="Count"))
- .values("count")
- ).get(workspace__slug=slug, project_id=project_id, pk=pk)
- return Response(
- IssueSerializer(issue, fields=self.fields, expand=self.expand).data,
- status=status.HTTP_200_OK,
- )
-
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
@@ -268,7 +393,31 @@ class IssueAPIEndpoint(BaseAPIView):
).data,
)
+ @work_item_docs(
+ operation_id="create_work_item",
+ summary="Create work item",
+ description="Create a new work item in the specified project with the provided details.",
+ request=OpenApiRequest(
+ request=IssueSerializer,
+ examples=[ISSUE_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Work Item created successfully",
+ response=IssueSerializer,
+ examples=[ISSUE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id):
+ """Create work item
+
+ Create a new work item in the specified project with the provided details.
+ Supports external ID tracking for integration purposes.
+ """
project = Project.objects.get(pk=project_id)
serializer = IssueSerializer(
@@ -338,7 +487,103 @@ class IssueAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+class IssueDetailAPIEndpoint(BaseAPIView):
+ """Issue Detail Endpoint"""
+
+ model = Issue
+ webhook_event = "issue"
+ permission_classes = [ProjectEntityPermission]
+ serializer_class = IssueSerializer
+
+ def get_queryset(self):
+ return (
+ Issue.issue_objects.annotate(
+ sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .select_related("project")
+ .select_related("workspace")
+ .select_related("state")
+ .select_related("parent")
+ .prefetch_related("assignees")
+ .prefetch_related("labels")
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ ).distinct()
+
+ @work_item_docs(
+ operation_id="retrieve_work_item",
+ summary="Retrieve work item",
+ description="Retrieve details of a specific work item.",
+ parameters=[
+ PROJECT_ID_PARAMETER,
+ EXTERNAL_ID_PARAMETER,
+ EXTERNAL_SOURCE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="List of issues or issue details",
+ response=IssueSerializer,
+ examples=[ISSUE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: WORK_ITEM_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, pk):
+ """Retrieve work item
+
+ Retrieve details of a specific work item.
+ Supports filtering, ordering, and field selection through query parameters.
+ """
+
+ issue = Issue.issue_objects.annotate(
+ sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ ).get(workspace__slug=slug, project_id=project_id, pk=pk)
+ return Response(
+ IssueSerializer(issue, fields=self.fields, expand=self.expand).data,
+ status=status.HTTP_200_OK,
+ )
+
+ @work_item_docs(
+ operation_id="put_work_item",
+ summary="Update or create work item",
+ description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.",
+ request=OpenApiRequest(
+ request=IssueSerializer,
+ examples=[ISSUE_UPSERT_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Work Item updated successfully",
+ response=IssueSerializer,
+ examples=[ISSUE_EXAMPLE],
+ ),
+ 201: OpenApiResponse(
+ description="Work Item created successfully",
+ response=IssueSerializer,
+ examples=[ISSUE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: WORK_ITEM_NOT_FOUND_RESPONSE,
+ },
+ )
def put(self, request, slug, project_id):
+ """Update or create work item
+
+ Update an existing work item identified by external ID and source, or create a new one if it doesn't exist.
+ Requires external_id and external_source parameters for identification.
+ """
# Get the entities required for putting the issue, external_id and
# external_source are must to identify the issue here
project = Project.objects.get(pk=project_id)
@@ -448,7 +693,34 @@ class IssueAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- def patch(self, request, slug, project_id, pk=None):
+ @work_item_docs(
+ operation_id="update_work_item",
+ summary="Partially update work item",
+ description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.",
+ parameters=[
+ PROJECT_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IssueSerializer,
+ examples=[ISSUE_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Work Item patched successfully",
+ response=IssueSerializer,
+ examples=[ISSUE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: WORK_ITEM_NOT_FOUND_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
+ def patch(self, request, slug, project_id, pk):
+ """Update work item
+
+ Partially update an existing work item with the provided fields.
+ Supports external ID validation to prevent conflicts.
+ """
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
project = Project.objects.get(pk=project_id)
current_instance = json.dumps(
@@ -495,7 +767,25 @@ class IssueAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- def delete(self, request, slug, project_id, pk=None):
+ @work_item_docs(
+ operation_id="delete_work_item",
+ summary="Delete work item",
+ description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.",
+ parameters=[
+ PROJECT_ID_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ 403: ADMIN_ONLY_RESPONSE,
+ 404: WORK_ITEM_NOT_FOUND_RESPONSE,
+ },
+ )
+ def delete(self, request, slug, project_id, pk):
+ """Delete work item
+
+ Permanently delete an existing work item from the project.
+ Only admins or the item creator can perform this action.
+ """
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
@@ -507,7 +797,7 @@ class IssueAPIEndpoint(BaseAPIView):
).exists()
):
return Response(
- {"error": "Only admin or creator can delete the issue"},
+ {"error": "Only admin or creator can delete the work item"},
status=status.HTTP_403_FORBIDDEN,
)
current_instance = json.dumps(
@@ -526,12 +816,8 @@ class IssueAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class LabelAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to the labels.
-
- """
+class LabelListCreateAPIEndpoint(BaseAPIView):
+ """Label List and Create Endpoint"""
serializer_class = LabelSerializer
model = Label
@@ -553,9 +839,31 @@ class LabelAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
+ @label_docs(
+ operation_id="create_label",
+ description="Create a new label in the specified project with name, color, and description.",
+ request=OpenApiRequest(
+ request=LabelCreateUpdateSerializer,
+ examples=[LABEL_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Label created successfully",
+ response=LabelSerializer,
+ examples=[LABEL_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 409: LABEL_NAME_EXISTS_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id):
+ """Create label
+
+ Create a new label in the specified project with name, color, and description.
+ Supports external ID tracking for integration purposes.
+ """
try:
- serializer = LabelSerializer(data=request.data)
+ serializer = LabelCreateUpdateSerializer(data=request.data)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -582,6 +890,8 @@ class LabelAPIEndpoint(BaseAPIView):
)
serializer.save(project_id=project_id)
+ label = Label.objects.get(pk=serializer.instance.id)
+ serializer = LabelSerializer(label)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
@@ -598,22 +908,101 @@ class LabelAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
- def get(self, request, slug, project_id, pk=None):
- if pk is None:
- return self.paginate(
- request=request,
- queryset=(self.get_queryset()),
- on_results=lambda labels: LabelSerializer(
- labels, many=True, fields=self.fields, expand=self.expand
- ).data,
- )
+ @label_docs(
+ operation_id="list_labels",
+ description="Retrieve all labels in a project. Supports filtering by name and color.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ LabelSerializer,
+ "PaginatedLabelResponse",
+ "Paginated list of labels",
+ "Paginated Labels",
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List labels
+
+ Retrieve all labels in the project.
+ """
+ return self.paginate(
+ request=request,
+ queryset=(self.get_queryset()),
+ on_results=lambda labels: LabelSerializer(
+ labels, many=True, fields=self.fields, expand=self.expand
+ ).data,
+ )
+
+
+class LabelDetailAPIEndpoint(BaseAPIView):
+ """Label Detail Endpoint"""
+
+ serializer_class = LabelSerializer
+ model = Label
+ permission_classes = [ProjectMemberPermission]
+
+ @label_docs(
+ operation_id="get_labels",
+ description="Retrieve details of a specific label.",
+ parameters=[
+ LABEL_ID_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Labels",
+ response=LabelSerializer,
+ examples=[LABEL_EXAMPLE],
+ ),
+ 404: LABEL_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, pk):
+ """Retrieve label
+
+ Retrieve details of a specific label.
+ """
label = self.get_queryset().get(pk=pk)
- serializer = LabelSerializer(label, fields=self.fields, expand=self.expand)
+ serializer = LabelSerializer(label)
return Response(serializer.data, status=status.HTTP_200_OK)
- def patch(self, request, slug, project_id, pk=None):
+ @label_docs(
+ operation_id="update_label",
+ description="Partially update an existing label's properties like name, color, or description.",
+ parameters=[
+ LABEL_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=LabelCreateUpdateSerializer,
+ examples=[LABEL_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Label updated successfully",
+ response=LabelSerializer,
+ examples=[LABEL_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: LABEL_NOT_FOUND_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
+ def patch(self, request, slug, project_id, pk):
+ """Update label
+
+ Partially update an existing label's properties like name, color, or description.
+ Validates external ID uniqueness if provided.
+ """
label = self.get_queryset().get(pk=pk)
- serializer = LabelSerializer(label, data=request.data, partial=True)
+ serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
@@ -635,21 +1024,140 @@ class LabelAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
serializer.save()
+ label = Label.objects.get(pk=serializer.instance.id)
+ serializer = LabelSerializer(label)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- def delete(self, request, slug, project_id, pk=None):
+ @label_docs(
+ operation_id="delete_label",
+ description="Permanently remove a label from the project. This action cannot be undone.",
+ parameters=[
+ LABEL_ID_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ 404: LABEL_NOT_FOUND_RESPONSE,
+ },
+ )
+ def delete(self, request, slug, project_id, pk):
+ """Delete label
+
+ Permanently remove a label from the project.
+ This action cannot be undone.
+ """
label = self.get_queryset().get(pk=pk)
label.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
-class IssueLinkAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to the links of the particular issue.
+class IssueLinkListCreateAPIEndpoint(BaseAPIView):
+ """Work Item Link List and Create Endpoint"""
- """
+ serializer_class = IssueLinkSerializer
+ model = IssueLink
+ permission_classes = [ProjectEntityPermission]
+
+ def get_queryset(self):
+ return (
+ IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(issue_id=self.kwargs.get("issue_id"))
+ .filter(
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .filter(project__archived_at__isnull=True)
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ .distinct()
+ )
+
+ @issue_link_docs(
+ operation_id="list_work_item_links",
+ description="Retrieve all links associated with a work item. Supports filtering by URL, title, and metadata.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueLinkSerializer,
+ "PaginatedIssueLinkResponse",
+ "Paginated list of work item links",
+ "Paginated Work Item Links",
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ISSUE_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, issue_id):
+ """List work item links
+
+ Retrieve all links associated with a work item.
+ """
+ return self.paginate(
+ request=request,
+ queryset=(self.get_queryset()),
+ on_results=lambda issue_links: IssueLinkSerializer(
+ issue_links, many=True, fields=self.fields, expand=self.expand
+ ).data,
+ )
+
+ @issue_link_docs(
+ operation_id="create_work_item_link",
+ description="Add a new external link to a work item with URL, title, and metadata.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IssueLinkCreateSerializer,
+ examples=[ISSUE_LINK_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Work item link created successfully",
+ response=IssueLinkSerializer,
+ examples=[ISSUE_LINK_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ISSUE_NOT_FOUND_RESPONSE,
+ },
+ )
+ def post(self, request, slug, project_id, issue_id):
+ """Create issue link
+
+ Add a new external link to a work item with URL, title, and metadata.
+ Automatically tracks link creation activity.
+ """
+ serializer = IssueLinkCreateSerializer(data=request.data)
+ if serializer.is_valid():
+ serializer.save(project_id=project_id, issue_id=issue_id)
+ crawl_work_item_link_title.delay(
+ serializer.instance.id, serializer.instance.url
+ )
+ link = IssueLink.objects.get(pk=serializer.instance.id)
+ link.created_by_id = request.data.get("created_by", request.user.id)
+ link.save(update_fields=["created_by"])
+ issue_activity.delay(
+ type="link.activity.created",
+ requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
+ issue_id=str(self.kwargs.get("issue_id")),
+ project_id=str(self.kwargs.get("project_id")),
+ actor_id=str(link.created_by_id),
+ current_instance=None,
+ epoch=int(timezone.now().timestamp()),
+ )
+ serializer = IssueLinkSerializer(link)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+
+class IssueLinkDetailAPIEndpoint(BaseAPIView):
+ """Issue Link Detail Endpoint"""
permission_classes = [ProjectEntityPermission]
@@ -670,7 +1178,32 @@ class IssueLinkAPIEndpoint(BaseAPIView):
.distinct()
)
- def get(self, request, slug, project_id, issue_id, pk=None):
+ @issue_link_docs(
+ operation_id="retrieve_work_item_link",
+ description="Retrieve details of a specific work item link.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ LINK_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueLinkSerializer,
+ "PaginatedIssueLinkDetailResponse",
+ "Work item link details or paginated list",
+ "Work Item Link Details",
+ ),
+ 404: OpenApiResponse(description="Issue not found"),
+ },
+ )
+ def get(self, request, slug, project_id, issue_id, pk):
+ """Retrieve work item link
+
+ Retrieve details of a specific work item link.
+ """
if pk is None:
issue_links = self.get_queryset()
serializer = IssueLinkSerializer(
@@ -689,30 +1222,33 @@ class IssueLinkAPIEndpoint(BaseAPIView):
)
return Response(serializer.data, status=status.HTTP_200_OK)
- def post(self, request, slug, project_id, issue_id):
- serializer = IssueLinkSerializer(data=request.data)
- if serializer.is_valid():
- serializer.save(project_id=project_id, issue_id=issue_id)
- crawl_work_item_link_title.delay(
- serializer.data.get("id"), serializer.data.get("url")
- )
-
- link = IssueLink.objects.get(pk=serializer.data["id"])
- link.created_by_id = request.data.get("created_by", request.user.id)
- link.save(update_fields=["created_by"])
- issue_activity.delay(
- type="link.activity.created",
- requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
- issue_id=str(self.kwargs.get("issue_id")),
- project_id=str(self.kwargs.get("project_id")),
- actor_id=str(link.created_by_id),
- current_instance=None,
- epoch=int(timezone.now().timestamp()),
- )
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
+ @issue_link_docs(
+ operation_id="update_issue_link",
+ description="Modify the URL, title, or metadata of an existing issue link.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ LINK_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IssueLinkUpdateSerializer,
+ examples=[ISSUE_LINK_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Issue link updated successfully",
+ response=IssueLinkSerializer,
+ examples=[ISSUE_LINK_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: LINK_NOT_FOUND_RESPONSE,
+ },
+ )
def patch(self, request, slug, project_id, issue_id, pk):
+ """Update issue link
+
+ Modify the URL, title, or metadata of an existing issue link.
+ Tracks all changes in issue activity logs.
+ """
issue_link = IssueLink.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
@@ -735,10 +1271,28 @@ class IssueLinkAPIEndpoint(BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
+ serializer = IssueLinkSerializer(issue_link)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ @issue_link_docs(
+ operation_id="delete_work_item_link",
+ description="Permanently remove an external link from a work item.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ LINK_ID_PARAMETER,
+ ],
+ responses={
+ 204: OpenApiResponse(description="Work item link deleted successfully"),
+ 404: OpenApiResponse(description="Work item link not found"),
+ },
+ )
def delete(self, request, slug, project_id, issue_id, pk):
+ """Delete work item link
+
+ Permanently remove an external link from a work item.
+ Records deletion activity for audit purposes.
+ """
issue_link = IssueLink.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
@@ -758,12 +1312,8 @@ class IssueLinkAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class IssueCommentAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to comments of the particular issue.
-
- """
+class IssueCommentListCreateAPIEndpoint(BaseAPIView):
+ """Issue Comment List and Create Endpoint"""
serializer_class = IssueCommentSerializer
model = IssueComment
@@ -795,22 +1345,67 @@ class IssueCommentAPIEndpoint(BaseAPIView):
.distinct()
)
- def get(self, request, slug, project_id, issue_id, pk=None):
- if pk:
- issue_comment = self.get_queryset().get(pk=pk)
- serializer = IssueCommentSerializer(
- issue_comment, fields=self.fields, expand=self.expand
- )
- return Response(serializer.data, status=status.HTTP_200_OK)
+ @issue_comment_docs(
+ operation_id="list_work_item_comments",
+ description="Retrieve all comments for a work item.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueCommentSerializer,
+ "PaginatedIssueCommentResponse",
+ "Paginated list of work item comments",
+ "Paginated Work Item Comments",
+ ),
+ 404: OpenApiResponse(description="Issue not found"),
+ },
+ )
+ def get(self, request, slug, project_id, issue_id):
+ """List work item comments
+
+ Retrieve all comments for a work item.
+ """
return self.paginate(
request=request,
queryset=(self.get_queryset()),
- on_results=lambda issue_comment: IssueCommentSerializer(
- issue_comment, many=True, fields=self.fields, expand=self.expand
+ on_results=lambda issue_comments: IssueCommentSerializer(
+ issue_comments, many=True, fields=self.fields, expand=self.expand
).data,
)
+ @issue_comment_docs(
+ operation_id="create_work_item_comment",
+ description="Add a new comment to a work item with HTML content.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IssueCommentCreateSerializer,
+ examples=[ISSUE_COMMENT_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Work item comment created successfully",
+ response=IssueCommentSerializer,
+ examples=[ISSUE_COMMENT_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ISSUE_NOT_FOUND_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id, issue_id):
+ """Create work item comment
+
+ Add a new comment to a work item with HTML content.
+ Supports external ID tracking for integration purposes.
+ """
# Validation check if the issue already exists
if (
request.data.get("external_id")
@@ -830,18 +1425,18 @@ class IssueCommentAPIEndpoint(BaseAPIView):
).first()
return Response(
{
- "error": "Issue Comment with the same external id and external source already exists",
+ "error": "Work item comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
- serializer = IssueCommentSerializer(data=request.data)
+ serializer = IssueCommentCreateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
- issue_comment = IssueComment.objects.get(pk=serializer.data.get("id"))
+ issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
# Update the created_at and the created_by and save the comment
issue_comment.created_at = request.data.get("created_at", timezone.now())
issue_comment.created_by_id = request.data.get(
@@ -858,6 +1453,7 @@ class IssueCommentAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
+
# Send the model activity
model_activity.delay(
model_name="issue_comment",
@@ -868,10 +1464,101 @@ class IssueCommentAPIEndpoint(BaseAPIView):
slug=slug,
origin=base_host(request=request, is_app=True),
)
+
+ serializer = IssueCommentSerializer(issue_comment)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+class IssueCommentDetailAPIEndpoint(BaseAPIView):
+ """Work Item Comment Detail Endpoint"""
+
+ serializer_class = IssueCommentSerializer
+ model = IssueComment
+ webhook_event = "issue_comment"
+ permission_classes = [ProjectLitePermission]
+
+ def get_queryset(self):
+ return (
+ IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(issue_id=self.kwargs.get("issue_id"))
+ .filter(
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .filter(project__archived_at__isnull=True)
+ .select_related("workspace", "project", "issue", "actor")
+ .annotate(
+ is_member=Exists(
+ ProjectMember.objects.filter(
+ workspace__slug=self.kwargs.get("slug"),
+ project_id=self.kwargs.get("project_id"),
+ member_id=self.request.user.id,
+ is_active=True,
+ )
+ )
+ )
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ .distinct()
+ )
+
+ @issue_comment_docs(
+ operation_id="retrieve_work_item_comment",
+ description="Retrieve details of a specific comment.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ COMMENT_ID_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Work item comments",
+ response=IssueCommentSerializer,
+ examples=[ISSUE_COMMENT_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ISSUE_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, issue_id, pk):
+ """Retrieve issue comment
+
+ Retrieve details of a specific comment.
+ """
+ issue_comment = self.get_queryset().get(pk=pk)
+ serializer = IssueCommentSerializer(
+ issue_comment, fields=self.fields, expand=self.expand
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @issue_comment_docs(
+ operation_id="update_work_item_comment",
+ description="Modify the content of an existing comment on a work item.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ COMMENT_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IssueCommentCreateSerializer,
+ examples=[ISSUE_COMMENT_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Work item comment updated successfully",
+ response=IssueCommentSerializer,
+ examples=[ISSUE_COMMENT_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: COMMENT_NOT_FOUND_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
def patch(self, request, slug, project_id, issue_id, pk):
+ """Update work item comment
+
+ Modify the content of an existing comment on a work item.
+ Validates external ID uniqueness if provided.
+ """
issue_comment = IssueComment.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
@@ -895,13 +1582,13 @@ class IssueCommentAPIEndpoint(BaseAPIView):
):
return Response(
{
- "error": "Issue Comment with the same external id and external source already exists",
+ "error": "Work item comment with the same external id and external source already exists",
"id": str(issue_comment.id),
},
status=status.HTTP_409_CONFLICT,
)
- serializer = IssueCommentSerializer(
+ serializer = IssueCommentCreateSerializer(
issue_comment, data=request.data, partial=True
)
if serializer.is_valid():
@@ -925,10 +1612,30 @@ class IssueCommentAPIEndpoint(BaseAPIView):
slug=slug,
origin=base_host(request=request, is_app=True),
)
+
+ issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
+ serializer = IssueCommentSerializer(issue_comment)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ @issue_comment_docs(
+ operation_id="delete_work_item_comment",
+ description="Permanently remove a comment from a work item. Records deletion activity for audit purposes.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ COMMENT_ID_PARAMETER,
+ ],
+ responses={
+ 204: OpenApiResponse(description="Work item comment deleted successfully"),
+ 404: COMMENT_NOT_FOUND_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, issue_id, pk):
+ """Delete issue comment
+
+ Permanently remove a comment from a work item.
+ Records deletion activity for audit purposes.
+ """
issue_comment = IssueComment.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
@@ -948,10 +1655,37 @@ class IssueCommentAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class IssueActivityAPIEndpoint(BaseAPIView):
+class IssueActivityListAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
- def get(self, request, slug, project_id, issue_id, pk=None):
+ @issue_activity_docs(
+ operation_id="list_work_item_activities",
+ description="Retrieve all activities for a work item. Supports filtering by activity type and date range.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueActivitySerializer,
+ "PaginatedIssueActivityResponse",
+ "Paginated list of issue activities",
+ "Paginated Issue Activities",
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ISSUE_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, issue_id):
+ """List issue activities
+
+ Retrieve chronological activity logs for an issue.
+ Excludes comment, vote, reaction, and draft activities.
+ """
issue_activities = (
IssueActivity.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
@@ -965,10 +1699,61 @@ class IssueActivityAPIEndpoint(BaseAPIView):
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
- if pk:
- issue_activities = issue_activities.get(pk=pk)
- serializer = IssueActivitySerializer(issue_activities)
- return Response(serializer.data, status=status.HTTP_200_OK)
+ return self.paginate(
+ request=request,
+ queryset=(issue_activities),
+ on_results=lambda issue_activity: IssueActivitySerializer(
+ issue_activity, many=True, fields=self.fields, expand=self.expand
+ ).data,
+ )
+
+
+class IssueActivityDetailAPIEndpoint(BaseAPIView):
+ """Issue Activity Detail Endpoint"""
+
+ permission_classes = [ProjectEntityPermission]
+
+ @issue_activity_docs(
+ operation_id="retrieve_work_item_activity",
+ description="Retrieve details of a specific activity.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ ACTIVITY_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueActivitySerializer,
+ "PaginatedIssueActivityDetailResponse",
+ "Paginated list of work item activities",
+ "Work Item Activity Details",
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ISSUE_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, issue_id, pk):
+ """Retrieve issue activity
+
+ Retrieve details of a specific activity.
+ Excludes comment, vote, reaction, and draft activities.
+ """
+ issue_activities = (
+ IssueActivity.objects.filter(
+ issue_id=issue_id, workspace__slug=slug, project_id=project_id
+ )
+ .filter(
+ ~Q(field__in=["comment", "vote", "reaction", "draft"]),
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .filter(project__archived_at__isnull=True)
+ .select_related("actor", "workspace", "issue", "project")
+ ).order_by(request.GET.get("order_by", "created_at"))
return self.paginate(
request=request,
@@ -979,12 +1764,93 @@ class IssueActivityAPIEndpoint(BaseAPIView):
)
-class IssueAttachmentEndpoint(BaseAPIView):
- serializer_class = IssueAttachmentSerializer
- permission_classes = [ProjectEntityPermission]
- model = FileAsset
+class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
+ """Issue Attachment List and Create Endpoint"""
+ serializer_class = IssueAttachmentSerializer
+ model = FileAsset
+ permission_classes = [ProjectEntityPermission]
+
+ @issue_attachment_docs(
+ operation_id="create_work_item_attachment",
+ description="Generate presigned URL for uploading file attachments to a work item.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=IssueAttachmentUploadSerializer,
+ examples=[ISSUE_ATTACHMENT_UPLOAD_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Presigned download URL generated successfully",
+ examples=[
+ OpenApiExample(
+ name="Work Item Attachment Response",
+ value={
+ "upload_data": {
+ "url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url",
+ "fields": {
+ "key": "file.pdf",
+ "AWSAccessKeyId": "AKIAIOSFODNN7EXAMPLE",
+ "policy": "EXAMPLE",
+ "signature": "EXAMPLE",
+ "acl": "public-read",
+ "Content-Type": "application/pdf",
+ },
+ },
+ "asset_id": "550e8400-e29b-41d4-a716-446655440000",
+ "asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url",
+ "attachment": {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "file.pdf",
+ "type": "application/pdf",
+ "size": 1234567890,
+ "url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url",
+ },
+ },
+ )
+ ],
+ ),
+ 400: OpenApiResponse(
+ description="Validation error",
+ examples=[
+ OpenApiExample(
+ name="Missing required fields",
+ value={
+ "error": "Name and size are required fields.",
+ "status": False,
+ },
+ ),
+ OpenApiExample(
+ name="Invalid file type",
+ value={"error": "Invalid file type.", "status": False},
+ ),
+ ],
+ ),
+ 404: OpenApiResponse(
+ description="Issue or Project or Workspace not found",
+ examples=[
+ OpenApiExample(
+ name="Workspace not found",
+ value={"error": "Workspace not found"},
+ ),
+ OpenApiExample(
+ name="Project not found", value={"error": "Project not found"}
+ ),
+ OpenApiExample(
+ name="Issue not found", value={"error": "Issue not found"}
+ ),
+ ],
+ ),
+ },
+ )
def post(self, request, slug, project_id, issue_id):
+ """Create work item attachment
+
+ Generate presigned URL for uploading file attachments to a work item.
+ Validates file type and size before creating the attachment record.
+ """
name = request.data.get("name")
type = request.data.get("type", False)
size = request.data.get("size")
@@ -1071,7 +1937,66 @@ class IssueAttachmentEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
+ @issue_attachment_docs(
+ operation_id="list_work_item_attachments",
+ description="Retrieve all attachments for a work item.",
+ parameters=[
+ ISSUE_ID_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Work item attachment",
+ response=IssueAttachmentSerializer,
+ examples=[ISSUE_ATTACHMENT_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ATTACHMENT_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, issue_id):
+ """List issue attachments
+
+ List all attachments for an issue.
+ """
+ # Get all the attachments
+ issue_attachments = FileAsset.objects.filter(
+ issue_id=issue_id,
+ entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
+ workspace__slug=slug,
+ project_id=project_id,
+ is_uploaded=True,
+ )
+ # Serialize the attachments
+ serializer = IssueAttachmentSerializer(issue_attachments, many=True)
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
+ """Issue Attachment Detail Endpoint"""
+
+ serializer_class = IssueAttachmentSerializer
+ permission_classes = [ProjectEntityPermission]
+ model = FileAsset
+
+ @issue_attachment_docs(
+ operation_id="delete_work_item_attachment",
+ description="Permanently remove an attachment from a work item. Records deletion activity for audit purposes.",
+ parameters=[
+ ATTACHMENT_ID_PARAMETER,
+ ],
+ responses={
+ 204: OpenApiResponse(
+ description="Work item attachment deleted successfully"
+ ),
+ 404: ATTACHMENT_NOT_FOUND_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, issue_id, pk):
+ """Delete work item attachment
+
+ Soft delete an attachment from a work item by marking it as deleted.
+ Records deletion activity and triggers metadata cleanup.
+ """
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
@@ -1097,41 +2022,97 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
- def get(self, request, slug, project_id, issue_id, pk=None):
- if pk:
- # Get the asset
- asset = FileAsset.objects.get(
- id=pk, workspace__slug=slug, project_id=project_id
- )
+ @issue_attachment_docs(
+ operation_id="retrieve_work_item_attachment",
+ description="Download attachment file. Returns a redirect to the presigned download URL.",
+ parameters=[
+ ATTACHMENT_ID_PARAMETER,
+ ],
+ responses={
+ 302: OpenApiResponse(
+ description="Redirect to presigned download URL",
+ ),
+ 400: OpenApiResponse(
+ description="Asset not uploaded",
+ response={
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Error message",
+ "example": "The asset is not uploaded.",
+ },
+ "status": {
+ "type": "boolean",
+ "description": "Request status",
+ "example": False,
+ },
+ },
+ },
+ examples=[ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE],
+ ),
+ 404: ATTACHMENT_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, project_id, issue_id, pk):
+ """Retrieve work item attachment
- # Check if the asset is uploaded
- if not asset.is_uploaded:
- return Response(
- {"error": "The asset is not uploaded.", "status": False},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- storage = S3Storage(request=request)
- presigned_url = storage.generate_presigned_url(
- object_name=asset.asset.name,
- disposition="attachment",
- filename=asset.attributes.get("name"),
- )
- return HttpResponseRedirect(presigned_url)
-
- # Get all the attachments
- issue_attachments = FileAsset.objects.filter(
- issue_id=issue_id,
- entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
- workspace__slug=slug,
- project_id=project_id,
- is_uploaded=True,
+ Retrieve details of a specific attachment.
+ """
+ # Get the asset
+ asset = FileAsset.objects.get(
+ id=pk, workspace__slug=slug, project_id=project_id
)
- # Serialize the attachments
- serializer = IssueAttachmentSerializer(issue_attachments, many=True)
- return Response(serializer.data, status=status.HTTP_200_OK)
+ # Check if the asset is uploaded
+ if not asset.is_uploaded:
+ return Response(
+ {"error": "The asset is not uploaded.", "status": False},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ storage = S3Storage(request=request)
+ presigned_url = storage.generate_presigned_url(
+ object_name=asset.asset.name,
+ disposition="attachment",
+ filename=asset.attributes.get("name"),
+ )
+ return HttpResponseRedirect(presigned_url)
+
+ @issue_attachment_docs(
+ operation_id="upload_work_item_attachment",
+ description="Mark an attachment as uploaded after successful file transfer to storage.",
+ parameters=[
+ ATTACHMENT_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request={
+ "application/json": {
+ "type": "object",
+ "properties": {
+ "is_uploaded": {
+ "type": "boolean",
+ "description": "Mark attachment as uploaded",
+ }
+ },
+ }
+ },
+ examples=[ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE],
+ ),
+ responses={
+ 204: OpenApiResponse(
+ description="Work item attachment uploaded successfully"
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: ATTACHMENT_NOT_FOUND_RESPONSE,
+ },
+ )
def patch(self, request, slug, project_id, issue_id, pk):
+ """Confirm attachment upload
+
+ Mark an attachment as uploaded after successful file transfer to storage.
+ Triggers activity logging and metadata extraction.
+ """
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
@@ -1160,3 +2141,81 @@ class IssueAttachmentEndpoint(BaseAPIView):
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class IssueSearchEndpoint(BaseAPIView):
+ """Endpoint to search across multiple fields in the issues"""
+
+ @extend_schema(
+ operation_id="search_work_items",
+ tags=["Work Items"],
+ description="Perform semantic search across issue names, sequence IDs, and project identifiers.",
+ parameters=[
+ WORKSPACE_SLUG_PARAMETER,
+ SEARCH_PARAMETER_REQUIRED,
+ LIMIT_PARAMETER,
+ WORKSPACE_SEARCH_PARAMETER,
+ PROJECT_ID_QUERY_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Work item search results",
+ response=IssueSearchSerializer,
+ examples=[ISSUE_SEARCH_EXAMPLE],
+ ),
+ 400: BAD_SEARCH_REQUEST_RESPONSE,
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: WORKSPACE_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug):
+ """Search work items
+
+ Perform semantic search across work item names, sequence IDs, and project identifiers.
+ Supports workspace-wide or project-specific search with configurable result limits.
+ """
+ query = request.query_params.get("search", False)
+ limit = request.query_params.get("limit", 10)
+ workspace_search = request.query_params.get("workspace_search", "false")
+ project_id = request.query_params.get("project_id", False)
+
+ if not query:
+ return Response({"issues": []}, status=status.HTTP_200_OK)
+
+ # Build search query
+ fields = ["name", "sequence_id", "project__identifier"]
+ q = Q()
+ for field in fields:
+ if field == "sequence_id":
+ # Match whole integers only (exclude decimal numbers)
+ sequences = re.findall(r"\b\d+\b", query)
+ for sequence_id in sequences:
+ q |= Q(**{"sequence_id": sequence_id})
+ else:
+ q |= Q(**{f"{field}__icontains": query})
+
+ # Filter issues
+ issues = Issue.issue_objects.filter(
+ q,
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ project__archived_at__isnull=True,
+ workspace__slug=slug,
+ )
+
+ # Apply project filter if not searching across workspace
+ if workspace_search == "false" and project_id:
+ issues = issues.filter(project_id=project_id)
+
+ # Get results
+ issue_results = issues.distinct().values(
+ "name",
+ "id",
+ "sequence_id",
+ "project__identifier",
+ "project_id",
+ "workspace__slug",
+ )[: int(limit)]
+
+ return Response({"issues": issue_results}, status=status.HTTP_200_OK)
diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py
index 954ee030b..a6d7176d7 100644
--- a/apps/api/plane/api/views/member.py
+++ b/apps/api/plane/api/views/member.py
@@ -1,29 +1,122 @@
-# Python imports
-import uuid
-
-# Django imports
-from django.contrib.auth.hashers import make_password
-from django.core.validators import validate_email
-from django.core.exceptions import ValidationError
-
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
+from drf_spectacular.utils import (
+ extend_schema,
+ OpenApiResponse,
+)
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer
-from plane.db.models import User, Workspace, Project, WorkspaceMember, ProjectMember
+from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
+from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission
+from plane.utils.openapi import (
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ UNAUTHORIZED_RESPONSE,
+ FORBIDDEN_RESPONSE,
+ WORKSPACE_NOT_FOUND_RESPONSE,
+ PROJECT_NOT_FOUND_RESPONSE,
+ WORKSPACE_MEMBER_EXAMPLE,
+ PROJECT_MEMBER_EXAMPLE,
+)
-from plane.app.permissions import ProjectMemberPermission
+
+class WorkspaceMemberAPIEndpoint(BaseAPIView):
+ permission_classes = [
+ WorkSpaceAdminPermission,
+ ]
+
+ @extend_schema(
+ operation_id="get_workspace_members",
+ summary="List workspace members",
+ description="Retrieve all users who are members of the specified workspace.",
+ tags=["Members"],
+ parameters=[WORKSPACE_SLUG_PARAMETER],
+ responses={
+ 200: OpenApiResponse(
+ description="List of workspace members with their roles",
+ response={
+ "type": "array",
+ "items": {
+ "allOf": [
+ {"$ref": "#/components/schemas/UserLite"},
+ {
+ "type": "object",
+ "properties": {
+ "role": {
+ "type": "integer",
+ "description": "Member role in the workspace",
+ }
+ },
+ },
+ ]
+ },
+ },
+ examples=[WORKSPACE_MEMBER_EXAMPLE],
+ ),
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: WORKSPACE_NOT_FOUND_RESPONSE,
+ },
+ )
+ # Get all the users that are present inside the workspace
+ def get(self, request, slug):
+ """List workspace members
+
+ Retrieve all users who are members of the specified workspace.
+ Returns user profiles with their respective workspace roles and permissions.
+ """
+ # Check if the workspace exists
+ if not Workspace.objects.filter(slug=slug).exists():
+ return Response(
+ {"error": "Provided workspace does not exist"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ workspace_members = WorkspaceMember.objects.filter(
+ workspace__slug=slug
+ ).select_related("member")
+
+ # Get all the users with their roles
+ users_with_roles = []
+ for workspace_member in workspace_members:
+ user_data = UserLiteSerializer(workspace_member.member).data
+ user_data["role"] = workspace_member.role
+ users_with_roles.append(user_data)
+
+ return Response(users_with_roles, status=status.HTTP_200_OK)
# API endpoint to get and insert users inside the workspace
class ProjectMemberAPIEndpoint(BaseAPIView):
permission_classes = [ProjectMemberPermission]
+ @extend_schema(
+ operation_id="get_project_members",
+ summary="List project members",
+ description="Retrieve all users who are members of the specified project.",
+ tags=["Members"],
+ parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ responses={
+ 200: OpenApiResponse(
+ description="List of project members with their roles",
+ response=UserLiteSerializer,
+ examples=[PROJECT_MEMBER_EXAMPLE],
+ ),
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ },
+ )
# Get all the users that are present inside the workspace
def get(self, request, slug, project_id):
+ """List project members
+
+ Retrieve all users who are members of the specified project.
+ Returns user profiles with their project-specific roles and access levels.
+ """
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
@@ -42,91 +135,3 @@ class ProjectMemberAPIEndpoint(BaseAPIView):
).data
return Response(users, status=status.HTTP_200_OK)
-
- # Insert a new user inside the workspace, and assign the user to the project
- def post(self, request, slug, project_id):
- # Check if user with email already exists, and send bad request if it's
- # not present, check for workspace and valid project mandat
- # ------------------- Validation -------------------
- if (
- request.data.get("email") is None
- or request.data.get("display_name") is None
- ):
- return Response(
- {
- "error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing."
- },
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- email = request.data.get("email")
-
- try:
- validate_email(email)
- except ValidationError:
- return Response(
- {"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST
- )
-
- workspace = Workspace.objects.filter(slug=slug).first()
- project = Project.objects.filter(pk=project_id).first()
-
- if not all([workspace, project]):
- return Response(
- {"error": "Provided workspace or project does not exist"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Check if user exists
- user = User.objects.filter(email=email).first()
- workspace_member = None
- project_member = None
-
- if user:
- # Check if user is part of the workspace
- workspace_member = WorkspaceMember.objects.filter(
- workspace=workspace, member=user
- ).first()
- if workspace_member:
- # Check if user is part of the project
- project_member = ProjectMember.objects.filter(
- project=project, member=user
- ).first()
- if project_member:
- return Response(
- {"error": "User is already part of the workspace and project"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # If user does not exist, create the user
- if not user:
- user = User.objects.create(
- email=email,
- display_name=request.data.get("display_name"),
- first_name=request.data.get("first_name", ""),
- last_name=request.data.get("last_name", ""),
- username=uuid.uuid4().hex,
- password=make_password(uuid.uuid4().hex),
- is_password_autoset=True,
- is_active=False,
- )
- user.save()
-
- # Create a workspace member for the user if not already a member
- if not workspace_member:
- workspace_member = WorkspaceMember.objects.create(
- workspace=workspace, member=user, role=request.data.get("role", 5)
- )
- workspace_member.save()
-
- # Create a project member for the user if not already a member
- if not project_member:
- project_member = ProjectMember.objects.create(
- project=project, member=user, role=request.data.get("role", 5)
- )
- project_member.save()
-
- # Serialize the user and return the response
- user_data = UserLiteSerializer(user).data
-
- return Response(user_data, status=status.HTTP_201_CREATED)
diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py
index 9995bb806..e0392dfba 100644
--- a/apps/api/plane/api/views/module.py
+++ b/apps/api/plane/api/views/module.py
@@ -10,12 +10,16 @@ from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
# Module imports
from plane.api.serializers import (
IssueSerializer,
ModuleIssueSerializer,
ModuleSerializer,
+ ModuleIssueRequestSerializer,
+ ModuleCreateSerializer,
+ ModuleUpdateSerializer,
)
from plane.app.permissions import ProjectEntityPermission
from plane.bgtasks.issue_activities_task import issue_activity
@@ -34,14 +38,245 @@ from plane.db.models import (
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
from plane.utils.host import base_host
+from plane.utils.openapi import (
+ module_docs,
+ module_issue_docs,
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ MODULE_ID_PARAMETER,
+ MODULE_PK_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ create_paginated_response,
+ # Request Examples
+ MODULE_CREATE_EXAMPLE,
+ MODULE_UPDATE_EXAMPLE,
+ MODULE_ISSUE_REQUEST_EXAMPLE,
+ # Response Examples
+ MODULE_EXAMPLE,
+ MODULE_ISSUE_EXAMPLE,
+ INVALID_REQUEST_RESPONSE,
+ PROJECT_NOT_FOUND_RESPONSE,
+ EXTERNAL_ID_EXISTS_RESPONSE,
+ MODULE_NOT_FOUND_RESPONSE,
+ DELETED_RESPONSE,
+ ADMIN_ONLY_RESPONSE,
+ REQUIRED_FIELDS_RESPONSE,
+ MODULE_ISSUE_NOT_FOUND_RESPONSE,
+ ARCHIVED_RESPONSE,
+ CANNOT_ARCHIVE_RESPONSE,
+ UNARCHIVED_RESPONSE,
+)
-class ModuleAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to module.
+class ModuleListCreateAPIEndpoint(BaseAPIView):
+ """Module List and Create Endpoint"""
- """
+ serializer_class = ModuleSerializer
+ model = Module
+ webhook_event = "module"
+ permission_classes = [ProjectEntityPermission]
+
+ def get_queryset(self):
+ return (
+ Module.objects.filter(project_id=self.kwargs.get("project_id"))
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .select_related("project")
+ .select_related("workspace")
+ .select_related("lead")
+ .prefetch_related("members")
+ .prefetch_related(
+ Prefetch(
+ "link_module",
+ queryset=ModuleLink.objects.select_related("module", "created_by"),
+ )
+ )
+ .annotate(
+ total_issues=Count(
+ "issue_module",
+ filter=Q(
+ issue_module__issue__archived_at__isnull=True,
+ issue_module__issue__is_draft=False,
+ issue_module__deleted_at__isnull=True,
+ ),
+ distinct=True,
+ )
+ )
+ .annotate(
+ completed_issues=Count(
+ "issue_module__issue__state__group",
+ filter=Q(
+ issue_module__issue__state__group="completed",
+ issue_module__issue__archived_at__isnull=True,
+ issue_module__issue__is_draft=False,
+ issue_module__deleted_at__isnull=True,
+ ),
+ distinct=True,
+ )
+ )
+ .annotate(
+ cancelled_issues=Count(
+ "issue_module__issue__state__group",
+ filter=Q(
+ issue_module__issue__state__group="cancelled",
+ issue_module__issue__archived_at__isnull=True,
+ issue_module__issue__is_draft=False,
+ issue_module__deleted_at__isnull=True,
+ ),
+ distinct=True,
+ )
+ )
+ .annotate(
+ started_issues=Count(
+ "issue_module__issue__state__group",
+ filter=Q(
+ issue_module__issue__state__group="started",
+ issue_module__issue__archived_at__isnull=True,
+ issue_module__issue__is_draft=False,
+ issue_module__deleted_at__isnull=True,
+ ),
+ distinct=True,
+ )
+ )
+ .annotate(
+ unstarted_issues=Count(
+ "issue_module__issue__state__group",
+ filter=Q(
+ issue_module__issue__state__group="unstarted",
+ issue_module__issue__archived_at__isnull=True,
+ issue_module__issue__is_draft=False,
+ issue_module__deleted_at__isnull=True,
+ ),
+ distinct=True,
+ )
+ )
+ .annotate(
+ backlog_issues=Count(
+ "issue_module__issue__state__group",
+ filter=Q(
+ issue_module__issue__state__group="backlog",
+ issue_module__issue__archived_at__isnull=True,
+ issue_module__issue__is_draft=False,
+ issue_module__deleted_at__isnull=True,
+ ),
+ distinct=True,
+ )
+ )
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ )
+
+ @module_docs(
+ operation_id="create_module",
+ summary="Create module",
+ description="Create a new project module with specified name, description, and timeline.",
+ request=OpenApiRequest(
+ request=ModuleCreateSerializer,
+ examples=[MODULE_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Module created",
+ response=ModuleSerializer,
+ examples=[MODULE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
+ def post(self, request, slug, project_id):
+ """Create module
+
+ Create a new project module with specified name, description, and timeline.
+ Automatically assigns the creator as module lead and tracks activity.
+ """
+ project = Project.objects.get(pk=project_id, workspace__slug=slug)
+ serializer = ModuleCreateSerializer(
+ data=request.data,
+ context={"project_id": project_id, "workspace_id": project.workspace_id},
+ )
+ if serializer.is_valid():
+ if (
+ request.data.get("external_id")
+ and request.data.get("external_source")
+ and Module.objects.filter(
+ project_id=project_id,
+ workspace__slug=slug,
+ external_source=request.data.get("external_source"),
+ external_id=request.data.get("external_id"),
+ ).exists()
+ ):
+ module = Module.objects.filter(
+ project_id=project_id,
+ workspace__slug=slug,
+ external_source=request.data.get("external_source"),
+ external_id=request.data.get("external_id"),
+ ).first()
+ return Response(
+ {
+ "error": "Module with the same external id and external source already exists",
+ "id": str(module.id),
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ serializer.save()
+ # Send the model activity
+ model_activity.delay(
+ model_name="module",
+ model_id=str(serializer.instance.id),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=base_host(request=request, is_app=True),
+ )
+ module = Module.objects.get(pk=serializer.instance.id)
+ serializer = ModuleSerializer(module)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ @module_docs(
+ operation_id="list_modules",
+ summary="List modules",
+ description="Retrieve all modules in a project.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ ModuleSerializer,
+ "PaginatedModuleResponse",
+ "Paginated list of modules",
+ "Paginated Modules",
+ ),
+ 404: OpenApiResponse(description="Module not found"),
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List or retrieve modules
+
+ Retrieve all modules in a project or get details of a specific module.
+ Returns paginated results with module statistics and member information.
+ """
+ return self.paginate(
+ request=request,
+ queryset=(self.get_queryset().filter(archived_at__isnull=True)),
+ on_results=lambda modules: ModuleSerializer(
+ modules, many=True, fields=self.fields, expand=self.expand
+ ).data,
+ )
+
+
+class ModuleDetailAPIEndpoint(BaseAPIView):
+ """Module Detail Endpoint"""
model = Module
permission_classes = [ProjectEntityPermission]
@@ -136,53 +371,40 @@ class ModuleAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
- def post(self, request, slug, project_id):
- project = Project.objects.get(pk=project_id, workspace__slug=slug)
- serializer = ModuleSerializer(
- data=request.data,
- context={"project_id": project_id, "workspace_id": project.workspace_id},
- )
- if serializer.is_valid():
- if (
- request.data.get("external_id")
- and request.data.get("external_source")
- and Module.objects.filter(
- project_id=project_id,
- workspace__slug=slug,
- external_source=request.data.get("external_source"),
- external_id=request.data.get("external_id"),
- ).exists()
- ):
- module = Module.objects.filter(
- project_id=project_id,
- workspace__slug=slug,
- external_source=request.data.get("external_source"),
- external_id=request.data.get("external_id"),
- ).first()
- return Response(
- {
- "error": "Module with the same external id and external source already exists",
- "id": str(module.id),
- },
- status=status.HTTP_409_CONFLICT,
- )
- serializer.save()
- # Send the model activity
- model_activity.delay(
- model_name="module",
- model_id=str(serializer.data["id"]),
- requested_data=request.data,
- current_instance=None,
- actor_id=request.user.id,
- slug=slug,
- origin=base_host(request=request, is_app=True),
- )
- module = Module.objects.get(pk=serializer.data["id"])
- serializer = ModuleSerializer(module)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
+ @module_docs(
+ operation_id="update_module",
+ summary="Update module",
+ description="Modify an existing module's properties like name, description, status, or timeline.",
+ parameters=[
+ MODULE_PK_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=ModuleUpdateSerializer,
+ examples=[MODULE_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Module updated successfully",
+ response=ModuleSerializer,
+ examples=[MODULE_EXAMPLE],
+ ),
+ 400: OpenApiResponse(
+ description="Invalid request data",
+ response=ModuleSerializer,
+ examples=[MODULE_UPDATE_EXAMPLE],
+ ),
+ 404: OpenApiResponse(description="Module not found"),
+ 409: OpenApiResponse(
+ description="Module with same external ID already exists"
+ ),
+ },
+ )
def patch(self, request, slug, project_id, pk):
+ """Update module
+
+ Modify an existing module's properties like name, description, status, or timeline.
+ Tracks all changes in model activity logs for audit purposes.
+ """
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
current_instance = json.dumps(
@@ -222,7 +444,7 @@ class ModuleAPIEndpoint(BaseAPIView):
# Send the model activity
model_activity.delay(
model_name="module",
- model_id=str(serializer.data["id"]),
+ model_id=str(serializer.instance.id),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
@@ -233,22 +455,50 @@ class ModuleAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- def get(self, request, slug, project_id, pk=None):
- if pk:
- queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
- data = ModuleSerializer(
- queryset, fields=self.fields, expand=self.expand
- ).data
- return Response(data, status=status.HTTP_200_OK)
- return self.paginate(
- request=request,
- queryset=(self.get_queryset().filter(archived_at__isnull=True)),
- on_results=lambda modules: ModuleSerializer(
- modules, many=True, fields=self.fields, expand=self.expand
- ).data,
- )
+ @module_docs(
+ operation_id="retrieve_module",
+ summary="Retrieve module",
+ description="Retrieve details of a specific module.",
+ parameters=[
+ MODULE_PK_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Module",
+ response=ModuleSerializer,
+ examples=[MODULE_EXAMPLE],
+ ),
+ 404: OpenApiResponse(description="Module not found"),
+ },
+ )
+ def get(self, request, slug, project_id, pk):
+ """Retrieve module
+ Retrieve details of a specific module.
+ """
+ queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
+ data = ModuleSerializer(queryset, fields=self.fields, expand=self.expand).data
+ return Response(data, status=status.HTTP_200_OK)
+
+ @module_docs(
+ operation_id="delete_module",
+ summary="Delete module",
+ description="Permanently remove a module and all its associated issue relationships.",
+ parameters=[
+ MODULE_PK_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ 403: ADMIN_ONLY_RESPONSE,
+ 404: MODULE_NOT_FOUND_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, pk):
+ """Delete module
+
+ Permanently remove a module and all its associated issue relationships.
+ Only admins or the module creator can perform this action.
+ """
module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
if module.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
@@ -293,18 +543,12 @@ class ModuleAPIEndpoint(BaseAPIView):
return Response(status=status.HTTP_204_NO_CONTENT)
-class ModuleIssueAPIEndpoint(BaseAPIView):
- """
- This viewset automatically provides `list`, `create`, `retrieve`,
- `update` and `destroy` actions related to module issues.
-
- """
+class ModuleIssueListCreateAPIEndpoint(BaseAPIView):
+ """Module Work Item List and Create Endpoint"""
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
- bulk = True
-
permission_classes = [ProjectEntityPermission]
def get_queryset(self):
@@ -333,7 +577,35 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
.distinct()
)
+ @module_issue_docs(
+ operation_id="list_module_work_items",
+ summary="List module work items",
+ description="Retrieve all work items assigned to a module with detailed information.",
+ parameters=[
+ MODULE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ request={},
+ responses={
+ 200: create_paginated_response(
+ IssueSerializer,
+ "PaginatedModuleIssueResponse",
+ "Paginated list of module work items",
+ "Paginated Module Work Items",
+ ),
+ 404: OpenApiResponse(description="Module not found"),
+ },
+ )
def get(self, request, slug, project_id, module_id):
+ """List module work items
+
+ Retrieve all work items assigned to a module with detailed information.
+ Returns paginated results including assignees, labels, and attachments.
+ """
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
@@ -379,7 +651,33 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
).data,
)
+ @module_issue_docs(
+ operation_id="add_module_work_items",
+ summary="Add Work Items to Module",
+ description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.",
+ parameters=[
+ MODULE_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=ModuleIssueRequestSerializer,
+ examples=[MODULE_ISSUE_REQUEST_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Module issues added",
+ response=ModuleIssueSerializer,
+ examples=[MODULE_ISSUE_EXAMPLE],
+ ),
+ 400: REQUIRED_FIELDS_RESPONSE,
+ 404: MODULE_NOT_FOUND_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id, module_id):
+ """Add module work items
+
+ Assign multiple work items to a module or move them from another module.
+ Automatically handles bulk creation and updates with activity tracking.
+ """
issues = request.data.get("issues", [])
if not len(issues):
return Response(
@@ -459,7 +757,142 @@ class ModuleIssueAPIEndpoint(BaseAPIView):
status=status.HTTP_200_OK,
)
+
+class ModuleIssueDetailAPIEndpoint(BaseAPIView):
+ """
+ This viewset automatically provides `list`, `create`, `retrieve`,
+ `update` and `destroy` actions related to module work items.
+
+ """
+
+ serializer_class = ModuleIssueSerializer
+ model = ModuleIssue
+ webhook_event = "module_issue"
+ bulk = True
+
+ permission_classes = [ProjectEntityPermission]
+
+ def get_queryset(self):
+ return (
+ ModuleIssue.objects.annotate(
+ sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(module_id=self.kwargs.get("module_id"))
+ .filter(
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .filter(project__archived_at__isnull=True)
+ .select_related("project")
+ .select_related("workspace")
+ .select_related("module")
+ .select_related("issue", "issue__state", "issue__project")
+ .prefetch_related("issue__assignees", "issue__labels")
+ .prefetch_related("module__members")
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ .distinct()
+ )
+
+ @module_issue_docs(
+ operation_id="retrieve_module_work_item",
+ summary="Retrieve module work item",
+ description="Retrieve details of a specific module work item.",
+ parameters=[
+ MODULE_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ IssueSerializer,
+ "PaginatedModuleIssueDetailResponse",
+ "Paginated list of module work item details",
+ "Module Work Item Details",
+ ),
+ 404: OpenApiResponse(description="Module not found"),
+ },
+ )
+ def get(self, request, slug, project_id, module_id, issue_id):
+ """List module work items
+
+ Retrieve all work items assigned to a module with detailed information.
+ Returns paginated results including assignees, labels, and attachments.
+ """
+ order_by = request.GET.get("order_by", "created_at")
+ issues = (
+ Issue.issue_objects.filter(
+ issue_module__module_id=module_id,
+ issue_module__deleted_at__isnull=True,
+ pk=issue_id,
+ )
+ .annotate(
+ sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(bridge_id=F("issue_module__id"))
+ .filter(project_id=project_id)
+ .filter(workspace__slug=slug)
+ .select_related("project")
+ .select_related("workspace")
+ .select_related("state")
+ .select_related("parent")
+ .prefetch_related("assignees")
+ .prefetch_related("labels")
+ .order_by(order_by)
+ .annotate(
+ link_count=IssueLink.objects.filter(issue=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ attachment_count=FileAsset.objects.filter(
+ issue_id=OuterRef("id"),
+ entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
+ )
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ )
+ return self.paginate(
+ request=request,
+ queryset=(issues),
+ on_results=lambda issues: IssueSerializer(
+ issues, many=True, fields=self.fields, expand=self.expand
+ ).data,
+ )
+
+ @module_issue_docs(
+ operation_id="delete_module_work_item",
+ summary="Delete module work item",
+ description="Remove a work item from a module while keeping the work item in the project.",
+ parameters=[
+ MODULE_ID_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ 404: MODULE_ISSUE_NOT_FOUND_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, module_id, issue_id):
+ """Remove module work item
+
+ Remove a work item from a module while keeping the work item in the project.
+ Records the removal activity for tracking purposes.
+ """
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
project_id=project_id,
@@ -573,7 +1006,34 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.order_by(self.kwargs.get("order_by", "-created_at"))
)
- def get(self, request, slug, project_id, pk):
+ @module_docs(
+ operation_id="list_archived_modules",
+ summary="List archived modules",
+ description="Retrieve all modules that have been archived in the project.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ request={},
+ responses={
+ 200: create_paginated_response(
+ ModuleSerializer,
+ "PaginatedArchivedModuleResponse",
+ "Paginated list of archived modules",
+ "Paginated Archived Modules",
+ ),
+ 404: OpenApiResponse(description="Project not found"),
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List archived modules
+
+ Retrieve all modules that have been archived in the project.
+ Returns paginated results with module statistics.
+ """
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -582,7 +1042,26 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).data,
)
+ @module_docs(
+ operation_id="archive_module",
+ summary="Archive module",
+ description="Move a module to archived status for historical tracking.",
+ parameters=[
+ MODULE_PK_PARAMETER,
+ ],
+ request={},
+ responses={
+ 204: ARCHIVED_RESPONSE,
+ 400: CANNOT_ARCHIVE_RESPONSE,
+ 404: MODULE_NOT_FOUND_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id, pk):
+ """Archive module
+
+ Move a completed module to archived status for historical tracking.
+ Only modules with completed status can be archived.
+ """
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
if module.status not in ["completed", "cancelled"]:
return Response(
@@ -599,7 +1078,24 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+ @module_docs(
+ operation_id="unarchive_module",
+ summary="Unarchive module",
+ description="Restore an archived module to active status, making it available for regular use.",
+ parameters=[
+ MODULE_PK_PARAMETER,
+ ],
+ responses={
+ 204: UNARCHIVED_RESPONSE,
+ 404: MODULE_NOT_FOUND_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, pk):
+ """Unarchive module
+
+ Restore an archived module to active status, making it available for regular use.
+ The module will reappear in active module lists and become fully functional.
+ """
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
module.archived_at = None
module.save()
diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py
index 038d4faec..b89129a7f 100644
--- a/apps/api/plane/api/views/project.py
+++ b/apps/api/plane/api/views/project.py
@@ -11,9 +11,8 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
+from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
-from plane.api.serializers import ProjectSerializer
-from plane.app.permissions import ProjectBasePermission
# Module imports
from plane.db.models import (
@@ -31,10 +30,301 @@ from plane.db.models import (
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
from plane.utils.host import base_host
+from plane.api.serializers import (
+ ProjectSerializer,
+ ProjectCreateSerializer,
+ ProjectUpdateSerializer,
+)
+from plane.app.permissions import ProjectBasePermission
+from plane.utils.openapi import (
+ project_docs,
+ PROJECT_ID_PARAMETER,
+ PROJECT_PK_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ create_paginated_response,
+ # Request Examples
+ PROJECT_CREATE_EXAMPLE,
+ PROJECT_UPDATE_EXAMPLE,
+ # Response Examples
+ PROJECT_EXAMPLE,
+ PROJECT_NOT_FOUND_RESPONSE,
+ WORKSPACE_NOT_FOUND_RESPONSE,
+ PROJECT_NAME_TAKEN_RESPONSE,
+ DELETED_RESPONSE,
+ ARCHIVED_RESPONSE,
+ UNARCHIVED_RESPONSE,
+)
-class ProjectAPIEndpoint(BaseAPIView):
- """Project Endpoints to create, update, list, retrieve and delete endpoint"""
+class ProjectListCreateAPIEndpoint(BaseAPIView):
+ """Project List and Create Endpoint"""
+
+ serializer_class = ProjectSerializer
+ model = Project
+ webhook_event = "project"
+ permission_classes = [ProjectBasePermission]
+
+ def get_queryset(self):
+ return (
+ Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(
+ Q(
+ project_projectmember__member=self.request.user,
+ project_projectmember__is_active=True,
+ )
+ | Q(network=2)
+ )
+ .select_related(
+ "workspace", "workspace__owner", "default_assignee", "project_lead"
+ )
+ .annotate(
+ is_member=Exists(
+ ProjectMember.objects.filter(
+ member=self.request.user,
+ project_id=OuterRef("pk"),
+ workspace__slug=self.kwargs.get("slug"),
+ is_active=True,
+ )
+ )
+ )
+ .annotate(
+ total_members=ProjectMember.objects.filter(
+ project_id=OuterRef("id"), member__is_bot=False, is_active=True
+ )
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ total_modules=Module.objects.filter(project_id=OuterRef("id"))
+ .order_by()
+ .annotate(count=Func(F("id"), function="Count"))
+ .values("count")
+ )
+ .annotate(
+ member_role=ProjectMember.objects.filter(
+ project_id=OuterRef("pk"),
+ member_id=self.request.user.id,
+ is_active=True,
+ ).values("role")
+ )
+ .annotate(
+ is_deployed=Exists(
+ DeployBoard.objects.filter(
+ project_id=OuterRef("pk"),
+ workspace__slug=self.kwargs.get("slug"),
+ )
+ )
+ )
+ .order_by(self.kwargs.get("order_by", "-created_at"))
+ .distinct()
+ )
+
+ @project_docs(
+ operation_id="list_projects",
+ summary="List or retrieve projects",
+ description="Retrieve all projects in a workspace or get details of a specific project.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ ProjectSerializer,
+ "PaginatedProjectResponse",
+ "Paginated list of projects",
+ "Paginated Projects",
+ ),
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug):
+ """List projects
+
+ Retrieve all projects in a workspace or get details of a specific project.
+ Returns projects ordered by user's custom sort order with member information.
+ """
+ sort_order_query = ProjectMember.objects.filter(
+ member=request.user,
+ project_id=OuterRef("pk"),
+ workspace__slug=self.kwargs.get("slug"),
+ is_active=True,
+ ).values("sort_order")
+ projects = (
+ self.get_queryset()
+ .annotate(sort_order=Subquery(sort_order_query))
+ .prefetch_related(
+ Prefetch(
+ "project_projectmember",
+ queryset=ProjectMember.objects.filter(
+ workspace__slug=slug, is_active=True
+ ).select_related("member"),
+ )
+ )
+ .order_by(request.GET.get("order_by", "sort_order"))
+ )
+ return self.paginate(
+ request=request,
+ queryset=(projects),
+ on_results=lambda projects: ProjectSerializer(
+ projects, many=True, fields=self.fields, expand=self.expand
+ ).data,
+ )
+
+ @project_docs(
+ operation_id="create_project",
+ summary="Create project",
+ description="Create a new project in the workspace with default states and member assignments.",
+ request=OpenApiRequest(
+ request=ProjectCreateSerializer,
+ examples=[PROJECT_CREATE_EXAMPLE],
+ ),
+ responses={
+ 201: OpenApiResponse(
+ description="Project created successfully",
+ response=ProjectSerializer,
+ examples=[PROJECT_EXAMPLE],
+ ),
+ 404: WORKSPACE_NOT_FOUND_RESPONSE,
+ 409: PROJECT_NAME_TAKEN_RESPONSE,
+ },
+ )
+ def post(self, request, slug):
+ """Create project
+
+ Create a new project in the workspace with default states and member assignments.
+ Automatically adds the creator as admin and sets up default workflow states.
+ """
+ try:
+ workspace = Workspace.objects.get(slug=slug)
+ serializer = ProjectCreateSerializer(
+ data={**request.data}, context={"workspace_id": workspace.id}
+ )
+ if serializer.is_valid():
+ serializer.save()
+
+ # Add the user as Administrator to the project
+ _ = ProjectMember.objects.create(
+ project_id=serializer.instance.id, member=request.user, role=20
+ )
+ # Also create the issue property for the user
+ _ = IssueUserProperty.objects.create(
+ project_id=serializer.instance.id, user=request.user
+ )
+
+ if serializer.instance.project_lead is not None and str(
+ serializer.instance.project_lead
+ ) != str(request.user.id):
+ ProjectMember.objects.create(
+ project_id=serializer.instance.id,
+ member_id=serializer.instance.project_lead,
+ role=20,
+ )
+ # Also create the issue property for the user
+ IssueUserProperty.objects.create(
+ project_id=serializer.instance.id,
+ user_id=serializer.instance.project_lead,
+ )
+
+ # Default states
+ states = [
+ {
+ "name": "Backlog",
+ "color": "#60646C",
+ "sequence": 15000,
+ "group": "backlog",
+ "default": True,
+ },
+ {
+ "name": "Todo",
+ "color": "#60646C",
+ "sequence": 25000,
+ "group": "unstarted",
+ },
+ {
+ "name": "In Progress",
+ "color": "#F59E0B",
+ "sequence": 35000,
+ "group": "started",
+ },
+ {
+ "name": "Done",
+ "color": "#46A758",
+ "sequence": 45000,
+ "group": "completed",
+ },
+ {
+ "name": "Cancelled",
+ "color": "#9AA4BC",
+ "sequence": 55000,
+ "group": "cancelled",
+ },
+ ]
+
+ State.objects.bulk_create(
+ [
+ State(
+ name=state["name"],
+ color=state["color"],
+ project=serializer.instance,
+ sequence=state["sequence"],
+ workspace=serializer.instance.workspace,
+ group=state["group"],
+ default=state.get("default", False),
+ created_by=request.user,
+ )
+ for state in states
+ ]
+ )
+
+ project = self.get_queryset().filter(pk=serializer.instance.id).first()
+
+ # Model activity
+ model_activity.delay(
+ model_name="project",
+ model_id=str(project.id),
+ requested_data=request.data,
+ current_instance=None,
+ actor_id=request.user.id,
+ slug=slug,
+ origin=base_host(request=request, is_app=True),
+ )
+
+ serializer = ProjectSerializer(project)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ except IntegrityError as e:
+ if "already exists" in str(e):
+ return Response(
+ {"name": "The project name is already taken"},
+ status=status.HTTP_409_CONFLICT,
+ )
+ except Workspace.DoesNotExist:
+ return Response(
+ {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
+ )
+ except ValidationError:
+ return Response(
+ {"identifier": "The project identifier is already taken"},
+ status=status.HTTP_409_CONFLICT,
+ )
+
+
+class ProjectDetailAPIEndpoint(BaseAPIView):
+ """Project Endpoints to update, retrieve and delete endpoint"""
serializer_class = ProjectSerializer
model = Project
@@ -104,154 +394,58 @@ class ProjectAPIEndpoint(BaseAPIView):
.distinct()
)
- def get(self, request, slug, pk=None):
- if pk is None:
- sort_order_query = ProjectMember.objects.filter(
- member=request.user,
- project_id=OuterRef("pk"),
- workspace__slug=self.kwargs.get("slug"),
- is_active=True,
- ).values("sort_order")
- projects = (
- self.get_queryset()
- .annotate(sort_order=Subquery(sort_order_query))
- .prefetch_related(
- Prefetch(
- "project_projectmember",
- queryset=ProjectMember.objects.filter(
- workspace__slug=slug, is_active=True
- ).select_related("member"),
- )
- )
- .order_by(request.GET.get("order_by", "sort_order"))
- )
- return self.paginate(
- request=request,
- queryset=(projects),
- on_results=lambda projects: ProjectSerializer(
- projects, many=True, fields=self.fields, expand=self.expand
- ).data,
- )
+ @project_docs(
+ operation_id="retrieve_project",
+ summary="Retrieve project",
+ description="Retrieve details of a specific project.",
+ parameters=[
+ PROJECT_PK_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="Project details",
+ response=ProjectSerializer,
+ examples=[PROJECT_EXAMPLE],
+ ),
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ },
+ )
+ def get(self, request, slug, pk):
+ """Retrieve project
+
+ Retrieve details of a specific project.
+ """
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
- def post(self, request, slug):
- try:
- workspace = Workspace.objects.get(slug=slug)
- serializer = ProjectSerializer(
- data={**request.data}, context={"workspace_id": workspace.id}
- )
- if serializer.is_valid():
- serializer.save()
-
- # Add the user as Administrator to the project
- _ = ProjectMember.objects.create(
- project_id=serializer.data["id"], member=request.user, role=20
- )
- # Also create the issue property for the user
- _ = IssueUserProperty.objects.create(
- project_id=serializer.data["id"], user=request.user
- )
-
- if serializer.data["project_lead"] is not None and str(
- serializer.data["project_lead"]
- ) != str(request.user.id):
- ProjectMember.objects.create(
- project_id=serializer.data["id"],
- member_id=serializer.data["project_lead"],
- role=20,
- )
- # Also create the issue property for the user
- IssueUserProperty.objects.create(
- project_id=serializer.data["id"],
- user_id=serializer.data["project_lead"],
- )
-
- # Default states
- states = [
- {
- "name": "Backlog",
- "color": "#60646C",
- "sequence": 15000,
- "group": "backlog",
- "default": True,
- },
- {
- "name": "Todo",
- "color": "#60646C",
- "sequence": 25000,
- "group": "unstarted",
- },
- {
- "name": "In Progress",
- "color": "#F59E0B",
- "sequence": 35000,
- "group": "started",
- },
- {
- "name": "Done",
- "color": "#46A758",
- "sequence": 45000,
- "group": "completed",
- },
- {
- "name": "Cancelled",
- "color": "#9AA4BC",
- "sequence": 55000,
- "group": "cancelled",
- },
- ]
-
- State.objects.bulk_create(
- [
- State(
- name=state["name"],
- color=state["color"],
- project=serializer.instance,
- sequence=state["sequence"],
- workspace=serializer.instance.workspace,
- group=state["group"],
- default=state.get("default", False),
- created_by=request.user,
- )
- for state in states
- ]
- )
-
- project = self.get_queryset().filter(pk=serializer.data["id"]).first()
-
- # Model activity
- model_activity.delay(
- model_name="project",
- model_id=str(project.id),
- requested_data=request.data,
- current_instance=None,
- actor_id=request.user.id,
- slug=slug,
- origin=base_host(request=request, is_app=True),
- )
-
- serializer = ProjectSerializer(project)
- return Response(serializer.data, status=status.HTTP_201_CREATED)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- except IntegrityError as e:
- if "already exists" in str(e):
- return Response(
- {"name": "The project name is already taken"},
- status=status.HTTP_409_CONFLICT,
- )
- except Workspace.DoesNotExist:
- return Response(
- {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND
- )
- except ValidationError:
- return Response(
- {"identifier": "The project identifier is already taken"},
- status=status.HTTP_409_CONFLICT,
- )
-
+ @project_docs(
+ operation_id="update_project",
+ summary="Update project",
+ description="Partially update an existing project's properties like name, description, or settings.",
+ parameters=[
+ PROJECT_PK_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=ProjectUpdateSerializer,
+ examples=[PROJECT_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="Project updated successfully",
+ response=ProjectSerializer,
+ examples=[PROJECT_EXAMPLE],
+ ),
+ 404: PROJECT_NOT_FOUND_RESPONSE,
+ 409: PROJECT_NAME_TAKEN_RESPONSE,
+ },
+ )
def patch(self, request, slug, pk):
+ """Update project
+
+ Partially update an existing project's properties like name, description, or settings.
+ Tracks changes in model activity logs for audit purposes.
+ """
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
@@ -267,7 +461,7 @@ class ProjectAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- serializer = ProjectSerializer(
+ serializer = ProjectUpdateSerializer(
project,
data={**request.data, "intake_view": intake_view},
context={"workspace_id": workspace.id},
@@ -287,7 +481,7 @@ class ProjectAPIEndpoint(BaseAPIView):
is_default=True,
)
- project = self.get_queryset().filter(pk=serializer.data["id"]).first()
+ project = self.get_queryset().filter(pk=serializer.instance.id).first()
model_activity.delay(
model_name="project",
@@ -318,7 +512,23 @@ class ProjectAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
+ @project_docs(
+ operation_id="delete_project",
+ summary="Delete project",
+ description="Permanently remove a project and all its associated data from the workspace.",
+ parameters=[
+ PROJECT_PK_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ },
+ )
def delete(self, request, slug, pk):
+ """Delete project
+
+ Permanently remove a project and all its associated data from the workspace.
+ Only admins can delete projects and the action cannot be undone.
+ """
project = Project.objects.get(pk=pk, workspace__slug=slug)
# Delete the user favorite cycle
UserFavorite.objects.filter(
@@ -342,16 +552,52 @@ class ProjectAPIEndpoint(BaseAPIView):
class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
+ """Project Archive and Unarchive Endpoint"""
+
permission_classes = [ProjectBasePermission]
+ @project_docs(
+ operation_id="archive_project",
+ summary="Archive project",
+ description="Move a project to archived status, hiding it from active project lists.",
+ parameters=[
+ PROJECT_ID_PARAMETER,
+ ],
+ request={},
+ responses={
+ 204: ARCHIVED_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id):
+ """Archive project
+
+ Move a project to archived status, hiding it from active project lists.
+ Archived projects remain accessible but are excluded from regular workflows.
+ """
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+ @project_docs(
+ operation_id="unarchive_project",
+ summary="Unarchive project",
+ description="Restore an archived project to active status, making it available in regular workflows.",
+ parameters=[
+ PROJECT_ID_PARAMETER,
+ ],
+ request={},
+ responses={
+ 204: UNARCHIVED_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id):
+ """Unarchive project
+
+ Restore an archived project to active status, making it available in regular workflows.
+ The project will reappear in active project lists and become fully functional.
+ """
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = None
project.save()
diff --git a/apps/api/plane/api/views/state.py b/apps/api/plane/api/views/state.py
index 0fbbd222a..327c6c890 100644
--- a/apps/api/plane/api/views/state.py
+++ b/apps/api/plane/api/views/state.py
@@ -4,16 +4,37 @@ from django.db import IntegrityError
# Third party imports
from rest_framework import status
from rest_framework.response import Response
+from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest
+# Module imports
from plane.api.serializers import StateSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, State
-
-# Module imports
from .base import BaseAPIView
+from plane.utils.openapi import (
+ state_docs,
+ STATE_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ create_paginated_response,
+ # Request Examples
+ STATE_CREATE_EXAMPLE,
+ STATE_UPDATE_EXAMPLE,
+ # Response Examples
+ STATE_EXAMPLE,
+ INVALID_REQUEST_RESPONSE,
+ STATE_NAME_EXISTS_RESPONSE,
+ DELETED_RESPONSE,
+ STATE_CANNOT_DELETE_RESPONSE,
+ EXTERNAL_ID_EXISTS_RESPONSE,
+)
-class StateAPIEndpoint(BaseAPIView):
+class StateListCreateAPIEndpoint(BaseAPIView):
+ """State List and Create Endpoint"""
+
serializer_class = StateSerializer
model = State
permission_classes = [ProjectEntityPermission]
@@ -33,7 +54,30 @@ class StateAPIEndpoint(BaseAPIView):
.distinct()
)
+ @state_docs(
+ operation_id="create_state",
+ summary="Create state",
+ description="Create a new workflow state for a project with specified name, color, and group.",
+ request=OpenApiRequest(
+ request=StateSerializer,
+ examples=[STATE_CREATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="State created",
+ response=StateSerializer,
+ examples=[STATE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 409: STATE_NAME_EXISTS_RESPONSE,
+ },
+ )
def post(self, request, slug, project_id):
+ """Create state
+
+ Create a new workflow state for a project with specified name, color, and group.
+ Supports external ID tracking for integration purposes.
+ """
try:
serializer = StateSerializer(
data=request.data, context={"project_id": project_id}
@@ -80,14 +124,31 @@ class StateAPIEndpoint(BaseAPIView):
status=status.HTTP_409_CONFLICT,
)
- def get(self, request, slug, project_id, state_id=None):
- if state_id:
- serializer = StateSerializer(
- self.get_queryset().get(pk=state_id),
- fields=self.fields,
- expand=self.expand,
- )
- return Response(serializer.data, status=status.HTTP_200_OK)
+ @state_docs(
+ operation_id="list_states",
+ summary="List states",
+ description="Retrieve all workflow states for a project.",
+ parameters=[
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+ ],
+ responses={
+ 200: create_paginated_response(
+ StateSerializer,
+ "PaginatedStateResponse",
+ "Paginated list of states",
+ "Paginated States",
+ ),
+ },
+ )
+ def get(self, request, slug, project_id):
+ """List states
+
+ Retrieve all workflow states for a project.
+ Returns paginated results when listing all states.
+ """
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -96,7 +157,75 @@ class StateAPIEndpoint(BaseAPIView):
).data,
)
+
+class StateDetailAPIEndpoint(BaseAPIView):
+ """State Detail Endpoint"""
+
+ serializer_class = StateSerializer
+ model = State
+ permission_classes = [ProjectEntityPermission]
+
+ def get_queryset(self):
+ return (
+ State.objects.filter(workspace__slug=self.kwargs.get("slug"))
+ .filter(project_id=self.kwargs.get("project_id"))
+ .filter(
+ project__project_projectmember__member=self.request.user,
+ project__project_projectmember__is_active=True,
+ )
+ .filter(is_triage=False)
+ .filter(project__archived_at__isnull=True)
+ .select_related("project")
+ .select_related("workspace")
+ .distinct()
+ )
+
+ @state_docs(
+ operation_id="retrieve_state",
+ summary="Retrieve state",
+ description="Retrieve details of a specific state.",
+ parameters=[
+ STATE_ID_PARAMETER,
+ ],
+ responses={
+ 200: OpenApiResponse(
+ description="State retrieved",
+ response=StateSerializer,
+ examples=[STATE_EXAMPLE],
+ ),
+ },
+ )
+ def get(self, request, slug, project_id, state_id):
+ """Retrieve state
+
+ Retrieve details of a specific state.
+ Returns paginated results when listing all states.
+ """
+ serializer = StateSerializer(
+ self.get_queryset().get(pk=state_id),
+ fields=self.fields,
+ expand=self.expand,
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+ @state_docs(
+ operation_id="delete_state",
+ summary="Delete state",
+ description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.",
+ parameters=[
+ STATE_ID_PARAMETER,
+ ],
+ responses={
+ 204: DELETED_RESPONSE,
+ 400: STATE_CANNOT_DELETE_RESPONSE,
+ },
+ )
def delete(self, request, slug, project_id, state_id):
+ """Delete state
+
+ Permanently remove a workflow state from a project.
+ Default states and states with existing work items cannot be deleted.
+ """
state = State.objects.get(
is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug
)
@@ -119,7 +248,33 @@ class StateAPIEndpoint(BaseAPIView):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
- def patch(self, request, slug, project_id, state_id=None):
+ @state_docs(
+ operation_id="update_state",
+ summary="Update state",
+ description="Partially update an existing workflow state's properties like name, color, or group.",
+ parameters=[
+ STATE_ID_PARAMETER,
+ ],
+ request=OpenApiRequest(
+ request=StateSerializer,
+ examples=[STATE_UPDATE_EXAMPLE],
+ ),
+ responses={
+ 200: OpenApiResponse(
+ description="State updated",
+ response=StateSerializer,
+ examples=[STATE_EXAMPLE],
+ ),
+ 400: INVALID_REQUEST_RESPONSE,
+ 409: EXTERNAL_ID_EXISTS_RESPONSE,
+ },
+ )
+ def patch(self, request, slug, project_id, state_id):
+ """Update state
+
+ Partially update an existing workflow state's properties like name, color, or group.
+ Validates external ID uniqueness if provided.
+ """
state = State.objects.get(
workspace__slug=slug, project_id=project_id, pk=state_id
)
diff --git a/apps/api/plane/api/views/user.py b/apps/api/plane/api/views/user.py
new file mode 100644
index 000000000..b874cec18
--- /dev/null
+++ b/apps/api/plane/api/views/user.py
@@ -0,0 +1,37 @@
+# Third party imports
+from rest_framework import status
+from rest_framework.response import Response
+from drf_spectacular.utils import OpenApiResponse
+
+# Module imports
+from plane.api.serializers import UserLiteSerializer
+from plane.api.views.base import BaseAPIView
+from plane.db.models import User
+from plane.utils.openapi.decorators import user_docs
+from plane.utils.openapi import USER_EXAMPLE
+
+
+class UserEndpoint(BaseAPIView):
+ serializer_class = UserLiteSerializer
+ model = User
+
+ @user_docs(
+ operation_id="get_current_user",
+ summary="Get current user",
+ description="Retrieve the authenticated user's profile information including basic details.",
+ responses={
+ 200: OpenApiResponse(
+ description="Current user profile",
+ response=UserLiteSerializer,
+ examples=[USER_EXAMPLE],
+ ),
+ },
+ )
+ def get(self, request):
+ """Get current user
+
+ Retrieve the authenticated user's profile information including basic details.
+ Returns user data based on the current authentication context.
+ """
+ serializer = UserLiteSerializer(request.user)
+ return Response(serializer.data, status=status.HTTP_200_OK)
diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py
index 470960fcc..1596d90b3 100644
--- a/apps/api/plane/app/permissions/project.py
+++ b/apps/api/plane/app/permissions/project.py
@@ -75,12 +75,12 @@ class ProjectEntityPermission(BasePermission):
return False
# Handle requests based on project__identifier
- if hasattr(view, "project__identifier") and view.project__identifier:
+ if hasattr(view, "project_identifier") and view.project_identifier:
if request.method in SAFE_METHODS:
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
- project__identifier=view.project__identifier,
+ project__identifier=view.project_identifier,
is_active=True,
).exists()
diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py
index c690fbe7d..8a47cdd02 100644
--- a/apps/api/plane/app/views/analytic/advance.py
+++ b/apps/api/plane/app/views/analytic/advance.py
@@ -16,8 +16,6 @@ from plane.db.models import (
IssueView,
ProjectPage,
Workspace,
- CycleIssue,
- ModuleIssue,
ProjectMember,
)
from plane.utils.build_chart import build_analytics_chart
diff --git a/apps/api/plane/app/views/workspace/cycle.py b/apps/api/plane/app/views/workspace/cycle.py
index eb899553d..73deca059 100644
--- a/apps/api/plane/app/views/workspace/cycle.py
+++ b/apps/api/plane/app/views/workspace/cycle.py
@@ -10,7 +10,6 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
-from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py
index d8116cec3..ecf7ed183 100644
--- a/apps/api/plane/authentication/provider/oauth/github.py
+++ b/apps/api/plane/authentication/provider/oauth/github.py
@@ -18,7 +18,7 @@ from plane.authentication.adapter.error import (
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
- org_membership_url = f"https://api.github.com/orgs"
+ org_membership_url = "https://api.github.com/orgs"
provider = "github"
scope = "read:user user:email"
diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py
index 4def8e8ca..f768feac3 100644
--- a/apps/api/plane/bgtasks/issue_activities_task.py
+++ b/apps/api/plane/bgtasks/issue_activities_task.py
@@ -30,7 +30,6 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
-from plane.bgtasks.webhook_task import webhook_activity
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
diff --git a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py
index 48600e662..f4a9285ee 100644
--- a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py
+++ b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py
@@ -1,4 +1,3 @@
-import time
from django.core.management.base import BaseCommand
from django.db import transaction
from plane.db.models import Workspace
diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py
index 6449fd145..26b152c6c 100644
--- a/apps/api/plane/db/models/cycle.py
+++ b/apps/api/plane/db/models/cycle.py
@@ -71,7 +71,7 @@ class Cycle(ProjectBaseModel):
archived_at = models.DateTimeField(null=True)
logo_props = models.JSONField(default=dict)
# timezone
- TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
+ TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
version = models.IntegerField(default=1)
diff --git a/apps/api/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py
index 2f698ae1b..c6c366c9e 100644
--- a/apps/api/plane/db/models/intake.py
+++ b/apps/api/plane/db/models/intake.py
@@ -35,6 +35,14 @@ class SourceType(models.TextChoices):
IN_APP = "IN_APP"
+class IntakeIssueStatus(models.IntegerChoices):
+ PENDING = -2
+ REJECTED = -1
+ SNOOZED = 0
+ ACCEPTED = 1
+ DUPLICATE = 2
+
+
class IntakeIssue(ProjectBaseModel):
intake = models.ForeignKey(
"db.Intake", related_name="issue_intake", on_delete=models.CASCADE
diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py
index 6fba4d03c..6015461d5 100644
--- a/apps/api/plane/db/models/module.py
+++ b/apps/api/plane/db/models/module.py
@@ -51,6 +51,15 @@ def get_default_display_properties():
}
+class ModuleStatus(models.TextChoices):
+ BACKLOG = "backlog"
+ PLANNED = "planned"
+ IN_PROGRESS = "in-progress"
+ PAUSED = "paused"
+ COMPLETED = "completed"
+ CANCELLED = "cancelled"
+
+
class Module(ProjectBaseModel):
name = models.CharField(max_length=255, verbose_name="Module Name")
description = models.TextField(verbose_name="Module Description", blank=True)
diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py
index 79a0707d3..e58f60e80 100644
--- a/apps/api/plane/db/models/project.py
+++ b/apps/api/plane/db/models/project.py
@@ -120,7 +120,7 @@ class Project(BaseModel):
)
archived_at = models.DateTimeField(null=True)
# timezone
- TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
+ TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
# external_id for imports
external_source = models.CharField(max_length=255, null=True, blank=True)
diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py
index b2613a427..bad81b4ce 100644
--- a/apps/api/plane/db/models/user.py
+++ b/apps/api/plane/db/models/user.py
@@ -101,7 +101,7 @@ class User(AbstractBaseUser, PermissionsMixin):
)
# timezone
- USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
+ USER_TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py
index 7e5103a70..3f9f612dc 100644
--- a/apps/api/plane/db/models/workspace.py
+++ b/apps/api/plane/db/models/workspace.py
@@ -1,9 +1,6 @@
# Python imports
-from django.db.models.functions import Ln
import pytz
-import time
-from django.utils import timezone
-from typing import Optional, Any, Tuple, Dict
+from typing import Optional, Any
# Django imports
from django.conf import settings
@@ -115,7 +112,7 @@ def slug_validator(value):
class Workspace(BaseModel):
- TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
+ TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.TextField(verbose_name="Logo", blank=True, null=True)
diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py
index 38d2ac6e0..8d59f8192 100644
--- a/apps/api/plane/settings/common.py
+++ b/apps/api/plane/settings/common.py
@@ -75,6 +75,8 @@ REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
"EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler",
+ # Preserve original Django URL parameter names (pk) instead of converting to 'id'
+ "SCHEMA_COERCE_PATH_PK": False,
}
# Django Auth Backend
@@ -439,3 +441,10 @@ ATTACHMENT_MIME_TYPES = [
# Seed directory path
SEED_DIR = os.path.join(BASE_DIR, "seeds")
+
+ENABLE_DRF_SPECTACULAR = os.environ.get("ENABLE_DRF_SPECTACULAR", "0") == "1"
+
+if ENABLE_DRF_SPECTACULAR:
+ REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
+ INSTALLED_APPS.append("drf_spectacular")
+ from .openapi import SPECTACULAR_SETTINGS # noqa: F401
diff --git a/apps/api/plane/settings/openapi.py b/apps/api/plane/settings/openapi.py
new file mode 100644
index 000000000..b79daeecf
--- /dev/null
+++ b/apps/api/plane/settings/openapi.py
@@ -0,0 +1,272 @@
+"""
+OpenAPI/Swagger configuration for drf-spectacular.
+
+This file contains the complete configuration for API documentation generation.
+"""
+
+SPECTACULAR_SETTINGS = {
+ # ========================================================================
+ # Basic API Information
+ # ========================================================================
+ "TITLE": "The Plane REST API",
+ "DESCRIPTION": (
+ "The Plane REST API\n\n"
+ "Visit our quick start guide and full API documentation at "
+ "[developers.plane.so](https://developers.plane.so/api-reference/introduction)."
+ ),
+ "CONTACT": {
+ "name": "Plane",
+ "url": "https://plane.so",
+ "email": "support@plane.so",
+ },
+ "VERSION": "0.0.1",
+ "LICENSE": {
+ "name": "GNU AGPLv3",
+ "url": "https://github.com/makeplane/plane/blob/preview/LICENSE.txt",
+ },
+ # ========================================================================
+ # Schema Generation Settings
+ # ========================================================================
+ "SERVE_INCLUDE_SCHEMA": False,
+ "SCHEMA_PATH_PREFIX": "/api/v1/",
+ "SCHEMA_CACHE_TIMEOUT": 0, # disables caching
+ # ========================================================================
+ # Processing Hooks
+ # ========================================================================
+ "PREPROCESSING_HOOKS": [
+ "plane.utils.openapi.hooks.preprocess_filter_api_v1_paths",
+ ],
+ # ========================================================================
+ # Server Configuration
+ # ========================================================================
+ "SERVERS": [
+ {"url": "http://localhost:8000", "description": "Local"},
+ {"url": "https://api.plane.so", "description": "Production"},
+ ],
+ # ========================================================================
+ # API Tag Definitions
+ # ========================================================================
+ "TAGS": [
+ # System Features
+ {
+ "name": "Assets",
+ "description": (
+ "**File Upload & Presigned URLs**\n\n"
+ "Generate presigned URLs for direct file uploads to cloud storage. Handle user avatars, "
+ "cover images, and generic project assets with secure upload workflows.\n\n"
+ "*Key Features:*\n"
+ "- Generate presigned URLs for S3 uploads\n"
+ "- Support for user avatars and cover images\n"
+ "- Generic asset upload for projects\n"
+ "- File validation and size limits\n\n"
+ "*Use Cases:* User profile images, project file uploads, secure direct-to-cloud uploads."
+ ),
+ },
+ # Project Organization
+ {
+ "name": "Cycles",
+ "description": (
+ "**Sprint & Development Cycles**\n\n"
+ "Create and manage development cycles (sprints) to organize work into time-boxed iterations. "
+ "Track progress, assign work items, and monitor team velocity.\n\n"
+ "*Key Features:*\n"
+ "- Create and configure development cycles\n"
+ "- Assign work items to cycles\n"
+ "- Track cycle progress and completion\n"
+ "- Generate cycle analytics and reports\n\n"
+ "*Use Cases:* Sprint planning, iterative development, progress tracking, team velocity."
+ ),
+ },
+ # System Features
+ {
+ "name": "Intake",
+ "description": (
+ "**Work Item Intake Queue**\n\n"
+ "Manage incoming work items through a dedicated intake queue for triage and review. "
+ "Submit, update, and process work items before they enter the main project workflow.\n\n"
+ "*Key Features:*\n"
+ "- Submit work items to intake queue\n"
+ "- Review and triage incoming work items\n"
+ "- Update intake work item status and properties\n"
+ "- Accept, reject, or modify work items before approval\n\n"
+ "*Use Cases:* Work item triage, external submissions, quality review, approval workflows."
+ ),
+ },
+ # Project Organization
+ {
+ "name": "Labels",
+ "description": (
+ "**Labels & Tags**\n\n"
+ "Create and manage labels to categorize and organize work items. Use color-coded labels "
+ "for easy identification, filtering, and project organization.\n\n"
+ "*Key Features:*\n"
+ "- Create custom labels with colors and descriptions\n"
+ "- Apply labels to work items for categorization\n"
+ "- Filter and search by labels\n"
+ "- Organize labels across projects\n\n"
+ "*Use Cases:* Priority marking, feature categorization, bug classification, team organization."
+ ),
+ },
+ # Team & User Management
+ {
+ "name": "Members",
+ "description": (
+ "**Team Member Management**\n\n"
+ "Manage team members, roles, and permissions within projects and workspaces. "
+ "Control access levels and track member participation.\n\n"
+ "*Key Features:*\n"
+ "- Invite and manage team members\n"
+ "- Assign roles and permissions\n"
+ "- Control project and workspace access\n"
+ "- Track member activity and participation\n\n"
+ "*Use Cases:* Team setup, access control, role management, collaboration."
+ ),
+ },
+ # Project Organization
+ {
+ "name": "Modules",
+ "description": (
+ "**Feature Modules**\n\n"
+ "Group related work items into modules for better organization and tracking. "
+ "Plan features, track progress, and manage deliverables at a higher level.\n\n"
+ "*Key Features:*\n"
+ "- Create and organize feature modules\n"
+ "- Group work items by module\n"
+ "- Track module progress and completion\n"
+ "- Manage module leads and assignments\n\n"
+ "*Use Cases:* Feature planning, release organization, progress tracking, team coordination."
+ ),
+ },
+ # Core Project Management
+ {
+ "name": "Projects",
+ "description": (
+ "**Project Management**\n\n"
+ "Create and manage projects to organize your development work. Configure project settings, "
+ "manage team access, and control project visibility.\n\n"
+ "*Key Features:*\n"
+ "- Create, update, and delete projects\n"
+ "- Configure project settings and preferences\n"
+ "- Manage team access and permissions\n"
+ "- Control project visibility and sharing\n\n"
+ "*Use Cases:* Project setup, team collaboration, access control, project configuration."
+ ),
+ },
+ # Project Organization
+ {
+ "name": "States",
+ "description": (
+ "**Workflow States**\n\n"
+ "Define custom workflow states for work items to match your team's process. "
+ "Configure state transitions and track work item progress through different stages.\n\n"
+ "*Key Features:*\n"
+ "- Create custom workflow states\n"
+ "- Configure state transitions and rules\n"
+ "- Track work item progress through states\n"
+ "- Set state-based permissions and automation\n\n"
+ "*Use Cases:* Custom workflows, status tracking, process automation, progress monitoring."
+ ),
+ },
+ # Team & User Management
+ {
+ "name": "Users",
+ "description": (
+ "**Current User Information**\n\n"
+ "Get information about the currently authenticated user including profile details "
+ "and account settings.\n\n"
+ "*Key Features:*\n"
+ "- Retrieve current user profile\n"
+ "- Access user account information\n"
+ "- View user preferences and settings\n"
+ "- Get authentication context\n\n"
+ "*Use Cases:* Profile display, user context, account information, authentication status."
+ ),
+ },
+ # Work Item Management
+ {
+ "name": "Work Item Activity",
+ "description": (
+ "**Activity History & Search**\n\n"
+ "View activity history and search for work items across the workspace. "
+ "Get detailed activity logs and find work items using text search.\n\n"
+ "*Key Features:*\n"
+ "- View work item activity history\n"
+ "- Search work items across workspace\n"
+ "- Track changes and modifications\n"
+ "- Filter search results by project\n\n"
+ "*Use Cases:* Activity tracking, work item discovery, change history, workspace search."
+ ),
+ },
+ {
+ "name": "Work Item Attachments",
+ "description": (
+ "**Work Item File Attachments**\n\n"
+ "Generate presigned URLs for uploading files directly to specific work items. "
+ "Upload and manage attachments associated with work items.\n\n"
+ "*Key Features:*\n"
+ "- Generate presigned URLs for work item attachments\n"
+ "- Upload files directly to work items\n"
+ "- Retrieve and manage attachment metadata\n"
+ "- Delete attachments from work items\n\n"
+ "*Use Cases:* Screenshots, error logs, design files, supporting documents."
+ ),
+ },
+ {
+ "name": "Work Item Comments",
+ "description": (
+ "**Comments & Discussions**\n\n"
+ "Add comments and discussions to work items for team collaboration. "
+ "Support threaded conversations, mentions, and rich text formatting.\n\n"
+ "*Key Features:*\n"
+ "- Add comments to work items\n"
+ "- Thread conversations and replies\n"
+ "- Mention users and trigger notifications\n"
+ "- Rich text and markdown support\n\n"
+ "*Use Cases:* Team discussions, progress updates, code reviews, decision tracking."
+ ),
+ },
+ {
+ "name": "Work Item Links",
+ "description": (
+ "**External Links & References**\n\n"
+ "Link work items to external resources like documentation, repositories, or design files. "
+ "Maintain connections between work items and external systems.\n\n"
+ "*Key Features:*\n"
+ "- Add external URL links to work items\n"
+ "- Validate and preview linked resources\n"
+ "- Organize links by type and category\n"
+ "- Track link usage and access\n\n"
+ "*Use Cases:* Documentation links, repository connections, design references, external tools."
+ ),
+ },
+ {
+ "name": "Work Items",
+ "description": (
+ "**Work Items & Tasks**\n\n"
+ "Create and manage work items like tasks, bugs, features, and user stories. "
+ "The core entities for tracking work in your projects.\n\n"
+ "*Key Features:*\n"
+ "- Create, update, and manage work items\n"
+ "- Assign to team members and set priorities\n"
+ "- Track progress through workflow states\n"
+ "- Set due dates, estimates, and relationships\n\n"
+ "*Use Cases:* Bug tracking, task management, feature development, sprint planning."
+ ),
+ },
+ ],
+ # ========================================================================
+ # Security & Authentication
+ # ========================================================================
+ "AUTHENTICATION_WHITELIST": [
+ "plane.api.middleware.api_authentication.APIKeyAuthentication",
+ ],
+ # ========================================================================
+ # Schema Generation Options
+ # ========================================================================
+ "COMPONENT_NO_READ_ONLY_REQUIRED": True,
+ "COMPONENT_SPLIT_REQUEST": True,
+ "ENUM_NAME_OVERRIDES": {
+ "ModuleStatusEnum": "plane.db.models.module.ModuleStatus",
+ "IntakeWorkItemStatusEnum": "plane.db.models.intake.IntakeIssueStatus",
+ },
+}
diff --git a/apps/api/plane/space/serializer/__init__.py b/apps/api/plane/space/serializer/__init__.py
index ad4e9897d..a3fe1029f 100644
--- a/apps/api/plane/space/serializer/__init__.py
+++ b/apps/api/plane/space/serializer/__init__.py
@@ -1,5 +1,5 @@
from .user import UserLiteSerializer
-from .issue import LabelLiteSerializer, StateLiteSerializer, IssuePublicSerializer
+from .issue import LabelLiteSerializer, IssuePublicSerializer
-from .state import StateSerializer, StateLiteSerializer
+from .state import StateSerializer
diff --git a/apps/api/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py
index b70c9352a..15f3a8a28 100644
--- a/apps/api/plane/tests/conftest.py
+++ b/apps/api/plane/tests/conftest.py
@@ -1,15 +1,13 @@
import pytest
-from django.conf import settings
from rest_framework.test import APIClient
from pytest_django.fixtures import django_db_setup
-from unittest.mock import patch, MagicMock
from plane.db.models import User, Workspace, WorkspaceMember
from plane.db.models.api import APIToken
@pytest.fixture(scope="session")
-def django_db_setup(django_db_setup):
+def django_db_setup(django_db_setup): # noqa: F811
"""Set up the Django database for the test session"""
pass
diff --git a/apps/api/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py
index 50022b490..b4853e531 100644
--- a/apps/api/plane/tests/conftest_external.py
+++ b/apps/api/plane/tests/conftest_external.py
@@ -1,6 +1,5 @@
import pytest
from unittest.mock import MagicMock, patch
-from django.conf import settings
@pytest.fixture
diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py
index a52882b9d..b44f5f3fc 100644
--- a/apps/api/plane/tests/contract/app/test_authentication.py
+++ b/apps/api/plane/tests/contract/app/test_authentication.py
@@ -6,7 +6,7 @@ from django.utils import timezone
from rest_framework import status
from django.test import Client
from django.core.exceptions import ValidationError
-from unittest.mock import patch, MagicMock
+from unittest.mock import patch
from plane.db.models import User
from plane.settings.redis import redis_instance
diff --git a/apps/api/plane/tests/unit/models/test_workspace_model.py b/apps/api/plane/tests/unit/models/test_workspace_model.py
index aa3c15645..26a797512 100644
--- a/apps/api/plane/tests/unit/models/test_workspace_model.py
+++ b/apps/api/plane/tests/unit/models/test_workspace_model.py
@@ -1,7 +1,7 @@
import pytest
from uuid import uuid4
-from plane.db.models import Workspace, WorkspaceMember, User
+from plane.db.models import Workspace, WorkspaceMember
@pytest.mark.unit
diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py
index b692306a7..c06e67158 100644
--- a/apps/api/plane/urls.py
+++ b/apps/api/plane/urls.py
@@ -2,6 +2,11 @@
from django.conf import settings
from django.urls import include, path, re_path
+from drf_spectacular.views import (
+ SpectacularAPIView,
+ SpectacularRedocView,
+ SpectacularSwaggerView,
+)
handler404 = "plane.app.views.error_404.custom_404_view"
@@ -14,6 +19,20 @@ urlpatterns = [
path("", include("plane.web.urls")),
]
+if settings.ENABLE_DRF_SPECTACULAR:
+ urlpatterns += [
+ path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
+ path(
+ "api/schema/swagger-ui/",
+ SpectacularSwaggerView.as_view(url_name="schema"),
+ name="swagger-ui",
+ ),
+ path(
+ "api/schema/redoc/",
+ SpectacularRedocView.as_view(url_name="schema"),
+ name="redoc",
+ ),
+ ]
if settings.DEBUG:
try:
diff --git a/apps/api/plane/utils/openapi/README.md b/apps/api/plane/utils/openapi/README.md
new file mode 100644
index 000000000..9ac82cdd3
--- /dev/null
+++ b/apps/api/plane/utils/openapi/README.md
@@ -0,0 +1,102 @@
+# OpenAPI Utilities Module
+
+This module provides a well-organized structure for OpenAPI/drf-spectacular utilities, replacing the monolithic `openapi_spec_helpers.py` file with a more maintainable modular approach.
+
+## Structure
+
+```
+plane/utils/openapi/
+├── __init__.py # Main module that re-exports everything
+├── auth.py # Authentication extensions
+├── parameters.py # Common OpenAPI parameters
+├── responses.py # Common OpenAPI responses
+├── examples.py # Common OpenAPI examples
+├── decorators.py # Helper decorators for different endpoint types
+└── hooks.py # Schema processing hooks (pre/post processing)
+```
+
+## Usage
+
+### Import Everything (Recommended for backwards compatibility)
+```python
+from plane.utils.openapi import (
+ asset_docs,
+ ASSET_ID_PARAMETER,
+ UNAUTHORIZED_RESPONSE,
+ # ... other imports
+)
+```
+
+### Import from Specific Modules (Recommended for new code)
+```python
+from plane.utils.openapi.decorators import asset_docs
+from plane.utils.openapi.parameters import ASSET_ID_PARAMETER
+from plane.utils.openapi.responses import UNAUTHORIZED_RESPONSE
+```
+
+## Module Contents
+
+### auth.py
+- `APIKeyAuthenticationExtension` - X-API-Key authentication
+- `APITokenAuthenticationExtension` - Bearer token authentication
+
+### parameters.py
+- Path parameters: `WORKSPACE_SLUG_PARAMETER`, `PROJECT_ID_PARAMETER`, `ISSUE_ID_PARAMETER`, `ASSET_ID_PARAMETER`
+- Query parameters: `CURSOR_PARAMETER`, `PER_PAGE_PARAMETER`
+
+### responses.py
+- Auth responses: `UNAUTHORIZED_RESPONSE`, `FORBIDDEN_RESPONSE`
+- Resource responses: `NOT_FOUND_RESPONSE`, `VALIDATION_ERROR_RESPONSE`
+- Asset responses: `PRESIGNED_URL_SUCCESS_RESPONSE`, `ASSET_UPDATED_RESPONSE`, etc.
+- Generic asset responses: `GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE`, `ASSET_DOWNLOAD_SUCCESS_RESPONSE`, etc.
+
+### examples.py
+- `FILE_UPLOAD_EXAMPLE`, `WORKSPACE_EXAMPLE`, `PROJECT_EXAMPLE`, `ISSUE_EXAMPLE`
+
+### decorators.py
+- `workspace_docs()` - For workspace endpoints
+- `project_docs()` - For project endpoints
+- `issue_docs()` - For issue/work item endpoints
+- `asset_docs()` - For asset endpoints
+
+### hooks.py
+- `preprocess_filter_api_v1_paths()` - Filters API v1 paths
+- `postprocess_assign_tags()` - Assigns tags based on URL patterns
+- `generate_operation_summary()` - Generates operation summaries
+
+## Migration Status
+
+✅ **FULLY COMPLETE** - All components from the legacy `openapi_spec_helpers.py` have been successfully migrated to this modular structure and the old file has been completely removed. All imports have been updated to use the new modular structure.
+
+### What was migrated:
+- ✅ All authentication extensions
+- ✅ All common parameters and responses
+- ✅ All helper decorators
+- ✅ All schema processing hooks
+- ✅ All examples and reusable components
+- ✅ All asset view decorators converted to use new helpers
+- ✅ All view imports updated to new module paths
+- ✅ Legacy file completely removed
+
+### Files updated:
+- `plane/api/views/asset.py` - All methods use new `@asset_docs` helpers
+- `plane/api/views/project.py` - Import updated
+- `plane/api/views/user.py` - Import updated
+- `plane/api/views/state.py` - Import updated
+- `plane/api/views/intake.py` - Import updated
+- `plane/api/views/member.py` - Import updated
+- `plane/api/views/module.py` - Import updated
+- `plane/api/views/cycle.py` - Import updated
+- `plane/api/views/issue.py` - Import updated
+- `plane/settings/common.py` - Hook paths updated
+- `plane/api/apps.py` - Auth extension import updated
+
+## Benefits
+
+1. **Better Organization**: Related functionality is grouped together
+2. **Easier Maintenance**: Changes to specific areas only affect relevant files
+3. **Improved Discoverability**: Clear module names make it easy to find what you need
+4. **Backwards Compatibility**: All existing imports continue to work
+5. **Reduced Coupling**: Import only what you need from specific modules
+6. **Consistent Documentation**: All endpoints now use standardized helpers
+7. **Massive Code Reduction**: ~80% reduction in decorator bloat using reusable components
\ No newline at end of file
diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py
new file mode 100644
index 000000000..bf6821258
--- /dev/null
+++ b/apps/api/plane/utils/openapi/__init__.py
@@ -0,0 +1,315 @@
+"""
+OpenAPI utilities for drf-spectacular integration.
+
+This module provides reusable components for API documentation:
+- Authentication extensions
+- Common parameters and responses
+- Helper decorators
+- Schema preprocessing hooks
+- Examples
+"""
+
+# Authentication extensions
+from .auth import APIKeyAuthenticationExtension
+
+# Parameters
+from .parameters import (
+ WORKSPACE_SLUG_PARAMETER,
+ PROJECT_ID_PARAMETER,
+ PROJECT_PK_PARAMETER,
+ PROJECT_IDENTIFIER_PARAMETER,
+ ISSUE_IDENTIFIER_PARAMETER,
+ ASSET_ID_PARAMETER,
+ CYCLE_ID_PARAMETER,
+ MODULE_ID_PARAMETER,
+ MODULE_PK_PARAMETER,
+ ISSUE_ID_PARAMETER,
+ STATE_ID_PARAMETER,
+ LABEL_ID_PARAMETER,
+ COMMENT_ID_PARAMETER,
+ LINK_ID_PARAMETER,
+ ATTACHMENT_ID_PARAMETER,
+ ACTIVITY_ID_PARAMETER,
+ CURSOR_PARAMETER,
+ PER_PAGE_PARAMETER,
+ EXTERNAL_ID_PARAMETER,
+ EXTERNAL_SOURCE_PARAMETER,
+ ORDER_BY_PARAMETER,
+ SEARCH_PARAMETER,
+ SEARCH_PARAMETER_REQUIRED,
+ LIMIT_PARAMETER,
+ WORKSPACE_SEARCH_PARAMETER,
+ PROJECT_ID_QUERY_PARAMETER,
+ CYCLE_VIEW_PARAMETER,
+ FIELDS_PARAMETER,
+ EXPAND_PARAMETER,
+)
+
+# Responses
+from .responses import (
+ UNAUTHORIZED_RESPONSE,
+ FORBIDDEN_RESPONSE,
+ NOT_FOUND_RESPONSE,
+ VALIDATION_ERROR_RESPONSE,
+ DELETED_RESPONSE,
+ ARCHIVED_RESPONSE,
+ UNARCHIVED_RESPONSE,
+ INVALID_REQUEST_RESPONSE,
+ CONFLICT_RESPONSE,
+ ADMIN_ONLY_RESPONSE,
+ CANNOT_DELETE_RESPONSE,
+ CANNOT_ARCHIVE_RESPONSE,
+ REQUIRED_FIELDS_RESPONSE,
+ PROJECT_NOT_FOUND_RESPONSE,
+ WORKSPACE_NOT_FOUND_RESPONSE,
+ PROJECT_NAME_TAKEN_RESPONSE,
+ ISSUE_NOT_FOUND_RESPONSE,
+ WORK_ITEM_NOT_FOUND_RESPONSE,
+ EXTERNAL_ID_EXISTS_RESPONSE,
+ LABEL_NOT_FOUND_RESPONSE,
+ LABEL_NAME_EXISTS_RESPONSE,
+ MODULE_NOT_FOUND_RESPONSE,
+ MODULE_ISSUE_NOT_FOUND_RESPONSE,
+ CYCLE_CANNOT_ARCHIVE_RESPONSE,
+ STATE_NAME_EXISTS_RESPONSE,
+ STATE_CANNOT_DELETE_RESPONSE,
+ COMMENT_NOT_FOUND_RESPONSE,
+ LINK_NOT_FOUND_RESPONSE,
+ ATTACHMENT_NOT_FOUND_RESPONSE,
+ BAD_SEARCH_REQUEST_RESPONSE,
+ PRESIGNED_URL_SUCCESS_RESPONSE,
+ GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE,
+ GENERIC_ASSET_VALIDATION_ERROR_RESPONSE,
+ ASSET_CONFLICT_RESPONSE,
+ ASSET_DOWNLOAD_SUCCESS_RESPONSE,
+ ASSET_DOWNLOAD_ERROR_RESPONSE,
+ ASSET_UPDATED_RESPONSE,
+ ASSET_DELETED_RESPONSE,
+ ASSET_NOT_FOUND_RESPONSE,
+ create_paginated_response,
+)
+
+# Examples
+from .examples import (
+ FILE_UPLOAD_EXAMPLE,
+ WORKSPACE_EXAMPLE,
+ PROJECT_EXAMPLE,
+ ISSUE_EXAMPLE,
+ USER_EXAMPLE,
+ get_sample_for_schema,
+ # Request Examples
+ ISSUE_CREATE_EXAMPLE,
+ ISSUE_UPDATE_EXAMPLE,
+ ISSUE_UPSERT_EXAMPLE,
+ LABEL_CREATE_EXAMPLE,
+ LABEL_UPDATE_EXAMPLE,
+ ISSUE_LINK_CREATE_EXAMPLE,
+ ISSUE_LINK_UPDATE_EXAMPLE,
+ ISSUE_COMMENT_CREATE_EXAMPLE,
+ ISSUE_COMMENT_UPDATE_EXAMPLE,
+ ISSUE_ATTACHMENT_UPLOAD_EXAMPLE,
+ ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE,
+ CYCLE_CREATE_EXAMPLE,
+ CYCLE_UPDATE_EXAMPLE,
+ CYCLE_ISSUE_REQUEST_EXAMPLE,
+ TRANSFER_CYCLE_ISSUE_EXAMPLE,
+ MODULE_CREATE_EXAMPLE,
+ MODULE_UPDATE_EXAMPLE,
+ MODULE_ISSUE_REQUEST_EXAMPLE,
+ PROJECT_CREATE_EXAMPLE,
+ PROJECT_UPDATE_EXAMPLE,
+ STATE_CREATE_EXAMPLE,
+ STATE_UPDATE_EXAMPLE,
+ INTAKE_ISSUE_CREATE_EXAMPLE,
+ INTAKE_ISSUE_UPDATE_EXAMPLE,
+ # Response Examples
+ CYCLE_EXAMPLE,
+ TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE,
+ TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE,
+ TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE,
+ MODULE_EXAMPLE,
+ STATE_EXAMPLE,
+ LABEL_EXAMPLE,
+ ISSUE_LINK_EXAMPLE,
+ ISSUE_COMMENT_EXAMPLE,
+ ISSUE_ATTACHMENT_EXAMPLE,
+ ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE,
+ INTAKE_ISSUE_EXAMPLE,
+ MODULE_ISSUE_EXAMPLE,
+ ISSUE_SEARCH_EXAMPLE,
+ WORKSPACE_MEMBER_EXAMPLE,
+ PROJECT_MEMBER_EXAMPLE,
+ CYCLE_ISSUE_EXAMPLE,
+)
+
+# Helper decorators
+from .decorators import (
+ workspace_docs,
+ project_docs,
+ issue_docs,
+ intake_docs,
+ asset_docs,
+ user_docs,
+ cycle_docs,
+ work_item_docs,
+ label_docs,
+ issue_link_docs,
+ issue_comment_docs,
+ issue_activity_docs,
+ issue_attachment_docs,
+ module_docs,
+ module_issue_docs,
+ state_docs,
+)
+
+# Schema processing hooks
+from .hooks import (
+ preprocess_filter_api_v1_paths,
+ generate_operation_summary,
+)
+
+__all__ = [
+ # Authentication
+ "APIKeyAuthenticationExtension",
+ # Parameters
+ "WORKSPACE_SLUG_PARAMETER",
+ "PROJECT_ID_PARAMETER",
+ "PROJECT_PK_PARAMETER",
+ "PROJECT_IDENTIFIER_PARAMETER",
+ "ISSUE_IDENTIFIER_PARAMETER",
+ "ASSET_ID_PARAMETER",
+ "CYCLE_ID_PARAMETER",
+ "MODULE_ID_PARAMETER",
+ "MODULE_PK_PARAMETER",
+ "ISSUE_ID_PARAMETER",
+ "STATE_ID_PARAMETER",
+ "LABEL_ID_PARAMETER",
+ "COMMENT_ID_PARAMETER",
+ "LINK_ID_PARAMETER",
+ "ATTACHMENT_ID_PARAMETER",
+ "ACTIVITY_ID_PARAMETER",
+ "CURSOR_PARAMETER",
+ "PER_PAGE_PARAMETER",
+ "EXTERNAL_ID_PARAMETER",
+ "EXTERNAL_SOURCE_PARAMETER",
+ "ORDER_BY_PARAMETER",
+ "SEARCH_PARAMETER",
+ "SEARCH_PARAMETER_REQUIRED",
+ "LIMIT_PARAMETER",
+ "WORKSPACE_SEARCH_PARAMETER",
+ "PROJECT_ID_QUERY_PARAMETER",
+ "CYCLE_VIEW_PARAMETER",
+ "FIELDS_PARAMETER",
+ "EXPAND_PARAMETER",
+ # Responses
+ "UNAUTHORIZED_RESPONSE",
+ "FORBIDDEN_RESPONSE",
+ "NOT_FOUND_RESPONSE",
+ "VALIDATION_ERROR_RESPONSE",
+ "DELETED_RESPONSE",
+ "ARCHIVED_RESPONSE",
+ "UNARCHIVED_RESPONSE",
+ "INVALID_REQUEST_RESPONSE",
+ "CONFLICT_RESPONSE",
+ "ADMIN_ONLY_RESPONSE",
+ "CANNOT_DELETE_RESPONSE",
+ "CANNOT_ARCHIVE_RESPONSE",
+ "REQUIRED_FIELDS_RESPONSE",
+ "PROJECT_NOT_FOUND_RESPONSE",
+ "WORKSPACE_NOT_FOUND_RESPONSE",
+ "PROJECT_NAME_TAKEN_RESPONSE",
+ "ISSUE_NOT_FOUND_RESPONSE",
+ "WORK_ITEM_NOT_FOUND_RESPONSE",
+ "EXTERNAL_ID_EXISTS_RESPONSE",
+ "LABEL_NOT_FOUND_RESPONSE",
+ "LABEL_NAME_EXISTS_RESPONSE",
+ "MODULE_NOT_FOUND_RESPONSE",
+ "MODULE_ISSUE_NOT_FOUND_RESPONSE",
+ "CYCLE_CANNOT_ARCHIVE_RESPONSE",
+ "STATE_NAME_EXISTS_RESPONSE",
+ "STATE_CANNOT_DELETE_RESPONSE",
+ "COMMENT_NOT_FOUND_RESPONSE",
+ "LINK_NOT_FOUND_RESPONSE",
+ "ATTACHMENT_NOT_FOUND_RESPONSE",
+ "BAD_SEARCH_REQUEST_RESPONSE",
+ "create_paginated_response",
+ "PRESIGNED_URL_SUCCESS_RESPONSE",
+ "GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE",
+ "GENERIC_ASSET_VALIDATION_ERROR_RESPONSE",
+ "ASSET_CONFLICT_RESPONSE",
+ "ASSET_DOWNLOAD_SUCCESS_RESPONSE",
+ "ASSET_DOWNLOAD_ERROR_RESPONSE",
+ "ASSET_UPDATED_RESPONSE",
+ "ASSET_DELETED_RESPONSE",
+ "ASSET_NOT_FOUND_RESPONSE",
+ # Examples
+ "FILE_UPLOAD_EXAMPLE",
+ "WORKSPACE_EXAMPLE",
+ "PROJECT_EXAMPLE",
+ "ISSUE_EXAMPLE",
+ "USER_EXAMPLE",
+ "get_sample_for_schema",
+ # Request Examples
+ "ISSUE_CREATE_EXAMPLE",
+ "ISSUE_UPDATE_EXAMPLE",
+ "ISSUE_UPSERT_EXAMPLE",
+ "LABEL_CREATE_EXAMPLE",
+ "LABEL_UPDATE_EXAMPLE",
+ "ISSUE_LINK_CREATE_EXAMPLE",
+ "ISSUE_LINK_UPDATE_EXAMPLE",
+ "ISSUE_COMMENT_CREATE_EXAMPLE",
+ "ISSUE_COMMENT_UPDATE_EXAMPLE",
+ "ISSUE_ATTACHMENT_UPLOAD_EXAMPLE",
+ "ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE",
+ "CYCLE_CREATE_EXAMPLE",
+ "CYCLE_UPDATE_EXAMPLE",
+ "CYCLE_ISSUE_REQUEST_EXAMPLE",
+ "TRANSFER_CYCLE_ISSUE_EXAMPLE",
+ "MODULE_CREATE_EXAMPLE",
+ "MODULE_UPDATE_EXAMPLE",
+ "MODULE_ISSUE_REQUEST_EXAMPLE",
+ "PROJECT_CREATE_EXAMPLE",
+ "PROJECT_UPDATE_EXAMPLE",
+ "STATE_CREATE_EXAMPLE",
+ "STATE_UPDATE_EXAMPLE",
+ "INTAKE_ISSUE_CREATE_EXAMPLE",
+ "INTAKE_ISSUE_UPDATE_EXAMPLE",
+ # Response Examples
+ "CYCLE_EXAMPLE",
+ "TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE",
+ "TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE",
+ "TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE",
+ "MODULE_EXAMPLE",
+ "STATE_EXAMPLE",
+ "LABEL_EXAMPLE",
+ "ISSUE_LINK_EXAMPLE",
+ "ISSUE_COMMENT_EXAMPLE",
+ "ISSUE_ATTACHMENT_EXAMPLE",
+ "ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE",
+ "INTAKE_ISSUE_EXAMPLE",
+ "MODULE_ISSUE_EXAMPLE",
+ "ISSUE_SEARCH_EXAMPLE",
+ "WORKSPACE_MEMBER_EXAMPLE",
+ "PROJECT_MEMBER_EXAMPLE",
+ "CYCLE_ISSUE_EXAMPLE",
+ # Decorators
+ "workspace_docs",
+ "project_docs",
+ "issue_docs",
+ "intake_docs",
+ "asset_docs",
+ "user_docs",
+ "cycle_docs",
+ "work_item_docs",
+ "label_docs",
+ "issue_link_docs",
+ "issue_comment_docs",
+ "issue_activity_docs",
+ "issue_attachment_docs",
+ "module_docs",
+ "module_issue_docs",
+ "state_docs",
+ # Hooks
+ "preprocess_filter_api_v1_paths",
+ "generate_operation_summary",
+]
diff --git a/apps/api/plane/utils/openapi/auth.py b/apps/api/plane/utils/openapi/auth.py
new file mode 100644
index 000000000..e6012cc4e
--- /dev/null
+++ b/apps/api/plane/utils/openapi/auth.py
@@ -0,0 +1,29 @@
+"""
+OpenAPI authentication extensions for drf-spectacular.
+
+This module provides authentication extensions that automatically register
+custom authentication classes with the OpenAPI schema generator.
+"""
+
+from drf_spectacular.extensions import OpenApiAuthenticationExtension
+
+
+class APIKeyAuthenticationExtension(OpenApiAuthenticationExtension):
+ """
+ OpenAPI authentication extension for plane.api.middleware.api_authentication.APIKeyAuthentication
+ """
+
+ target_class = "plane.api.middleware.api_authentication.APIKeyAuthentication"
+ name = "ApiKeyAuthentication"
+ priority = 1
+
+ def get_security_definition(self, auto_schema):
+ """
+ Return the security definition for API key authentication.
+ """
+ return {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "API key authentication. Provide your API key in the X-API-Key header.",
+ }
diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py
new file mode 100644
index 000000000..e4a86839f
--- /dev/null
+++ b/apps/api/plane/utils/openapi/decorators.py
@@ -0,0 +1,264 @@
+"""
+Helper decorators for drf-spectacular OpenAPI documentation.
+
+This module provides domain-specific decorators that apply common
+parameters, responses, and tags to API endpoints based on their context.
+"""
+
+from drf_spectacular.utils import extend_schema
+from .parameters import WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER
+from .responses import UNAUTHORIZED_RESPONSE, FORBIDDEN_RESPONSE, NOT_FOUND_RESPONSE
+
+
+def _merge_schema_options(defaults, kwargs):
+ """Helper function to merge responses and parameters from kwargs into defaults"""
+ # Merge responses
+ if "responses" in kwargs:
+ defaults["responses"].update(kwargs["responses"])
+ kwargs = {k: v for k, v in kwargs.items() if k != "responses"}
+
+ # Merge parameters
+ if "parameters" in kwargs:
+ defaults["parameters"].extend(kwargs["parameters"])
+ kwargs = {k: v for k, v in kwargs.items() if k != "parameters"}
+
+ defaults.update(kwargs)
+ return defaults
+
+
+def user_docs(**kwargs):
+ """Decorator for user-related endpoints"""
+ defaults = {
+ "tags": ["Users"],
+ "parameters": [],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def workspace_docs(**kwargs):
+ """Decorator for workspace-related endpoints"""
+ defaults = {
+ "tags": ["Workspaces"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def project_docs(**kwargs):
+ """Decorator for project-related endpoints"""
+ defaults = {
+ "tags": ["Projects"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def cycle_docs(**kwargs):
+ """Decorator for cycle-related endpoints"""
+ defaults = {
+ "tags": ["Cycles"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def issue_docs(**kwargs):
+ """Decorator for issue-related endpoints"""
+ defaults = {
+ "tags": ["Work Items"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def intake_docs(**kwargs):
+ """Decorator for intake-related endpoints"""
+ defaults = {
+ "tags": ["Intake"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def asset_docs(**kwargs):
+ """Decorator for asset-related endpoints with common defaults"""
+ defaults = {
+ "tags": ["Assets"],
+ "parameters": [],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+# Issue-related decorators for specific tags
+def work_item_docs(**kwargs):
+ """Decorator for work item endpoints (main issue operations)"""
+ defaults = {
+ "tags": ["Work Items"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def label_docs(**kwargs):
+ """Decorator for label management endpoints"""
+ defaults = {
+ "tags": ["Labels"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def issue_link_docs(**kwargs):
+ """Decorator for issue link endpoints"""
+ defaults = {
+ "tags": ["Work Item Links"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def issue_comment_docs(**kwargs):
+ """Decorator for issue comment endpoints"""
+ defaults = {
+ "tags": ["Work Item Comments"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def issue_activity_docs(**kwargs):
+ """Decorator for issue activity/search endpoints"""
+ defaults = {
+ "tags": ["Work Item Activity"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def issue_attachment_docs(**kwargs):
+ """Decorator for issue attachment endpoints"""
+ defaults = {
+ "tags": ["Work Item Attachments"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def module_docs(**kwargs):
+ """Decorator for module management endpoints"""
+ defaults = {
+ "tags": ["Modules"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def module_issue_docs(**kwargs):
+ """Decorator for module issue management endpoints"""
+ defaults = {
+ "tags": ["Modules"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
+
+
+def state_docs(**kwargs):
+ """Decorator for state management endpoints"""
+ defaults = {
+ "tags": ["States"],
+ "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
+ "responses": {
+ 401: UNAUTHORIZED_RESPONSE,
+ 403: FORBIDDEN_RESPONSE,
+ 404: NOT_FOUND_RESPONSE,
+ },
+ }
+
+ return extend_schema(**_merge_schema_options(defaults, kwargs))
diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py
new file mode 100644
index 000000000..136669159
--- /dev/null
+++ b/apps/api/plane/utils/openapi/examples.py
@@ -0,0 +1,816 @@
+"""
+Common OpenAPI examples for drf-spectacular.
+
+This module provides reusable example data for API responses and requests
+to make the generated documentation more helpful and realistic.
+"""
+
+from drf_spectacular.utils import OpenApiExample
+
+
+# File Upload Examples
+FILE_UPLOAD_EXAMPLE = OpenApiExample(
+ name="File Upload Success",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "asset": "uploads/workspace_1/file_example.pdf",
+ "attributes": {
+ "name": "example-document.pdf",
+ "size": 1024000,
+ "mimetype": "application/pdf",
+ },
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z",
+ },
+)
+
+
+# Workspace Examples
+WORKSPACE_EXAMPLE = OpenApiExample(
+ name="Workspace",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "My Workspace",
+ "slug": "my-workspace",
+ "organization_size": "1-10",
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z",
+ },
+)
+
+
+# Project Examples
+PROJECT_EXAMPLE = OpenApiExample(
+ name="Project",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Mobile App Development",
+ "description": "Development of the mobile application",
+ "identifier": "MAD",
+ "network": 2,
+ "project_lead": "550e8400-e29b-41d4-a716-446655440001",
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z",
+ },
+)
+
+
+# Issue Examples
+ISSUE_EXAMPLE = OpenApiExample(
+ name="Issue",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Implement user authentication",
+ "description": "Add OAuth 2.0 authentication flow",
+ "sequence_id": 1,
+ "priority": "high",
+ "assignees": ["550e8400-e29b-41d4-a716-446655440001"],
+ "labels": ["550e8400-e29b-41d4-a716-446655440002"],
+ "created_at": "2024-01-15T10:30:00Z",
+ "updated_at": "2024-01-15T10:30:00Z",
+ },
+)
+
+
+# User Examples
+USER_EXAMPLE = OpenApiExample(
+ name="User",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "first_name": "John",
+ "last_name": "Doe",
+ "email": "john.doe@example.com",
+ "avatar": "https://example.com/avatar.jpg",
+ "avatar_url": "https://example.com/avatar.jpg",
+ "display_name": "John Doe",
+ },
+)
+
+
+# ============================================================================
+# REQUEST EXAMPLES - Centralized examples for API requests
+# ============================================================================
+
+# Work Item / Issue Examples
+ISSUE_CREATE_EXAMPLE = OpenApiExample(
+ "IssueCreateSerializer",
+ value={
+ "name": "New Issue",
+ "description": "New issue description",
+ "priority": "medium",
+ "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
+ "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"],
+ "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"],
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating a work item",
+)
+
+ISSUE_UPDATE_EXAMPLE = OpenApiExample(
+ "IssueUpdateSerializer",
+ value={
+ "name": "Updated Issue",
+ "description": "Updated issue description",
+ "priority": "medium",
+ "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
+ "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"],
+ "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"],
+ },
+ description="Example request for updating a work item",
+)
+
+ISSUE_UPSERT_EXAMPLE = OpenApiExample(
+ "IssueUpsertSerializer",
+ value={
+ "name": "Updated Issue via External ID",
+ "description": "Updated issue description",
+ "priority": "high",
+ "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
+ "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"],
+ "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"],
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for upserting a work item via external ID",
+)
+
+# Label Examples
+LABEL_CREATE_EXAMPLE = OpenApiExample(
+ "LabelCreateUpdateSerializer",
+ value={
+ "name": "New Label",
+ "color": "#ff0000",
+ "description": "New label description",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating a label",
+)
+
+LABEL_UPDATE_EXAMPLE = OpenApiExample(
+ "LabelCreateUpdateSerializer",
+ value={
+ "name": "Updated Label",
+ "color": "#00ff00",
+ "description": "Updated label description",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for updating a label",
+)
+
+# Issue Link Examples
+ISSUE_LINK_CREATE_EXAMPLE = OpenApiExample(
+ "IssueLinkCreateSerializer",
+ value={
+ "url": "https://example.com",
+ "title": "Example Link",
+ },
+ description="Example request for creating an issue link",
+)
+
+ISSUE_LINK_UPDATE_EXAMPLE = OpenApiExample(
+ "IssueLinkUpdateSerializer",
+ value={
+ "url": "https://example.com",
+ "title": "Updated Link",
+ },
+ description="Example request for updating an issue link",
+)
+
+# Issue Comment Examples
+ISSUE_COMMENT_CREATE_EXAMPLE = OpenApiExample(
+ "IssueCommentCreateSerializer",
+ value={
+ "comment_html": "New comment content
",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating an issue comment",
+)
+
+ISSUE_COMMENT_UPDATE_EXAMPLE = OpenApiExample(
+ "IssueCommentCreateSerializer",
+ value={
+ "comment_html": "Updated comment content
",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for updating an issue comment",
+)
+
+# Issue Attachment Examples
+ISSUE_ATTACHMENT_UPLOAD_EXAMPLE = OpenApiExample(
+ "IssueAttachmentUploadSerializer",
+ value={
+ "name": "document.pdf",
+ "type": "application/pdf",
+ "size": 1024000,
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating an issue attachment",
+)
+
+ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE = OpenApiExample(
+ "ConfirmUpload",
+ value={"is_uploaded": True},
+ description="Confirm that the attachment has been successfully uploaded",
+)
+
+# Cycle Examples
+CYCLE_CREATE_EXAMPLE = OpenApiExample(
+ "CycleCreateSerializer",
+ value={
+ "name": "Cycle 1",
+ "description": "Cycle 1 description",
+ "start_date": "2021-01-01",
+ "end_date": "2021-01-31",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating a cycle",
+)
+
+CYCLE_UPDATE_EXAMPLE = OpenApiExample(
+ "CycleUpdateSerializer",
+ value={
+ "name": "Updated Cycle",
+ "description": "Updated cycle description",
+ "start_date": "2021-01-01",
+ "end_date": "2021-01-31",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for updating a cycle",
+)
+
+CYCLE_ISSUE_REQUEST_EXAMPLE = OpenApiExample(
+ "CycleIssueRequestSerializer",
+ value={
+ "issues": [
+ "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
+ "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
+ ],
+ },
+ description="Example request for adding cycle issues",
+)
+
+TRANSFER_CYCLE_ISSUE_EXAMPLE = OpenApiExample(
+ "TransferCycleIssueRequestSerializer",
+ value={
+ "new_cycle_id": "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
+ },
+ description="Example request for transferring cycle issues",
+)
+
+# Module Examples
+MODULE_CREATE_EXAMPLE = OpenApiExample(
+ "ModuleCreateSerializer",
+ value={
+ "name": "New Module",
+ "description": "New module description",
+ "start_date": "2021-01-01",
+ "end_date": "2021-01-31",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating a module",
+)
+
+MODULE_UPDATE_EXAMPLE = OpenApiExample(
+ "ModuleUpdateSerializer",
+ value={
+ "name": "Updated Module",
+ "description": "Updated module description",
+ "start_date": "2021-01-01",
+ "end_date": "2021-01-31",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for updating a module",
+)
+
+MODULE_ISSUE_REQUEST_EXAMPLE = OpenApiExample(
+ "ModuleIssueRequestSerializer",
+ value={
+ "issues": [
+ "0ec6cfa4-e906-4aad-9390-2df0303a41cd",
+ "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
+ ],
+ },
+ description="Example request for adding module issues",
+)
+
+# Project Examples
+PROJECT_CREATE_EXAMPLE = OpenApiExample(
+ "ProjectCreateSerializer",
+ value={
+ "name": "New Project",
+ "description": "New project description",
+ "identifier": "new-project",
+ "project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
+ },
+ description="Example request for creating a project",
+)
+
+PROJECT_UPDATE_EXAMPLE = OpenApiExample(
+ "ProjectUpdateSerializer",
+ value={
+ "name": "Updated Project",
+ "description": "Updated project description",
+ "identifier": "updated-project",
+ "project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce",
+ },
+ description="Example request for updating a project",
+)
+
+# State Examples
+STATE_CREATE_EXAMPLE = OpenApiExample(
+ "StateCreateSerializer",
+ value={
+ "name": "New State",
+ "color": "#ff0000",
+ "group": "backlog",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for creating a state",
+)
+
+STATE_UPDATE_EXAMPLE = OpenApiExample(
+ "StateUpdateSerializer",
+ value={
+ "name": "Updated State",
+ "color": "#00ff00",
+ "group": "backlog",
+ "external_id": "1234567890",
+ "external_source": "github",
+ },
+ description="Example request for updating a state",
+)
+
+# Intake Examples
+INTAKE_ISSUE_CREATE_EXAMPLE = OpenApiExample(
+ "IntakeIssueCreateSerializer",
+ value={
+ "issue": {
+ "name": "New Issue",
+ "description": "New issue description",
+ "priority": "medium",
+ }
+ },
+ description="Example request for creating an intake issue",
+)
+
+INTAKE_ISSUE_UPDATE_EXAMPLE = OpenApiExample(
+ "IntakeIssueUpdateSerializer",
+ value={
+ "status": 1,
+ "issue": {
+ "name": "Updated Issue",
+ "description": "Updated issue description",
+ "priority": "high",
+ },
+ },
+ description="Example request for updating an intake issue",
+)
+
+
+# ============================================================================
+# RESPONSE EXAMPLES - Centralized examples for API responses
+# ============================================================================
+
+# Cycle Response Examples
+CYCLE_EXAMPLE = OpenApiExample(
+ name="Cycle",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Sprint 1 - Q1 2024",
+ "description": "First sprint of the quarter focusing on core features",
+ "start_date": "2024-01-01",
+ "end_date": "2024-01-14",
+ "status": "current",
+ "total_issues": 15,
+ "completed_issues": 8,
+ "cancelled_issues": 1,
+ "started_issues": 4,
+ "unstarted_issues": 2,
+ "backlog_issues": 0,
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Transfer Cycle Issue Response Examples
+TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE = OpenApiExample(
+ name="Transfer Cycle Issue Success",
+ value={
+ "message": "Success",
+ },
+ description="Successful transfer of cycle issues to new cycle",
+)
+
+TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE = OpenApiExample(
+ name="Transfer Cycle Issue Error",
+ value={
+ "error": "New Cycle Id is required",
+ },
+ description="Error when required cycle ID is missing",
+)
+
+TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE = OpenApiExample(
+ name="Transfer to Completed Cycle Error",
+ value={
+ "error": "The cycle where the issues are transferred is already completed",
+ },
+ description="Error when trying to transfer to a completed cycle",
+)
+
+# Module Response Examples
+MODULE_EXAMPLE = OpenApiExample(
+ name="Module",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Authentication Module",
+ "description": "User authentication and authorization features",
+ "start_date": "2024-01-01",
+ "target_date": "2024-02-15",
+ "status": "in-progress",
+ "total_issues": 12,
+ "completed_issues": 5,
+ "cancelled_issues": 0,
+ "started_issues": 4,
+ "unstarted_issues": 3,
+ "backlog_issues": 0,
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# State Response Examples
+STATE_EXAMPLE = OpenApiExample(
+ name="State",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "In Progress",
+ "color": "#f39c12",
+ "group": "started",
+ "sequence": 2,
+ "default": False,
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Label Response Examples
+LABEL_EXAMPLE = OpenApiExample(
+ name="Label",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "bug",
+ "color": "#ff4444",
+ "description": "Issues that represent bugs in the system",
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Issue Link Response Examples
+ISSUE_LINK_EXAMPLE = OpenApiExample(
+ name="IssueLink",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "url": "https://github.com/example/repo/pull/123",
+ "title": "Fix authentication bug",
+ "metadata": {
+ "title": "Fix authentication bug",
+ "description": "Pull request to fix authentication timeout issue",
+ "image": "https://github.com/example/repo/avatar.png",
+ },
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Issue Comment Response Examples
+ISSUE_COMMENT_EXAMPLE = OpenApiExample(
+ name="IssueComment",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "comment_html": "This issue has been resolved by implementing OAuth 2.0 flow.
",
+ "comment_json": {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "text": "This issue has been resolved by implementing OAuth 2.0 flow.",
+ }
+ ],
+ }
+ ],
+ },
+ "actor": {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "first_name": "John",
+ "last_name": "Doe",
+ "display_name": "John Doe",
+ "avatar": "https://example.com/avatar.jpg",
+ },
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Issue Attachment Response Examples
+ISSUE_ATTACHMENT_EXAMPLE = OpenApiExample(
+ name="IssueAttachment",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "screenshot.png",
+ "size": 1024000,
+ "asset_url": "https://s3.amazonaws.com/bucket/screenshot.png?signed-url",
+ "attributes": {
+ "name": "screenshot.png",
+ "type": "image/png",
+ "size": 1024000,
+ },
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Issue Attachment Error Response Examples
+ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE = OpenApiExample(
+ name="Issue Attachment Not Uploaded",
+ value={
+ "error": "The asset is not uploaded.",
+ "status": False,
+ },
+ description="Error when trying to download an attachment that hasn't been uploaded yet",
+)
+
+# Intake Issue Response Examples
+INTAKE_ISSUE_EXAMPLE = OpenApiExample(
+ name="IntakeIssue",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "status": 0, # Pending
+ "source": "in_app",
+ "issue": {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "name": "Feature request: Dark mode",
+ "description": "Add dark mode support to the application",
+ "priority": "medium",
+ "sequence_id": 124,
+ },
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Module Issue Response Examples
+MODULE_ISSUE_EXAMPLE = OpenApiExample(
+ name="ModuleIssue",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "module": "550e8400-e29b-41d4-a716-446655440001",
+ "issue": "550e8400-e29b-41d4-a716-446655440002",
+ "sub_issues_count": 2,
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+# Issue Search Response Examples
+ISSUE_SEARCH_EXAMPLE = OpenApiExample(
+ name="IssueSearchResults",
+ value={
+ "issues": [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Fix authentication bug in user login",
+ "sequence_id": 123,
+ "project__identifier": "MAB",
+ "project_id": "550e8400-e29b-41d4-a716-446655440001",
+ "workspace__slug": "my-workspace",
+ },
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440002",
+ "name": "Add authentication middleware",
+ "sequence_id": 124,
+ "project__identifier": "MAB",
+ "project_id": "550e8400-e29b-41d4-a716-446655440001",
+ "workspace__slug": "my-workspace",
+ },
+ ]
+ },
+)
+
+# Workspace Member Response Examples
+WORKSPACE_MEMBER_EXAMPLE = OpenApiExample(
+ name="WorkspaceMembers",
+ value=[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "first_name": "John",
+ "last_name": "Doe",
+ "display_name": "John Doe",
+ "email": "john.doe@example.com",
+ "avatar": "https://example.com/avatar.jpg",
+ "role": 20,
+ },
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "first_name": "Jane",
+ "last_name": "Smith",
+ "display_name": "Jane Smith",
+ "email": "jane.smith@example.com",
+ "avatar": "https://example.com/avatar2.jpg",
+ "role": 15,
+ },
+ ],
+)
+
+# Project Member Response Examples
+PROJECT_MEMBER_EXAMPLE = OpenApiExample(
+ name="ProjectMembers",
+ value=[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "first_name": "John",
+ "last_name": "Doe",
+ "display_name": "John Doe",
+ "email": "john.doe@example.com",
+ "avatar": "https://example.com/avatar.jpg",
+ },
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "first_name": "Jane",
+ "last_name": "Smith",
+ "display_name": "Jane Smith",
+ "email": "jane.smith@example.com",
+ "avatar": "https://example.com/avatar2.jpg",
+ },
+ ],
+)
+
+# Cycle Issue Response Examples
+CYCLE_ISSUE_EXAMPLE = OpenApiExample(
+ name="CycleIssue",
+ value={
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "cycle": "550e8400-e29b-41d4-a716-446655440001",
+ "issue": "550e8400-e29b-41d4-a716-446655440002",
+ "sub_issues_count": 3,
+ "created_at": "2024-01-01T10:30:00Z",
+ "updated_at": "2024-01-10T15:45:00Z",
+ },
+)
+
+
+# Sample data for different entity types
+SAMPLE_ISSUE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Fix authentication bug in user login",
+ "description": "Users are unable to log in due to authentication service timeout",
+ "priority": "high",
+ "sequence_id": 123,
+ "state": {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "name": "In Progress",
+ "group": "started",
+ },
+ "assignees": [],
+ "labels": [],
+ "created_at": "2024-01-15T10:30:00Z",
+}
+
+SAMPLE_LABEL = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "bug",
+ "color": "#ff4444",
+ "description": "Issues that represent bugs in the system",
+}
+
+SAMPLE_CYCLE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Sprint 1 - Q1 2024",
+ "description": "First sprint of the quarter focusing on core features",
+ "start_date": "2024-01-01",
+ "end_date": "2024-01-14",
+ "status": "current",
+}
+
+SAMPLE_MODULE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Authentication Module",
+ "description": "User authentication and authorization features",
+ "start_date": "2024-01-01",
+ "target_date": "2024-02-15",
+ "status": "in_progress",
+}
+
+SAMPLE_PROJECT = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Mobile App Backend",
+ "description": "Backend services for the mobile application",
+ "identifier": "MAB",
+ "network": 2,
+}
+
+SAMPLE_STATE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "In Progress",
+ "color": "#ffa500",
+ "group": "started",
+ "sequence": 2,
+}
+
+SAMPLE_COMMENT = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "comment_html": "This issue needs more investigation. I'll look into the database connection timeout.
",
+ "created_at": "2024-01-15T14:20:00Z",
+ "actor": {"id": "550e8400-e29b-41d4-a716-446655440002", "display_name": "John Doe"},
+}
+
+SAMPLE_LINK = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "url": "https://github.com/example/repo/pull/123",
+ "title": "Fix authentication timeout issue",
+ "metadata": {},
+}
+
+SAMPLE_ACTIVITY = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "field": "priority",
+ "old_value": "medium",
+ "new_value": "high",
+ "created_at": "2024-01-15T11:45:00Z",
+ "actor": {
+ "id": "550e8400-e29b-41d4-a716-446655440002",
+ "display_name": "Jane Smith",
+ },
+}
+
+SAMPLE_INTAKE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "status": 0,
+ "issue": {
+ "id": "550e8400-e29b-41d4-a716-446655440003",
+ "name": "Feature request: Dark mode support",
+ },
+ "created_at": "2024-01-15T09:15:00Z",
+}
+
+SAMPLE_GENERIC = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Sample Item",
+ "created_at": "2024-01-15T12:00:00Z",
+}
+
+SAMPLE_CYCLE_ISSUE = {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "cycle": "550e8400-e29b-41d4-a716-446655440001",
+ "issue": "550e8400-e29b-41d4-a716-446655440002",
+ "sub_issues_count": 3,
+ "created_at": "2024-01-01T10:30:00Z",
+}
+
+# Mapping of schema types to sample data
+SCHEMA_EXAMPLES = {
+ "Issue": SAMPLE_ISSUE,
+ "WorkItem": SAMPLE_ISSUE,
+ "Label": SAMPLE_LABEL,
+ "Cycle": SAMPLE_CYCLE,
+ "Module": SAMPLE_MODULE,
+ "Project": SAMPLE_PROJECT,
+ "State": SAMPLE_STATE,
+ "Comment": SAMPLE_COMMENT,
+ "Link": SAMPLE_LINK,
+ "Activity": SAMPLE_ACTIVITY,
+ "Intake": SAMPLE_INTAKE,
+ "CycleIssue": SAMPLE_CYCLE_ISSUE,
+}
+
+
+def get_sample_for_schema(schema_name):
+ """
+ Get appropriate sample data for a schema type.
+
+ Args:
+ schema_name (str): Name of the schema (e.g., "PaginatedIssueResponse")
+
+ Returns:
+ dict: Sample data for the schema type
+ """
+ # Extract base schema name from paginated responses
+ if schema_name.startswith("Paginated"):
+ base_name = schema_name.replace("Paginated", "").replace("Response", "")
+ return SCHEMA_EXAMPLES.get(base_name, SAMPLE_GENERIC)
+
+ return SCHEMA_EXAMPLES.get(schema_name, SAMPLE_GENERIC)
diff --git a/apps/api/plane/utils/openapi/hooks.py b/apps/api/plane/utils/openapi/hooks.py
new file mode 100644
index 000000000..3cd7eaf7a
--- /dev/null
+++ b/apps/api/plane/utils/openapi/hooks.py
@@ -0,0 +1,56 @@
+"""
+Schema processing hooks for drf-spectacular OpenAPI generation.
+
+This module provides preprocessing and postprocessing functions that modify
+the generated OpenAPI schema to apply custom filtering, tagging, and other
+transformations.
+"""
+
+
+def preprocess_filter_api_v1_paths(endpoints):
+ """
+ Filter OpenAPI endpoints to only include /api/v1/ paths and exclude PUT methods.
+ """
+ filtered = []
+ for path, path_regex, method, callback in endpoints:
+ # Only include paths that start with /api/v1/ and exclude PUT methods
+ if (
+ path.startswith("/api/v1/")
+ and method.upper() != "PUT"
+ and "server" not in path.lower()
+ ):
+ filtered.append((path, path_regex, method, callback))
+ return filtered
+
+
+def generate_operation_summary(method, path, tag):
+ """
+ Generate a human-readable summary for an operation.
+ """
+ # Extract the main resource from the path
+ path_parts = [part for part in path.split("/") if part and not part.startswith("{")]
+
+ if len(path_parts) > 0:
+ resource = path_parts[-1].replace("-", " ").title()
+ else:
+ resource = tag
+
+ # Generate summary based on method
+ method_summaries = {
+ "GET": f"Retrieve {resource}",
+ "POST": f"Create {resource}",
+ "PATCH": f"Update {resource}",
+ "DELETE": f"Delete {resource}",
+ }
+
+ # Handle specific cases
+ if "archive" in path.lower():
+ if method == "POST":
+ return f'Archive {tag.rstrip("s")}'
+ elif method == "DELETE":
+ return f'Unarchive {tag.rstrip("s")}'
+
+ if "transfer" in path.lower():
+ return f'Transfer {tag.rstrip("s")}'
+
+ return method_summaries.get(method, f"{method} {resource}")
diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py
new file mode 100644
index 000000000..0d7f3a3d1
--- /dev/null
+++ b/apps/api/plane/utils/openapi/parameters.py
@@ -0,0 +1,493 @@
+"""
+Common OpenAPI parameters for drf-spectacular.
+
+This module provides reusable parameter definitions that can be shared
+across multiple API endpoints to ensure consistency.
+"""
+
+from drf_spectacular.utils import OpenApiParameter, OpenApiExample
+from drf_spectacular.types import OpenApiTypes
+
+
+# Path Parameters
+WORKSPACE_SLUG_PARAMETER = OpenApiParameter(
+ name="slug",
+ description="Workspace slug",
+ required=True,
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example workspace",
+ value="my-workspace",
+ description="A typical workspace slug",
+ )
+ ],
+)
+
+PROJECT_ID_PARAMETER = OpenApiParameter(
+ name="project_id",
+ description="Project ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example project ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical project UUID",
+ )
+ ],
+)
+
+PROJECT_PK_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Project ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example project ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical project UUID",
+ )
+ ],
+)
+
+PROJECT_IDENTIFIER_PARAMETER = OpenApiParameter(
+ name="project_identifier",
+ description="Project identifier (unique string within workspace)",
+ required=True,
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example project identifier",
+ value="PROJ",
+ description="A typical project identifier",
+ )
+ ],
+)
+
+ISSUE_IDENTIFIER_PARAMETER = OpenApiParameter(
+ name="issue_identifier",
+ description="Issue sequence ID (numeric identifier within project)",
+ required=True,
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example issue identifier",
+ value=123,
+ description="A typical issue sequence ID",
+ )
+ ],
+)
+
+ASSET_ID_PARAMETER = OpenApiParameter(
+ name="asset_id",
+ description="Asset ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example asset ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical asset UUID",
+ )
+ ],
+)
+
+CYCLE_ID_PARAMETER = OpenApiParameter(
+ name="cycle_id",
+ description="Cycle ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example cycle ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical cycle UUID",
+ )
+ ],
+)
+
+MODULE_ID_PARAMETER = OpenApiParameter(
+ name="module_id",
+ description="Module ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example module ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical module UUID",
+ )
+ ],
+)
+
+MODULE_PK_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Module ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example module ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical module UUID",
+ )
+ ],
+)
+
+ISSUE_ID_PARAMETER = OpenApiParameter(
+ name="issue_id",
+ description="Issue ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example issue ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical issue UUID",
+ )
+ ],
+)
+
+STATE_ID_PARAMETER = OpenApiParameter(
+ name="state_id",
+ description="State ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example state ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical state UUID",
+ )
+ ],
+)
+
+# Additional Path Parameters
+LABEL_ID_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Label ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example label ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical label UUID",
+ )
+ ],
+)
+
+COMMENT_ID_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Comment ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example comment ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical comment UUID",
+ )
+ ],
+)
+
+LINK_ID_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Link ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example link ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical link UUID",
+ )
+ ],
+)
+
+ATTACHMENT_ID_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Attachment ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example attachment ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical attachment UUID",
+ )
+ ],
+)
+
+ACTIVITY_ID_PARAMETER = OpenApiParameter(
+ name="pk",
+ description="Activity ID",
+ required=True,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.PATH,
+ examples=[
+ OpenApiExample(
+ name="Example activity ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="A typical activity UUID",
+ )
+ ],
+)
+
+# Query Parameters
+CURSOR_PARAMETER = OpenApiParameter(
+ name="cursor",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Pagination cursor for getting next set of results",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="Next page cursor",
+ value="20:1:0",
+ description="Cursor format: 'page_size:page_number:offset'",
+ )
+ ],
+)
+
+PER_PAGE_PARAMETER = OpenApiParameter(
+ name="per_page",
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.QUERY,
+ description="Number of results per page (default: 20, max: 100)",
+ required=False,
+ examples=[
+ OpenApiExample(name="Default", value=20),
+ OpenApiExample(name="Maximum", value=100),
+ ],
+)
+
+# External Integration Parameters
+EXTERNAL_ID_PARAMETER = OpenApiParameter(
+ name="external_id",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="External system identifier for filtering or lookup",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="GitHub Issue",
+ value="1234567890",
+ description="GitHub issue number",
+ )
+ ],
+)
+
+EXTERNAL_SOURCE_PARAMETER = OpenApiParameter(
+ name="external_source",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="External system source name for filtering or lookup",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="GitHub",
+ value="github",
+ description="GitHub integration source",
+ ),
+ OpenApiExample(
+ name="Jira",
+ value="jira",
+ description="Jira integration source",
+ ),
+ ],
+)
+
+# Ordering Parameters
+ORDER_BY_PARAMETER = OpenApiParameter(
+ name="order_by",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Field to order results by. Prefix with '-' for descending order",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="Created date descending",
+ value="-created_at",
+ description="Most recent items first",
+ ),
+ OpenApiExample(
+ name="Priority ascending",
+ value="priority",
+ description="Order by priority (urgent, high, medium, low, none)",
+ ),
+ OpenApiExample(
+ name="State group",
+ value="state__group",
+ description="Order by state group (backlog, unstarted, started, completed, cancelled)",
+ ),
+ OpenApiExample(
+ name="Assignee name",
+ value="assignees__first_name",
+ description="Order by assignee first name",
+ ),
+ ],
+)
+
+# Search Parameters
+SEARCH_PARAMETER = OpenApiParameter(
+ name="search",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Search query to filter results by name, description, or identifier",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="Name search",
+ value="bug fix",
+ description="Search for items containing 'bug fix'",
+ ),
+ OpenApiExample(
+ name="Sequence ID",
+ value="123",
+ description="Search by sequence ID number",
+ ),
+ ],
+)
+
+SEARCH_PARAMETER_REQUIRED = OpenApiParameter(
+ name="search",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Search query to filter results by name, description, or identifier",
+ required=True,
+ examples=[
+ OpenApiExample(
+ name="Name search",
+ value="bug fix",
+ description="Search for items containing 'bug fix'",
+ ),
+ OpenApiExample(
+ name="Sequence ID",
+ value="123",
+ description="Search by sequence ID number",
+ ),
+ ],
+)
+
+LIMIT_PARAMETER = OpenApiParameter(
+ name="limit",
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.QUERY,
+ description="Maximum number of results to return",
+ required=False,
+ examples=[
+ OpenApiExample(name="Default", value=10),
+ OpenApiExample(name="More results", value=50),
+ ],
+)
+
+WORKSPACE_SEARCH_PARAMETER = OpenApiParameter(
+ name="workspace_search",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Whether to search across entire workspace or within specific project",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="Project only",
+ value="false",
+ description="Search within specific project only",
+ ),
+ OpenApiExample(
+ name="Workspace wide",
+ value="true",
+ description="Search across entire workspace",
+ ),
+ ],
+)
+
+PROJECT_ID_QUERY_PARAMETER = OpenApiParameter(
+ name="project_id",
+ description="Project ID for filtering results within a specific project",
+ required=False,
+ type=OpenApiTypes.UUID,
+ location=OpenApiParameter.QUERY,
+ examples=[
+ OpenApiExample(
+ name="Example project ID",
+ value="550e8400-e29b-41d4-a716-446655440000",
+ description="Filter results for this project",
+ )
+ ],
+)
+
+# Cycle View Parameter
+CYCLE_VIEW_PARAMETER = OpenApiParameter(
+ name="cycle_view",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Filter cycles by status",
+ required=False,
+ examples=[
+ OpenApiExample(name="All cycles", value="all"),
+ OpenApiExample(name="Current cycles", value="current"),
+ OpenApiExample(name="Upcoming cycles", value="upcoming"),
+ OpenApiExample(name="Completed cycles", value="completed"),
+ OpenApiExample(name="Draft cycles", value="draft"),
+ OpenApiExample(name="Incomplete cycles", value="incomplete"),
+ ],
+)
+
+# Field Selection Parameters
+FIELDS_PARAMETER = OpenApiParameter(
+ name="fields",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Comma-separated list of fields to include in response",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="Basic fields",
+ value="id,name,description",
+ description="Include only basic fields",
+ ),
+ OpenApiExample(
+ name="With relations",
+ value="id,name,assignees,state",
+ description="Include fields with relationships",
+ ),
+ ],
+)
+
+EXPAND_PARAMETER = OpenApiParameter(
+ name="expand",
+ type=OpenApiTypes.STR,
+ location=OpenApiParameter.QUERY,
+ description="Comma-separated list of related fields to expand in response",
+ required=False,
+ examples=[
+ OpenApiExample(
+ name="Expand assignees",
+ value="assignees",
+ description="Include full assignee details",
+ ),
+ OpenApiExample(
+ name="Multiple expansions",
+ value="assignees,labels,state",
+ description="Include details for multiple relations",
+ ),
+ ],
+)
diff --git a/apps/api/plane/utils/openapi/responses.py b/apps/api/plane/utils/openapi/responses.py
new file mode 100644
index 000000000..a70a749f3
--- /dev/null
+++ b/apps/api/plane/utils/openapi/responses.py
@@ -0,0 +1,492 @@
+"""
+Common OpenAPI responses for drf-spectacular.
+
+This module provides reusable response definitions for common HTTP status codes
+and scenarios that occur across multiple API endpoints.
+"""
+
+from drf_spectacular.utils import OpenApiResponse, OpenApiExample, inline_serializer
+from rest_framework import serializers
+from .examples import get_sample_for_schema
+
+
+# Authentication & Authorization Responses
+UNAUTHORIZED_RESPONSE = OpenApiResponse(
+ description="Authentication credentials were not provided or are invalid.",
+ examples=[
+ OpenApiExample(
+ name="Unauthorized",
+ value={
+ "error": "Authentication credentials were not provided",
+ "error_code": "AUTHENTICATION_REQUIRED",
+ },
+ )
+ ],
+)
+
+FORBIDDEN_RESPONSE = OpenApiResponse(
+ description="Permission denied. User lacks required permissions.",
+ examples=[
+ OpenApiExample(
+ name="Forbidden",
+ value={
+ "error": "You do not have permission to perform this action",
+ "error_code": "PERMISSION_DENIED",
+ },
+ )
+ ],
+)
+
+
+# Resource Responses
+NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="The requested resource was not found.",
+ examples=[
+ OpenApiExample(
+ name="Not Found",
+ value={"error": "Not found", "error_code": "RESOURCE_NOT_FOUND"},
+ )
+ ],
+)
+
+VALIDATION_ERROR_RESPONSE = OpenApiResponse(
+ description="Validation error occurred with the provided data.",
+ examples=[
+ OpenApiExample(
+ name="Validation Error",
+ value={
+ "error": "Validation failed",
+ "details": {"field_name": ["This field is required."]},
+ },
+ )
+ ],
+)
+
+# Generic Success Responses
+DELETED_RESPONSE = OpenApiResponse(
+ description="Resource deleted successfully",
+ examples=[
+ OpenApiExample(
+ name="Deleted Successfully",
+ value={"message": "Resource deleted successfully"},
+ )
+ ],
+)
+
+ARCHIVED_RESPONSE = OpenApiResponse(
+ description="Resource archived successfully",
+ examples=[
+ OpenApiExample(
+ name="Archived Successfully",
+ value={"message": "Resource archived successfully"},
+ )
+ ],
+)
+
+UNARCHIVED_RESPONSE = OpenApiResponse(
+ description="Resource unarchived successfully",
+ examples=[
+ OpenApiExample(
+ name="Unarchived Successfully",
+ value={"message": "Resource unarchived successfully"},
+ )
+ ],
+)
+
+# Specific Error Responses
+INVALID_REQUEST_RESPONSE = OpenApiResponse(
+ description="Invalid request data provided",
+ examples=[
+ OpenApiExample(
+ name="Invalid Request",
+ value={
+ "error": "Invalid request data",
+ "details": "Specific validation errors",
+ },
+ )
+ ],
+)
+
+CONFLICT_RESPONSE = OpenApiResponse(
+ description="Resource conflict - duplicate or constraint violation",
+ examples=[
+ OpenApiExample(
+ name="Resource Conflict",
+ value={
+ "error": "Resource with the same identifier already exists",
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ },
+ )
+ ],
+)
+
+ADMIN_ONLY_RESPONSE = OpenApiResponse(
+ description="Only admin or creator can perform this action",
+ examples=[
+ OpenApiExample(
+ name="Admin Only",
+ value={"error": "Only admin or creator can perform this action"},
+ )
+ ],
+)
+
+CANNOT_DELETE_RESPONSE = OpenApiResponse(
+ description="Resource cannot be deleted due to constraints",
+ examples=[
+ OpenApiExample(
+ name="Cannot Delete",
+ value={"error": "Resource cannot be deleted", "reason": "Has dependencies"},
+ )
+ ],
+)
+
+CANNOT_ARCHIVE_RESPONSE = OpenApiResponse(
+ description="Resource cannot be archived in current state",
+ examples=[
+ OpenApiExample(
+ name="Cannot Archive",
+ value={
+ "error": "Resource cannot be archived",
+ "reason": "Not in valid state",
+ },
+ )
+ ],
+)
+
+REQUIRED_FIELDS_RESPONSE = OpenApiResponse(
+ description="Required fields are missing",
+ examples=[
+ OpenApiExample(
+ name="Required Fields Missing",
+ value={"error": "Required fields are missing", "fields": ["name", "type"]},
+ )
+ ],
+)
+
+# Project-specific Responses
+PROJECT_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Project not found",
+ examples=[
+ OpenApiExample(
+ name="Project Not Found",
+ value={"error": "Project not found"},
+ )
+ ],
+)
+
+WORKSPACE_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Workspace not found",
+ examples=[
+ OpenApiExample(
+ name="Workspace Not Found",
+ value={"error": "Workspace not found"},
+ )
+ ],
+)
+
+PROJECT_NAME_TAKEN_RESPONSE = OpenApiResponse(
+ description="Project name already taken",
+ examples=[
+ OpenApiExample(
+ name="Project Name Taken",
+ value={"error": "Project name already taken"},
+ )
+ ],
+)
+
+# Issue-specific Responses
+ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Issue not found",
+ examples=[
+ OpenApiExample(
+ name="Issue Not Found",
+ value={"error": "Issue not found"},
+ )
+ ],
+)
+
+WORK_ITEM_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Work item not found",
+ examples=[
+ OpenApiExample(
+ name="Work Item Not Found",
+ value={"error": "Work item not found"},
+ )
+ ],
+)
+
+EXTERNAL_ID_EXISTS_RESPONSE = OpenApiResponse(
+ description="Resource with same external ID already exists",
+ examples=[
+ OpenApiExample(
+ name="External ID Exists",
+ value={
+ "error": "Resource with the same external id and external source already exists",
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ },
+ )
+ ],
+)
+
+# Label-specific Responses
+LABEL_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Label not found",
+ examples=[
+ OpenApiExample(
+ name="Label Not Found",
+ value={"error": "Label not found"},
+ )
+ ],
+)
+
+LABEL_NAME_EXISTS_RESPONSE = OpenApiResponse(
+ description="Label with the same name already exists",
+ examples=[
+ OpenApiExample(
+ name="Label Name Exists",
+ value={"error": "Label with the same name already exists in the project"},
+ )
+ ],
+)
+
+# Module-specific Responses
+MODULE_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Module not found",
+ examples=[
+ OpenApiExample(
+ name="Module Not Found",
+ value={"error": "Module not found"},
+ )
+ ],
+)
+
+MODULE_ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Module issue not found",
+ examples=[
+ OpenApiExample(
+ name="Module Issue Not Found",
+ value={"error": "Module issue not found"},
+ )
+ ],
+)
+
+# Cycle-specific Responses
+CYCLE_CANNOT_ARCHIVE_RESPONSE = OpenApiResponse(
+ description="Cycle cannot be archived",
+ examples=[
+ OpenApiExample(
+ name="Cycle Cannot Archive",
+ value={"error": "Only completed cycles can be archived"},
+ )
+ ],
+)
+
+# State-specific Responses
+STATE_NAME_EXISTS_RESPONSE = OpenApiResponse(
+ description="State with the same name already exists",
+ examples=[
+ OpenApiExample(
+ name="State Name Exists",
+ value={"error": "State with the same name already exists"},
+ )
+ ],
+)
+
+STATE_CANNOT_DELETE_RESPONSE = OpenApiResponse(
+ description="State cannot be deleted",
+ examples=[
+ OpenApiExample(
+ name="State Cannot Delete",
+ value={
+ "error": "State cannot be deleted",
+ "reason": "Default state or has issues",
+ },
+ )
+ ],
+)
+
+# Comment-specific Responses
+COMMENT_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Comment not found",
+ examples=[
+ OpenApiExample(
+ name="Comment Not Found",
+ value={"error": "Comment not found"},
+ )
+ ],
+)
+
+# Link-specific Responses
+LINK_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Link not found",
+ examples=[
+ OpenApiExample(
+ name="Link Not Found",
+ value={"error": "Link not found"},
+ )
+ ],
+)
+
+# Attachment-specific Responses
+ATTACHMENT_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Attachment not found",
+ examples=[
+ OpenApiExample(
+ name="Attachment Not Found",
+ value={"error": "Attachment not found"},
+ )
+ ],
+)
+
+# Search-specific Responses
+BAD_SEARCH_REQUEST_RESPONSE = OpenApiResponse(
+ description="Bad request - invalid search parameters",
+ examples=[
+ OpenApiExample(
+ name="Bad Search Request",
+ value={"error": "Invalid search parameters"},
+ )
+ ],
+)
+
+
+# Pagination Response Templates
+def create_paginated_response(
+ item_schema,
+ schema_name,
+ description="Paginated results",
+ example_name="Paginated Response",
+):
+ """Create a paginated response with the specified item schema"""
+
+ return OpenApiResponse(
+ description=description,
+ response=inline_serializer(
+ name=schema_name,
+ fields={
+ "grouped_by": serializers.CharField(allow_null=True),
+ "sub_grouped_by": serializers.CharField(allow_null=True),
+ "total_count": serializers.IntegerField(),
+ "next_cursor": serializers.CharField(),
+ "prev_cursor": serializers.CharField(),
+ "next_page_results": serializers.BooleanField(),
+ "prev_page_results": serializers.BooleanField(),
+ "count": serializers.IntegerField(),
+ "total_pages": serializers.IntegerField(),
+ "total_results": serializers.IntegerField(),
+ "extra_stats": serializers.CharField(allow_null=True),
+ "results": serializers.ListField(child=item_schema()),
+ },
+ ),
+ examples=[
+ OpenApiExample(
+ name=example_name,
+ value={
+ "grouped_by": "state",
+ "sub_grouped_by": "priority",
+ "total_count": 150,
+ "next_cursor": "20:1:0",
+ "prev_cursor": "20:0:0",
+ "next_page_results": True,
+ "prev_page_results": False,
+ "count": 20,
+ "total_pages": 8,
+ "total_results": 150,
+ "extra_stats": None,
+ "results": [get_sample_for_schema(schema_name)],
+ },
+ summary=example_name,
+ )
+ ],
+ )
+
+
+# Asset-specific Responses
+PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse(
+ description="Presigned URL generated successfully"
+)
+
+GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse(
+ description="Presigned URL generated successfully",
+ examples=[
+ OpenApiExample(
+ name="Generic Asset Upload Response",
+ value={
+ "upload_data": {
+ "url": "https://s3.amazonaws.com/bucket-name",
+ "fields": {
+ "key": "workspace-id/uuid-filename.pdf",
+ "AWSAccessKeyId": "AKIA...",
+ "policy": "eyJ...",
+ "signature": "abc123...",
+ },
+ },
+ "asset_id": "550e8400-e29b-41d4-a716-446655440000",
+ "asset_url": "https://cdn.example.com/workspace-id/uuid-filename.pdf",
+ },
+ )
+ ],
+)
+
+GENERIC_ASSET_VALIDATION_ERROR_RESPONSE = OpenApiResponse(
+ description="Validation error",
+ examples=[
+ OpenApiExample(
+ name="Missing required fields",
+ value={"error": "Name and size are required fields.", "status": False},
+ ),
+ OpenApiExample(
+ name="Invalid file type",
+ value={"error": "Invalid file type.", "status": False},
+ ),
+ ],
+)
+
+ASSET_CONFLICT_RESPONSE = OpenApiResponse(
+ description="Asset with same external ID already exists",
+ examples=[
+ OpenApiExample(
+ name="Duplicate external asset",
+ value={
+ "message": "Asset with same external id and source already exists",
+ "asset_id": "550e8400-e29b-41d4-a716-446655440000",
+ "asset_url": "https://cdn.example.com/existing-file.pdf",
+ },
+ )
+ ],
+)
+
+ASSET_DOWNLOAD_SUCCESS_RESPONSE = OpenApiResponse(
+ description="Presigned download URL generated successfully",
+ examples=[
+ OpenApiExample(
+ name="Asset Download Response",
+ value={
+ "asset_id": "550e8400-e29b-41d4-a716-446655440000",
+ "asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url",
+ "asset_name": "document.pdf",
+ "asset_type": "application/pdf",
+ },
+ )
+ ],
+)
+
+ASSET_DOWNLOAD_ERROR_RESPONSE = OpenApiResponse(
+ description="Bad request",
+ examples=[
+ OpenApiExample(
+ name="Asset not uploaded", value={"error": "Asset not yet uploaded"}
+ ),
+ ],
+)
+
+ASSET_UPDATED_RESPONSE = OpenApiResponse(description="Asset updated successfully")
+
+ASSET_DELETED_RESPONSE = OpenApiResponse(description="Asset deleted successfully")
+
+ASSET_NOT_FOUND_RESPONSE = OpenApiResponse(
+ description="Asset not found",
+ examples=[
+ OpenApiExample(name="Asset not found", value={"error": "Asset not found"})
+ ],
+)
diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt
index 3a12b9bf6..78e9efed3 100644
--- a/apps/api/requirements/base.txt
+++ b/apps/api/requirements/base.txt
@@ -65,3 +65,5 @@ opentelemetry-api==1.28.1
opentelemetry-sdk==1.28.1
opentelemetry-instrumentation-django==0.49b1
opentelemetry-exporter-otlp==1.28.1
+# OpenAPI Specification
+drf-spectacular==0.28.0
\ No newline at end of file
From 57479f45544d654370e7b8b1ecc25587942de583 Mon Sep 17 00:00:00 2001
From: Aaron Heckmann
Date: Thu, 24 Jul 2025 13:14:51 -0700
Subject: [PATCH 002/178] fix: lint (#7433)
* chore: fix lint
* fix: constants check:lint command
* chore(lint): permit unused vars which begin w/ _
* chore: rm dead code
* fix(lint): more lint fixes to constants pkg
* fix(lint): lint the live server
- fix lint issues
* chore: improve clean script
* fix(lint): more lint
* chore: set live server process title
* chore(deps): update to turbo@2.5.5
* chore(live): target node22
* fix(dev): add missing ui pkg dependency
* fix(dev): lint decorators
* fix(dev): lint space app
* fix(dev): address lint issues in types pkg
* fix(dev): lint editor pkg
* chore(dev): moar lint
* fix(dev): live server exit code
* chore: address PR feedback
* fix(lint): better TPageExtended type
* chore: refactor
* chore: revert most live server changes
* fix: few more lint issues
* chore: enable ci checks
Ensure we can build + confirm that lint is not getting worse.
* chore: address PR feedback
* fix: web lint warning added to package.json
* fix: ci:lint command
---------
Co-authored-by: sriram veeraghanta
---
.github/workflows/build-test-pull-request.yml | 95 -----------
.../workflows/pull-request-build-lint-api.yml | 29 ++++
.../pull-request-build-lint-web-apps.yml | 42 +++++
.nvmrc | 1 +
.../authentication/gitlab/page.tsx | 8 +-
.../authentication/google/page.tsx | 8 +-
apps/admin/package.json | 2 +-
apps/live/package.json | 9 +-
apps/live/src/ce/types/common.d.ts | 2 +-
apps/live/src/core/helpers/logger.ts | 1 +
apps/live/src/core/hocuspocus-server.ts | 2 +-
apps/live/src/core/services/api.service.ts | 2 +-
.../app/[workspaceSlug]/[projectId]/page.ts | 8 +-
.../account/auth-forms/password.tsx | 4 +-
.../account/oauth/github-button.tsx | 2 +-
.../account/oauth/gitlab-button.tsx | 2 +-
.../account/oauth/google-button.tsx | 2 +-
.../filters/applied-filters/filters-list.tsx | 10 +-
.../filters/applied-filters/priority.tsx | 6 +-
.../issues/filters/applied-filters/root.tsx | 22 ++-
.../issue-layouts/kanban/base-kanban-root.tsx | 2 +-
.../issue-layouts/list/base-list-root.tsx | 2 +-
.../core/hooks/use-intersection-observer.tsx | 4 +-
apps/space/core/store/instance.store.ts | 2 +-
apps/space/core/store/issue-detail.store.ts | 16 +-
apps/space/core/store/profile.store.ts | 4 +-
apps/space/helpers/date-time.helper.ts | 2 +-
apps/space/helpers/string.helper.ts | 2 +-
apps/space/package.json | 4 +-
apps/web/.eslintignore | 3 +-
.../cycles/(list)/mobile-header.tsx | 4 +-
.../settings/account/security/page.tsx | 12 +-
.../app/(all)/accounts/set-password/page.tsx | 10 +-
apps/web/app/(all)/create-workspace/page.tsx | 4 +-
apps/web/app/(all)/onboarding/page.tsx | 6 +-
apps/web/app/(all)/profile/security/page.tsx | 8 +-
.../issue-layouts/kanban/kanban-group.tsx | 2 +-
.../issues/issue-layouts/list/list-group.tsx | 2 +-
.../issues/issue-layouts/quick-add/root.tsx | 2 +-
.../workspace/create-workspace-form.tsx | 2 +-
apps/web/package.json | 2 +-
package.json | 10 +-
.../constants/{.eslintrc.js => .eslintrc.cjs} | 0
packages/constants/src/analytics/common.ts | 4 +-
packages/constants/src/auth.ts | 4 +-
packages/constants/src/issue/common.ts | 1 +
packages/constants/src/issue/layout.ts | 5 +-
packages/constants/src/settings.ts | 2 +-
packages/decorators/src/controller.ts | 2 +-
packages/decorators/src/rest.ts | 13 +-
.../decorators/src/websocket-controller.ts | 38 ++---
packages/decorators/src/websocket.ts | 6 +-
packages/decorators/tsconfig.json | 2 +-
packages/editor/package.json | 2 +-
.../components/editors/editor-content.tsx | 4 +-
.../core/components/links/link-edit-view.tsx | 16 +-
.../src/core/components/links/link-view.tsx | 2 +-
.../core/extensions/code/lowlight-plugin.ts | 8 +-
.../custom-image/components/upload-status.tsx | 2 +-
.../src/core/extensions/mentions/utils.ts | 4 +-
.../extensions/table/table/table-controls.ts | 2 +-
.../extensions/table/table/table-view.tsx | 8 +-
.../editor/src/core/helpers/scroll-to-node.ts | 7 +-
packages/editor/src/core/hooks/use-editor.ts | 2 +-
packages/editor/src/core/types/editor.ts | 3 +-
packages/editor/src/core/types/mention.ts | 2 +-
.../core/types/slash-commands-suggestion.ts | 2 +-
packages/editor/tailwind.config.js | 4 +-
packages/eslint-config/library.js | 9 +-
packages/eslint-config/next.js | 9 +-
packages/eslint-config/server.js | 25 ++-
packages/hooks/package.json | 2 +-
packages/i18n/src/hooks/use-translation.ts | 2 +-
packages/i18n/src/store/index.ts | 11 +-
packages/logger/package.json | 7 +
packages/logger/src/config.ts | 2 +-
packages/propel/package.json | 2 +-
packages/services/package.json | 2 +-
packages/shared-state/package.json | 2 +-
packages/types/package.json | 2 +-
packages/types/src/dashboard.ts | 2 +-
.../src/issues/activity/issue_activity.ts | 2 +-
.../src/issues/activity/issue_comment.ts | 8 +-
packages/types/src/issues/issue.ts | 2 +-
packages/types/src/module/modules.ts | 2 +-
packages/types/src/page/extended.ts | 2 +-
packages/types/src/users.ts | 2 +-
packages/types/src/workspace-notifications.ts | 2 +-
packages/ui/package.json | 3 +-
packages/ui/src/icons/priority-icon.tsx | 2 +-
packages/ui/src/sortable/sortable.stories.tsx | 12 +-
packages/ui/src/sortable/sortable.tsx | 2 +-
packages/utils/package.json | 2 +-
packages/utils/src/string.ts | 24 +--
yarn.lock | 159 ++----------------
95 files changed, 348 insertions(+), 460 deletions(-)
delete mode 100644 .github/workflows/build-test-pull-request.yml
create mode 100644 .github/workflows/pull-request-build-lint-api.yml
create mode 100644 .github/workflows/pull-request-build-lint-web-apps.yml
create mode 100644 .nvmrc
rename packages/constants/{.eslintrc.js => .eslintrc.cjs} (100%)
diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml
deleted file mode 100644
index acbd9d26e..000000000
--- a/.github/workflows/build-test-pull-request.yml
+++ /dev/null
@@ -1,95 +0,0 @@
-name: Build and Lint on Pull Request
-
-on:
- workflow_dispatch:
- pull_request:
- types: ["opened", "synchronize", "ready_for_review"]
-
-jobs:
- lint-server:
- if: github.event.pull_request.draft == false
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.x" # Specify the Python version you need
- - name: Install Pylint
- run: python -m pip install ruff
- - name: Install Server Dependencies
- run: cd apps/server && pip install -r requirements.txt
- - name: Lint apps/server
- run: ruff check --fix apps/server
-
- lint-admin:
- if: github.event.pull_request.draft == false
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20.x
- - run: yarn install
- - run: yarn lint --filter=admin
-
- lint-space:
- if: github.event.pull_request.draft == false
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20.x
- - run: yarn install
- - run: yarn lint --filter=space
-
- lint-web:
- if: github.event.pull_request.draft == false
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20.x
- - run: yarn install
- - run: yarn lint --filter=web
-
- build-admin:
- needs: lint-admin
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20.x
- - run: yarn install
- - run: yarn build --filter=admin
-
- build-space:
- needs: lint-space
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20.x
- - run: yarn install
- - run: yarn build --filter=space
-
- build-web:
- needs: lint-web
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 20.x
- - run: yarn install
- - run: yarn build --filter=web
diff --git a/.github/workflows/pull-request-build-lint-api.yml b/.github/workflows/pull-request-build-lint-api.yml
new file mode 100644
index 000000000..93619b03d
--- /dev/null
+++ b/.github/workflows/pull-request-build-lint-api.yml
@@ -0,0 +1,29 @@
+name: Build and lint API
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: ["preview"]
+ types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
+ paths:
+ - "apps/api/**"
+
+jobs:
+ lint-api:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ if: |
+ github.event.pull_request.draft == false &&
+ github.event.pull_request.requested_reviewers != null
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Install Pylint
+ run: python -m pip install ruff
+ - name: Install API Dependencies
+ run: cd apps/api && pip install -r requirements.txt
+ - name: Lint apps/api
+ run: ruff check --fix apps/api
diff --git a/.github/workflows/pull-request-build-lint-web-apps.yml b/.github/workflows/pull-request-build-lint-web-apps.yml
new file mode 100644
index 000000000..8afe78c49
--- /dev/null
+++ b/.github/workflows/pull-request-build-lint-web-apps.yml
@@ -0,0 +1,42 @@
+name: Build and lint web apps
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: ["preview"]
+ types: ["opened", "synchronize", "ready_for_review", "review_requested", "reopened"]
+ paths:
+ - "**.tsx?"
+ - "**.jsx?"
+ - "**.css"
+ - "**.json"
+ - "!apps/api/**"
+
+jobs:
+ build-and-lint:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ if: |
+ github.event.pull_request.draft == false &&
+ github.event.pull_request.requested_reviewers != null
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version-file: ".nvmrc"
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Build web apps
+ run: yarn run build
+
+ - name: Lint web apps
+ run: yarn run ci:lint
+
+
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..deed13c01
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+lts/jod
diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx
index cdcfcd61b..f0b464acb 100644
--- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx
+++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx
@@ -66,9 +66,11 @@ const InstanceGitlabAuthenticationPage = observer(() => {
{
- Boolean(parseInt(enableGitlabConfig)) === true
- ? updateConfig("IS_GITLAB_ENABLED", "0")
- : updateConfig("IS_GITLAB_ENABLED", "1");
+ if (Boolean(parseInt(enableGitlabConfig)) === true) {
+ updateConfig("IS_GITLAB_ENABLED", "0");
+ } else {
+ updateConfig("IS_GITLAB_ENABLED", "1");
+ }
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx
index 6ac4ea09b..7cf42cb57 100644
--- a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx
+++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx
@@ -67,9 +67,11 @@ const InstanceGoogleAuthenticationPage = observer(() => {
{
- Boolean(parseInt(enableGoogleConfig)) === true
- ? updateConfig("IS_GOOGLE_ENABLED", "0")
- : updateConfig("IS_GOOGLE_ENABLED", "1");
+ if (Boolean(parseInt(enableGoogleConfig)) === true) {
+ updateConfig("IS_GOOGLE_ENABLED", "0");
+ } else {
+ updateConfig("IS_GOOGLE_ENABLED", "1");
+ }
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 4e7cea25d..f4c293977 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -10,7 +10,7 @@
"preview": "next build && next start",
"start": "next start",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 19",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/apps/live/package.json b/apps/live/package.json
index d8d1de6ad..f0bb5158e 100644
--- a/apps/live/package.json
+++ b/apps/live/package.json
@@ -10,7 +10,7 @@
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/server.js'",
"build": "tsc --noEmit && tsup",
"start": "node --env-file=.env dist/server.js",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 10",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
@@ -51,14 +51,15 @@
"@types/compression": "1.8.1",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
- "@types/express": "^4.17.21",
- "@types/express-ws": "^3.0.4",
+ "@types/express": "^4.17.23",
+ "@types/express-ws": "^3.0.5",
"@types/node": "^20.14.9",
"@types/pino-http": "^5.8.4",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
- "typescript": "5.8.3"
+ "typescript": "5.8.3",
+ "ws": "^8.18.3"
}
}
diff --git a/apps/live/src/ce/types/common.d.ts b/apps/live/src/ce/types/common.d.ts
index 2f51c6ff5..ffc9e1053 100644
--- a/apps/live/src/ce/types/common.d.ts
+++ b/apps/live/src/ce/types/common.d.ts
@@ -1 +1 @@
-export type TAdditionalDocumentTypes = {};
+export type TAdditionalDocumentTypes = never;
diff --git a/apps/live/src/core/helpers/logger.ts b/apps/live/src/core/helpers/logger.ts
index 07efaea6e..a821797fa 100644
--- a/apps/live/src/core/helpers/logger.ts
+++ b/apps/live/src/core/helpers/logger.ts
@@ -9,6 +9,7 @@ const transport = {
};
const hooks = {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
logMethod(inputArgs: any, method: any): any {
if (inputArgs.length >= 2) {
const arg1 = inputArgs.shift();
diff --git a/apps/live/src/core/hocuspocus-server.ts b/apps/live/src/core/hocuspocus-server.ts
index 072d45cbc..ceea6548b 100644
--- a/apps/live/src/core/hocuspocus-server.ts
+++ b/apps/live/src/core/hocuspocus-server.ts
@@ -52,7 +52,7 @@ export const getHocusPocusServer = async () => {
cookie,
userId,
});
- } catch (error) {
+ } catch (_error) {
throw Error("Authentication unsuccessful!");
}
},
diff --git a/apps/live/src/core/services/api.service.ts b/apps/live/src/core/services/api.service.ts
index 1aa5cf9e5..abc53c111 100644
--- a/apps/live/src/core/services/api.service.ts
+++ b/apps/live/src/core/services/api.service.ts
@@ -36,7 +36,7 @@ export abstract class APIService {
return this.axiosInstance.patch(url, data, config);
}
- delete(url: string, data?: any, config = {}) {
+ delete(url: string, data?: Record | null | string, config = {}) {
return this.axiosInstance.delete(url, { data, ...config });
}
diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.ts b/apps/space/app/[workspaceSlug]/[projectId]/page.ts
index 5fbb835dc..0badbe64f 100644
--- a/apps/space/app/[workspaceSlug]/[projectId]/page.ts
+++ b/apps/space/app/[workspaceSlug]/[projectId]/page.ts
@@ -10,7 +10,7 @@ type Props = {
workspaceSlug: string;
projectId: string;
};
- searchParams: any;
+ searchParams: Record<"board" | "peekId", string | string[] | undefined>;
};
export default async function IssuesPage(props: Props) {
@@ -23,7 +23,7 @@ export default async function IssuesPage(props: Props) {
try {
response = await publishService.retrieveSettingsByProjectId(workspaceSlug, projectId);
} catch (error) {
- // redirect to 404 page on error
+ console.error("Error fetching project publish settings:", error);
notFound();
}
@@ -31,8 +31,8 @@ export default async function IssuesPage(props: Props) {
if (response?.entity_name === "project") {
url = `/issues/${response?.anchor}`;
const params = new URLSearchParams();
- if (board) params.append("board", board);
- if (peekId) params.append("peekId", peekId);
+ if (board) params.append("board", String(board));
+ if (peekId) params.append("peekId", String(peekId));
if (params.toString()) url += `?${params.toString()}`;
redirect(url);
} else {
diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx
index 08ff7f142..08db783e3 100644
--- a/apps/space/core/components/account/auth-forms/password.tsx
+++ b/apps/space/core/components/account/auth-forms/password.tsx
@@ -109,7 +109,9 @@ export const AuthPasswordForm: React.FC = observer((props: Props) => {
onSubmit={async (event) => {
event.preventDefault();
await handleCSRFToken();
- formRef.current && formRef.current.submit();
+ if (formRef.current) {
+ formRef.current.submit();
+ }
setIsSubmitting(true);
}}
onError={() => setIsSubmitting(false)}
diff --git a/apps/space/core/components/account/oauth/github-button.tsx b/apps/space/core/components/account/oauth/github-button.tsx
index eaa83ebbb..b37cea956 100644
--- a/apps/space/core/components/account/oauth/github-button.tsx
+++ b/apps/space/core/components/account/oauth/github-button.tsx
@@ -1,6 +1,6 @@
import { FC } from "react";
-import { useSearchParams } from "next/navigation";
import Image from "next/image";
+import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
// images
diff --git a/apps/space/core/components/account/oauth/gitlab-button.tsx b/apps/space/core/components/account/oauth/gitlab-button.tsx
index ba1880ae9..40834b3cf 100644
--- a/apps/space/core/components/account/oauth/gitlab-button.tsx
+++ b/apps/space/core/components/account/oauth/gitlab-button.tsx
@@ -1,6 +1,6 @@
import { FC } from "react";
-import { useSearchParams } from "next/navigation";
import Image from "next/image";
+import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
// images
diff --git a/apps/space/core/components/account/oauth/google-button.tsx b/apps/space/core/components/account/oauth/google-button.tsx
index dc28bdae4..1c699674f 100644
--- a/apps/space/core/components/account/oauth/google-button.tsx
+++ b/apps/space/core/components/account/oauth/google-button.tsx
@@ -1,6 +1,6 @@
import { FC } from "react";
-import { useSearchParams } from "next/navigation";
import Image from "next/image";
+import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
import { API_BASE_URL } from "@plane/constants";
// images
diff --git a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx
index 65ac29602..d9d03de15 100644
--- a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx
+++ b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx
@@ -39,18 +39,10 @@ export const AppliedFiltersList: React.FC = observer((props) => {
{filterKey === "priority" && (
handleRemoveFilter("priority", val)}
- values={filterValue ?? []}
+ values={(filterValue ?? []) as TFilters["priority"]}
/>
)}
- {/* {filterKey === "labels" && labels && (
- handleRemoveFilter("labels", val)}
- labels={labels}
- values={value}
- />
- )} */}
-
{filterKey === "state" && (
handleRemoveFilter("state", val)}
diff --git a/apps/space/core/components/issues/filters/applied-filters/priority.tsx b/apps/space/core/components/issues/filters/applied-filters/priority.tsx
index 6fdf5c653..33af39e21 100644
--- a/apps/space/core/components/issues/filters/applied-filters/priority.tsx
+++ b/apps/space/core/components/issues/filters/applied-filters/priority.tsx
@@ -1,11 +1,11 @@
"use client";
import { X } from "lucide-react";
-import { PriorityIcon } from "@plane/ui";
+import { PriorityIcon, type TIssuePriorities } from "@plane/ui";
type Props = {
handleRemove: (val: string) => void;
- values: string[];
+ values: TIssuePriorities[];
};
export const AppliedPriorityFilters: React.FC = (props) => {
@@ -17,7 +17,7 @@ export const AppliedPriorityFilters: React.FC = (props) => {
values.length > 0 &&
values.map((priority) => (
-
+
{priority}
diff --git a/apps/web/app/(all)/onboarding/page.tsx b/apps/web/app/(all)/onboarding/page.tsx
index 7e7d15a10..45ef3c5dd 100644
--- a/apps/web/app/(all)/onboarding/page.tsx
+++ b/apps/web/app/(all)/onboarding/page.tsx
@@ -42,10 +42,14 @@ const OnboardingPage = observer(() => {
// computed values
const workspacesList = Object.values(workspaces ?? {});
+
// fetching workspaces list
const { isLoading: workspaceListLoader } = useSWR(USER_WORKSPACES_LIST, () => {
- user?.id && fetchWorkspaces();
+ if (user?.id) {
+ fetchWorkspaces();
+ }
});
+
// fetching user workspace invitations
const { isLoading: invitationsLoader, data: invitations } = useSWR(
`USER_WORKSPACE_INVITATIONS_LIST_${user?.id}`,
diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/app/(all)/profile/security/page.tsx
index eec52f994..017be3b52 100644
--- a/apps/web/app/(all)/profile/security/page.tsx
+++ b/apps/web/app/(all)/profile/security/page.tsx
@@ -14,7 +14,7 @@ import { PasswordStrengthMeter } from "@/components/account/password-strength-me
import { PageHead } from "@/components/core/page-title";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// helpers
-import { authErrorHandler } from "@/helpers/authentication.helper";
+import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper";
// hooks
import { useUser } from "@/hooks/store";
// services
@@ -87,8 +87,10 @@ const SecurityPage = observer(() => {
title: t("auth.common.password.toast.change_password.success.title"),
message: t("auth.common.password.toast.change_password.success.message"),
});
- } catch (err: any) {
- const errorInfo = authErrorHandler(err.error_code?.toString());
+ } catch (error: unknown) {
+ const err = error as Error & { error_code?: string };
+ const code = err.error_code?.toString();
+ const errorInfo = code ? authErrorHandler(code as EAuthenticationErrorCodes) : undefined;
setToast({
type: TOAST_TYPE.ERROR,
title: errorInfo?.title ?? t("auth.common.password.toast.error.title"),
diff --git a/apps/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx b/apps/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx
index a95109ad5..ed834eea7 100644
--- a/apps/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx
+++ b/apps/web/core/components/issues/issue-layouts/kanban/kanban-group.tsx
@@ -6,7 +6,7 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
// plane constants
-import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
+import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
//types
diff --git a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx
index 5d43fec4d..577a52adc 100644
--- a/apps/web/core/components/issues/issue-layouts/list/list-group.tsx
+++ b/apps/web/core/components/issues/issue-layouts/list/list-group.tsx
@@ -5,7 +5,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
// plane constants
-import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
+import { DRAG_ALLOWED_GROUPS } from "@plane/constants";
// plane i18n
import { useTranslation } from "@plane/i18n";
// plane ui
diff --git a/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx b/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx
index 701083663..066db2088 100644
--- a/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx
+++ b/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx
@@ -6,7 +6,7 @@ import { useParams } from "next/navigation";
import { useForm, UseFormRegister } from "react-hook-form";
import { PlusIcon } from "lucide-react";
// plane constants
-import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
+import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
import { IProject, TIssue, EIssueLayoutTypes } from "@plane/types";
diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx
index d7d54c661..f760cd1d9 100644
--- a/apps/web/core/components/workspace/create-workspace-form.tsx
+++ b/apps/web/core/components/workspace/create-workspace-form.tsx
@@ -29,7 +29,7 @@ type Props = {
slug: string;
organization_size: string;
};
- setDefaultValues: Dispatch>;
+ setDefaultValues: Dispatch>>;
secondaryButton?: React.ReactNode;
primaryButtonText?: {
loading: string;
diff --git a/apps/web/package.json b/apps/web/package.json
index 62f455a96..336c59802 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,7 +8,7 @@
"build": "next build",
"start": "next start",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 821",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/package.json b/package.json
index 2aeeeb533..fd58a5887 100644
--- a/package.json
+++ b/package.json
@@ -13,14 +13,15 @@
"build": "turbo run build",
"dev": "turbo run dev --concurrency=18",
"start": "turbo run start",
- "clean": "turbo run clean",
+ "clean": "turbo run clean && rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
"fix": "turbo run fix",
- "check": "turbo run check"
+ "check": "turbo run check",
+ "ci:lint": "turbo run check:lint"
},
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
- "turbo": "^2.5.4"
+ "turbo": "^2.5.5"
},
"resolutions": {
"brace-expansion": "2.0.2",
@@ -30,7 +31,8 @@
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0",
"tar-fs": "3.0.9",
- "prosemirror-view": "1.40.0"
+ "prosemirror-view": "1.40.0",
+ "@types/express": "4.17.23"
},
"packageManager": "yarn@1.22.22"
}
diff --git a/packages/constants/.eslintrc.js b/packages/constants/.eslintrc.cjs
similarity index 100%
rename from packages/constants/.eslintrc.js
rename to packages/constants/.eslintrc.cjs
diff --git a/packages/constants/src/analytics/common.ts b/packages/constants/src/analytics/common.ts
index 4192cef8c..001391314 100644
--- a/packages/constants/src/analytics/common.ts
+++ b/packages/constants/src/analytics/common.ts
@@ -6,7 +6,9 @@ export interface IInsightField {
i18nProps?: {
entity?: string;
entityPlural?: string;
- [key: string]: any;
+ prefix?: string;
+ suffix?: string;
+ [key: string]: unknown;
};
}
diff --git a/packages/constants/src/auth.ts b/packages/constants/src/auth.ts
index 16ee1f3e7..953375c0e 100644
--- a/packages/constants/src/auth.ts
+++ b/packages/constants/src/auth.ts
@@ -70,7 +70,7 @@ export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthErrorCodes;
title: string;
- message: any;
+ message: string | React.ReactNode;
};
export enum EAdminAuthErrorCodes {
@@ -90,7 +90,7 @@ export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
- message: any;
+ message: string | React.ReactNode;
};
export enum EAuthErrorCodes {
diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts
index 0f47e767c..b6fa8368a 100644
--- a/packages/constants/src/issue/common.ts
+++ b/packages/constants/src/issue/common.ts
@@ -29,6 +29,7 @@ export enum EIssueGroupByToServerOptions {
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
"team_project" = "project_id",
}
diff --git a/packages/constants/src/issue/layout.ts b/packages/constants/src/issue/layout.ts
index 9f983aee4..88b740606 100644
--- a/packages/constants/src/issue/layout.ts
+++ b/packages/constants/src/issue/layout.ts
@@ -14,7 +14,7 @@ export type TIssueLayoutMap = Record<
export const SITES_ISSUE_LAYOUTS: {
key: TIssueLayout;
titleTranslationKey: string;
- icon: any;
+ icon: string;
}[] = [
{
key: "list",
@@ -26,9 +26,6 @@ export const SITES_ISSUE_LAYOUTS: {
icon: "Kanban",
titleTranslationKey: "issue.layouts.kanban",
},
- // { key: "calendar", title: "Calendar", icon: Calendar },
- // { key: "spreadsheet", title: "Spreadsheet", icon: Sheet },
- // { key: "gantt", title: "Gantt chart", icon: GanttChartSquare },
];
export const ISSUE_LAYOUT_MAP: TIssueLayoutMap = {
diff --git a/packages/constants/src/settings.ts b/packages/constants/src/settings.ts
index f42374dc7..2c55a6a2d 100644
--- a/packages/constants/src/settings.ts
+++ b/packages/constants/src/settings.ts
@@ -1,4 +1,4 @@
-import { PROFILE_SETTINGS } from ".";
+import { PROFILE_SETTINGS } from "./profile";
import { WORKSPACE_SETTINGS } from "./workspace";
export enum WORKSPACE_SETTINGS_CATEGORY {
diff --git a/packages/decorators/src/controller.ts b/packages/decorators/src/controller.ts
index a9185a848..60112658c 100644
--- a/packages/decorators/src/controller.ts
+++ b/packages/decorators/src/controller.ts
@@ -16,7 +16,7 @@ interface ControllerInstance {
}
interface ControllerConstructor {
- new (...args: any[]): ControllerInstance;
+ new (...args: unknown[]): ControllerInstance;
prototype: ControllerInstance;
}
diff --git a/packages/decorators/src/rest.ts b/packages/decorators/src/rest.ts
index e643e26a9..5a80a9f51 100644
--- a/packages/decorators/src/rest.ts
+++ b/packages/decorators/src/rest.ts
@@ -10,6 +10,7 @@ type RestMethod = "get" | "post" | "put" | "patch" | "delete";
* @returns
*/
export function Controller(baseRoute: string = ""): ClassDecorator {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
return function (target: Function) {
Reflect.defineMetadata("baseRoute", baseRoute, target);
};
@@ -24,11 +25,7 @@ function createHttpMethodDecorator(
method: RestMethod,
): (route: string) => MethodDecorator {
return function (route: string): MethodDecorator {
- return function (
- target: object,
- propertyKey: string | symbol,
- descriptor: PropertyDescriptor,
- ) {
+ return function (target: object, propertyKey: string | symbol) {
Reflect.defineMetadata("method", method, target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
@@ -48,11 +45,7 @@ export const Delete = createHttpMethodDecorator("delete");
* @returns
*/
export function Middleware(middleware: RequestHandler): MethodDecorator {
- return function (
- target: object,
- propertyKey: string | symbol,
- descriptor: PropertyDescriptor,
- ) {
+ return function (target: object, propertyKey: string | symbol) {
const middlewares =
Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
diff --git a/packages/decorators/src/websocket-controller.ts b/packages/decorators/src/websocket-controller.ts
index 85a018da0..776d2d7a3 100644
--- a/packages/decorators/src/websocket-controller.ts
+++ b/packages/decorators/src/websocket-controller.ts
@@ -7,7 +7,7 @@ interface ControllerInstance {
}
interface ControllerConstructor {
- new (...args: any[]): ControllerInstance;
+ new (...args: unknown[]): ControllerInstance;
prototype: ControllerInstance;
}
@@ -34,27 +34,23 @@ export function registerWebSocketControllers(
if (
typeof handler === "function" &&
- typeof (router as any).ws === "function"
+ "ws" in router &&
+ typeof router.ws === "function"
) {
- (router as any).ws(
- `${baseRoute}${route}`,
- (ws: WebSocket, req: Request) => {
- try {
- handler.call(instance, ws, req);
- } catch (error) {
- console.error(
- `WebSocket error in ${Controller.name}.${methodName}`,
- error,
- );
- ws.close(
- 1011,
- error instanceof Error
- ? error.message
- : "Internal server error",
- );
- }
- },
- );
+ router.ws(`${baseRoute}${route}`, (ws: WebSocket, req: Request) => {
+ try {
+ handler.call(instance, ws, req);
+ } catch (error) {
+ console.error(
+ `WebSocket error in ${Controller.name}.${methodName}`,
+ error,
+ );
+ ws.close(
+ 1011,
+ error instanceof Error ? error.message : "Internal server error",
+ );
+ }
+ });
}
}
});
diff --git a/packages/decorators/src/websocket.ts b/packages/decorators/src/websocket.ts
index 5b6b6a7b1..282df8970 100644
--- a/packages/decorators/src/websocket.ts
+++ b/packages/decorators/src/websocket.ts
@@ -6,11 +6,7 @@ import "reflect-metadata";
* @returns
*/
export function WebSocket(route: string): MethodDecorator {
- return function (
- target: object,
- propertyKey: string | symbol,
- descriptor: PropertyDescriptor,
- ) {
+ return function (target: object, propertyKey: string | symbol) {
Reflect.defineMetadata("method", "ws", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
diff --git a/packages/decorators/tsconfig.json b/packages/decorators/tsconfig.json
index 72638244b..0fd863c96 100644
--- a/packages/decorators/tsconfig.json
+++ b/packages/decorators/tsconfig.json
@@ -10,6 +10,6 @@
"@/*": ["./src/*"]
}
},
- "include": ["./src"],
+ "include": ["./src", "./*.ts"],
"exclude": ["dist", "build", "node_modules"]
}
diff --git a/packages/editor/package.json b/packages/editor/package.json
index b495b629b..0d9390bef 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -24,7 +24,7 @@
"scripts": {
"build": "tsc && tsup --minify",
"dev": "tsup --watch",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 30",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/editor/src/core/components/editors/editor-content.tsx b/packages/editor/src/core/components/editors/editor-content.tsx
index 8171d06d9..89ebde53b 100644
--- a/packages/editor/src/core/components/editors/editor-content.tsx
+++ b/packages/editor/src/core/components/editors/editor-content.tsx
@@ -9,11 +9,11 @@ interface EditorContentProps {
}
export const EditorContentWrapper: FC = (props) => {
- const { editor, children, id, tabIndex } = props;
+ const { editor, children, tabIndex, id } = props;
return (
editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
-
+
{children}
);
diff --git a/packages/editor/src/core/components/links/link-edit-view.tsx b/packages/editor/src/core/components/links/link-edit-view.tsx
index 1e9a62b0e..db6ac3f5c 100644
--- a/packages/editor/src/core/components/links/link-edit-view.tsx
+++ b/packages/editor/src/core/components/links/link-edit-view.tsx
@@ -43,6 +43,12 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
const [linkRemoved, setLinkRemoved] = useState(false);
const hasSubmitted = useRef(false);
+ const removeLink = useCallback(() => {
+ editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
+ setLinkRemoved(true);
+ closeLinkView();
+ }, [editor, from, to, closeLinkView]);
+
// Effects
useEffect(
() =>
@@ -56,7 +62,7 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
}
}
},
- [linkRemoved, initialUrl]
+ [removeLink, linkRemoved, initialUrl]
);
// Sync state with props
@@ -105,13 +111,7 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
}
return true;
- }, [editor, from, to, initialText, localText, localUrl]);
-
- const removeLink = useCallback(() => {
- editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
- setLinkRemoved(true);
- closeLinkView();
- }, [editor, from, to, closeLinkView]);
+ }, [linkRemoved, positionRef, editor, from, to, initialText, localText, localUrl]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
diff --git a/packages/editor/src/core/components/links/link-view.tsx b/packages/editor/src/core/components/links/link-view.tsx
index 699f94e40..05d430df3 100644
--- a/packages/editor/src/core/components/links/link-view.tsx
+++ b/packages/editor/src/core/components/links/link-view.tsx
@@ -28,7 +28,7 @@ export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
setCurrentView("LinkPreview");
setPrevFrom(props.from);
}
- }, []);
+ }, [prevFrom, props.from]);
return (
<>
diff --git a/packages/editor/src/core/extensions/code/lowlight-plugin.ts b/packages/editor/src/core/extensions/code/lowlight-plugin.ts
index 0b8ed71ad..f5bbde612 100644
--- a/packages/editor/src/core/extensions/code/lowlight-plugin.ts
+++ b/packages/editor/src/core/extensions/code/lowlight-plugin.ts
@@ -118,18 +118,18 @@ export function LowlightPlugin({
transaction.steps.some(
(step) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
+ // @ts-expect-error
step.from !== undefined &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
+ // @ts-expect-error
step.to !== undefined &&
oldNodes.some(
(node) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
+ // @ts-expect-error
node.pos >= step.from &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
+ // @ts-expect-error
node.pos + node.node.nodeSize <= step.to
)
))
diff --git a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx
index f88c69c6f..466053650 100644
--- a/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx
+++ b/packages/editor/src/core/extensions/custom-image/components/upload-status.tsx
@@ -52,7 +52,7 @@ export const ImageUploadStatus: React.FC = (props) => {
cancelAnimationFrame(animationFrameRef.current);
}
};
- }, [uploadStatus]);
+ }, [displayStatus, uploadStatus]);
if (uploadStatus === undefined) return null;
diff --git a/packages/editor/src/core/extensions/mentions/utils.ts b/packages/editor/src/core/extensions/mentions/utils.ts
index 5fa356059..b788d84cc 100644
--- a/packages/editor/src/core/extensions/mentions/utils.ts
+++ b/packages/editor/src/core/extensions/mentions/utils.ts
@@ -3,13 +3,13 @@ import { ReactRenderer } from "@tiptap/react";
import { SuggestionOptions } from "@tiptap/suggestion";
import tippy, { Instance } from "tippy.js";
// helpers
+import { CORE_EXTENSIONS } from "@/constants/extension";
+import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { CommandListInstance } from "@/helpers/tippy";
// types
import { TMentionHandler } from "@/types";
// local components
import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown";
-import { getExtensionStorage } from "@/helpers/get-extension-storage";
-import { CORE_EXTENSIONS } from "@/constants/extension";
export const renderMentionsDropdown =
(props: Pick): SuggestionOptions["render"] =>
diff --git a/packages/editor/src/core/extensions/table/table/table-controls.ts b/packages/editor/src/core/extensions/table/table/table-controls.ts
index d499b1b6a..5cd3506d3 100644
--- a/packages/editor/src/core/extensions/table/table/table-controls.ts
+++ b/packages/editor/src/core/extensions/table/table/table-controls.ts
@@ -18,7 +18,7 @@ export function tableControls() {
},
},
props: {
- handleTripleClickOn(view, pos, node, nodePos, event, direct) {
+ handleTripleClickOn(view, pos, node, nodePos, event) {
if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) {
event.preventDefault();
const $pos = view.state.doc.resolve(pos);
diff --git a/packages/editor/src/core/extensions/table/table/table-view.tsx b/packages/editor/src/core/extensions/table/table/table-view.tsx
index 2ccdc3c6d..c4e723f0e 100644
--- a/packages/editor/src/core/extensions/table/table/table-view.tsx
+++ b/packages/editor/src/core/extensions/table/table/table-view.tsx
@@ -5,9 +5,7 @@ import { Decoration, NodeView } from "@tiptap/pm/view";
import { h } from "jsx-dom-cjs";
import { icons } from "src/core/extensions/table/table/icons";
import tippy, { Instance, Props } from "tippy.js";
-// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
-// local imports
import { isCellSelection } from "./utilities/helpers";
type ToolboxItem = {
@@ -148,7 +146,7 @@ const columnsToolboxItems: ToolboxItem[] = [
{
label: "Pick color",
icon: "", // No icon needed for color picker
- action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
+ action: (_args: unknown) => { }, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete column",
@@ -176,7 +174,7 @@ const rowsToolboxItems: ToolboxItem[] = [
{
label: "Pick color",
icon: "",
- action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
+ action: (_args: unknown) => { }, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete row",
@@ -217,7 +215,7 @@ function createToolbox({
h(
"div",
{ className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" },
- Object.entries(colors).map(([colorName, colorValue]) =>
+ Object.entries(colors).map(([_, colorValue]) =>
h("div", {
className: "grid place-items-center size-6 rounded cursor-pointer",
style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`,
diff --git a/packages/editor/src/core/helpers/scroll-to-node.ts b/packages/editor/src/core/helpers/scroll-to-node.ts
index 7e5aa0979..d74e7532c 100644
--- a/packages/editor/src/core/helpers/scroll-to-node.ts
+++ b/packages/editor/src/core/helpers/scroll-to-node.ts
@@ -32,8 +32,11 @@ function scrollToNode(editor: Editor, pos: number): void {
}
}
-// eslint-disable-next-line no-undef
-export function scrollToNodeViaDOMCoordinates(editor: Editor, pos: number, behavior?: ScrollBehavior): void {
+export function scrollToNodeViaDOMCoordinates(
+ editor: Editor,
+ pos: number,
+ behavior?: "auto" | "smooth" | "instant"
+): void {
const view = editor.view;
// Get the coordinates of the position
diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts
index 1979d46b1..1b82e0246 100644
--- a/packages/editor/src/core/hooks/use-editor.ts
+++ b/packages/editor/src/core/hooks/use-editor.ts
@@ -271,7 +271,7 @@ export const useEditor = (props: TEditorHookProps) => {
Y.applyUpdate(document, value);
},
}),
- [editor]
+ [editor, provider]
);
if (!editor) {
diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts
index b91d5fdee..d409b8828 100644
--- a/packages/editor/src/core/types/editor.ts
+++ b/packages/editor/src/core/types/editor.ts
@@ -111,8 +111,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
onStateChange: (callback: () => void) => () => void;
- // eslint-disable-next-line no-undef
- scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
+ scrollToNodeViaDOMCoordinates: (behavior?: "auto" | "smooth" | "instant", position?: number) => void;
setEditorValueAtCursorPosition: (content: string) => void;
setFocusAtPosition: (position: number) => void;
setProviderDocument: (value: Uint8Array) => void;
diff --git a/packages/editor/src/core/types/mention.ts b/packages/editor/src/core/types/mention.ts
index b7a65f8b4..529d64bde 100644
--- a/packages/editor/src/core/types/mention.ts
+++ b/packages/editor/src/core/types/mention.ts
@@ -1,5 +1,5 @@
// plane types
-import { IUserLite, TSearchEntities } from "@plane/types";
+import { TSearchEntities } from "@plane/types";
export type TMentionSuggestion = {
entity_identifier: string;
diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts
index d6dfae076..b5e806884 100644
--- a/packages/editor/src/core/types/slash-commands-suggestion.ts
+++ b/packages/editor/src/core/types/slash-commands-suggestion.ts
@@ -1,5 +1,5 @@
-import { CSSProperties } from "react";
import { Editor, Range } from "@tiptap/core";
+import { CSSProperties } from "react";
// types
import { TEditorCommands } from "@/types";
diff --git a/packages/editor/tailwind.config.js b/packages/editor/tailwind.config.js
index de93a571f..4e0b7684e 100644
--- a/packages/editor/tailwind.config.js
+++ b/packages/editor/tailwind.config.js
@@ -1,6 +1,6 @@
-const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
+import sharedConfig from "@plane/tailwind-config/tailwind.config.js";
-module.exports = {
+export default {
// prefix ui lib classes to avoid conflicting with the app
...sharedConfig,
};
diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js
index b868b35a4..30049d361 100644
--- a/packages/eslint-config/library.js
+++ b/packages/eslint-config/library.js
@@ -40,7 +40,14 @@ module.exports = {
"react/jsx-no-duplicate-props": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-expressions": "warn",
- "@typescript-eslint/no-unused-vars": ["warn"],
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-useless-empty-export": "error",
"@typescript-eslint/prefer-ts-expect-error": "warn",
diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js
index a2da66010..0685b8f81 100644
--- a/packages/eslint-config/next.js
+++ b/packages/eslint-config/next.js
@@ -39,7 +39,14 @@ module.exports = {
"react/jsx-no-duplicate-props": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-expressions": "warn",
- "@typescript-eslint/no-unused-vars": ["warn"],
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-useless-empty-export": "error",
"@typescript-eslint/prefer-ts-expect-error": "warn",
diff --git a/packages/eslint-config/server.js b/packages/eslint-config/server.js
index 824e25375..38a08d4ab 100644
--- a/packages/eslint-config/server.js
+++ b/packages/eslint-config/server.js
@@ -1,11 +1,34 @@
+const { resolve } = require("node:path");
+const project = resolve(process.cwd(), "tsconfig.json");
+
module.exports = {
- extends: ["eslint:recommended"],
+ extends: ["prettier", "plugin:@typescript-eslint/recommended"],
env: {
node: true,
es6: true,
},
+ plugins: ["@typescript-eslint", "import"],
+ settings: {
+ "import/resolver": {
+ typescript: {
+ project,
+ },
+ },
+ },
+ ignorePatterns: [".*.js", "node_modules/"],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
+ rules: {
+ "@typescript-eslint/no-explicit-any": "warn",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ],
+ }
};
diff --git a/packages/hooks/package.json b/packages/hooks/package.json
index 89f24ac38..5a9ed4f3d 100644
--- a/packages/hooks/package.json
+++ b/packages/hooks/package.json
@@ -13,7 +13,7 @@
"scripts": {
"build": "tsup --minify",
"dev": "tsup --watch",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 6",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/i18n/src/hooks/use-translation.ts b/packages/i18n/src/hooks/use-translation.ts
index c233ebcbf..fb9886422 100644
--- a/packages/i18n/src/hooks/use-translation.ts
+++ b/packages/i18n/src/hooks/use-translation.ts
@@ -5,7 +5,7 @@ import { TranslationContext } from "../context";
import { ILanguageOption, TLanguage } from "../types";
export type TTranslationStore = {
- t: (key: string, params?: Record) => string;
+ t: (key: string, params?: Record) => string;
currentLocale: TLanguage;
changeLanguage: (lng: TLanguage) => void;
languages: ILanguageOption[];
diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts
index c75d7b8a3..2873c87d1 100644
--- a/packages/i18n/src/store/index.ts
+++ b/packages/i18n/src/store/index.ts
@@ -136,7 +136,7 @@ export class TranslationStore {
* @param files - Array of file names to import (without .json extension)
* @returns Promise that resolves to merged translations
*/
- private async importAndMergeFiles(language: TLanguage, files: string[]): Promise {
+ private async importAndMergeFiles(language: TLanguage, files: string[]) {
try {
const importPromises = files.map((file) => import(`../locales/${language}/${file}.json`));
@@ -153,7 +153,7 @@ export class TranslationStore {
* @param language - The language to import the translations for
* @returns {Promise}
*/
- private async importLanguageFile(language: TLanguage): Promise {
+ private async importLanguageFile(language: TLanguage) {
const files = Object.values(ETranslationFiles);
return this.importAndMergeFiles(language, files);
}
@@ -176,7 +176,6 @@ export class TranslationStore {
/**
* Gets the IntlMessageFormat instance for the given key and locale
* Returns cached instance if available
- * Throws an error if the key is not found in the translations
*/
private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null {
const cacheKey = this.getCacheKey(key, locale);
@@ -188,10 +187,10 @@ export class TranslationStore {
// Get the message from the translations
const message = get(this.translations[locale], key);
- if (!message) return null;
+ if (typeof message !== "string") return null;
try {
- const formatter = new IntlMessageFormat(message as any, locale);
+ const formatter = new IntlMessageFormat(message, locale);
this.messageCache.set(cacheKey, formatter);
return formatter;
} catch (error) {
@@ -208,7 +207,7 @@ export class TranslationStore {
* @param params - The params to format the translation with
* @returns The translated string
*/
- t(key: string, params?: Record): string {
+ t(key: string, params?: Record): string {
try {
// Try current locale
let formatter = this.getMessageInstance(key, this.currentLocale);
diff --git a/packages/logger/package.json b/packages/logger/package.json
index 40a37bb08..6657f893a 100644
--- a/packages/logger/package.json
+++ b/packages/logger/package.json
@@ -7,6 +7,13 @@
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ }
+ },
"files": [
"dist/**"
],
diff --git a/packages/logger/src/config.ts b/packages/logger/src/config.ts
index fd918e59e..84bb98e5f 100644
--- a/packages/logger/src/config.ts
+++ b/packages/logger/src/config.ts
@@ -1,6 +1,6 @@
+import path from "path";
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
-import path from "path";
// Define log levels
const levels = {
diff --git a/packages/propel/package.json b/packages/propel/package.json
index 3cb12405c..63bd0f859 100644
--- a/packages/propel/package.json
+++ b/packages/propel/package.json
@@ -4,7 +4,7 @@
"private": true,
"license": "AGPL-3.0",
"scripts": {
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 7",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/services/package.json b/packages/services/package.json
index 916909696..5d5102b65 100644
--- a/packages/services/package.json
+++ b/packages/services/package.json
@@ -7,7 +7,7 @@
"scripts": {
"build": "tsc --noEmit && tsup --minify",
"dev": "tsup --watch",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 62",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json
index b9096eb6c..722194a27 100644
--- a/packages/shared-state/package.json
+++ b/packages/shared-state/package.json
@@ -7,7 +7,7 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 4",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/types/package.json b/packages/types/package.json
index 78eeeddd7..c836987a9 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -19,7 +19,7 @@
"scripts": {
"dev": "tsup --watch",
"build": "tsc --noEmit && tsup --minify",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 36",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/types/src/dashboard.ts b/packages/types/src/dashboard.ts
index ac2339d1c..b928430ba 100644
--- a/packages/types/src/dashboard.ts
+++ b/packages/types/src/dashboard.ts
@@ -1,8 +1,8 @@
import { EDurationFilters } from "./enums";
import { IIssueActivity, TIssuePriorities } from "./issues";
import { TIssue } from "./issues/issue";
-import { TStateGroups } from "./state";
import { TIssueRelationTypes } from "./issues/issue_relation";
+import { TStateGroups } from "./state";
export type TWidgetKeys =
| "overview_stats"
diff --git a/packages/types/src/issues/activity/issue_activity.ts b/packages/types/src/issues/activity/issue_activity.ts
index 15a37a4a7..bb71864e4 100644
--- a/packages/types/src/issues/activity/issue_activity.ts
+++ b/packages/types/src/issues/activity/issue_activity.ts
@@ -1,11 +1,11 @@
// local imports
+import { EInboxIssueSource } from "../../inbox";
import {
TIssueActivityWorkspaceDetail,
TIssueActivityProjectDetail,
TIssueActivityIssueDetail,
TIssueActivityUserDetail,
} from "./base";
-import { EInboxIssueSource } from "../../inbox";
export type TIssueActivity = {
id: string;
diff --git a/packages/types/src/issues/activity/issue_comment.ts b/packages/types/src/issues/activity/issue_comment.ts
index 1a4b557b5..9507a4934 100644
--- a/packages/types/src/issues/activity/issue_comment.ts
+++ b/packages/types/src/issues/activity/issue_comment.ts
@@ -1,13 +1,13 @@
+import { EIssueCommentAccessSpecifier } from "../../enums";
+import { TFileSignedURLResponse } from "../../file";
+import { IUserLite } from "../../users";
+import { IWorkspaceLite } from "../../workspace";
import {
TIssueActivityWorkspaceDetail,
TIssueActivityProjectDetail,
TIssueActivityIssueDetail,
TIssueActivityUserDetail,
} from "./base";
-import { EIssueCommentAccessSpecifier } from "../../enums";
-import { TFileSignedURLResponse } from "../../file";
-import { IUserLite } from "../../users";
-import { IWorkspaceLite } from "../../workspace";
export type TCommentReaction = {
id: string;
diff --git a/packages/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts
index 695b3ebc8..dbcb95218 100644
--- a/packages/types/src/issues/issue.ts
+++ b/packages/types/src/issues/issue.ts
@@ -1,9 +1,9 @@
import { TIssuePriorities } from "../issues";
+import { TIssuePublicComment } from "./activity/issue_comment";
import { TIssueAttachment } from "./issue_attachment";
import { TIssueLink } from "./issue_link";
import { TIssueReaction, IIssuePublicReaction, IPublicVote } from "./issue_reaction";
import { TIssueRelationTypes } from "./issue_relation";
-import { TIssuePublicComment } from "./activity/issue_comment";
export enum EIssueLayoutTypes {
LIST = "list",
diff --git a/packages/types/src/module/modules.ts b/packages/types/src/module/modules.ts
index 50e9e8af0..2078ae7d1 100644
--- a/packages/types/src/module/modules.ts
+++ b/packages/types/src/module/modules.ts
@@ -1,6 +1,6 @@
+import type { ILinkDetails } from "../issues";
import type { TIssue } from "../issues/issue";
import type { IIssueFilterOptions } from "../view-props";
-import type { ILinkDetails } from "../issues";
export type TModuleStatus = "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled";
diff --git a/packages/types/src/page/extended.ts b/packages/types/src/page/extended.ts
index 7edfae828..92f1b995d 100644
--- a/packages/types/src/page/extended.ts
+++ b/packages/types/src/page/extended.ts
@@ -1 +1 @@
-export type TPageExtended = {};
+export type TPageExtended = object;
diff --git a/packages/types/src/users.ts b/packages/types/src/users.ts
index 3258d14dc..29357e1a5 100644
--- a/packages/types/src/users.ts
+++ b/packages/types/src/users.ts
@@ -1,5 +1,5 @@
-import { IIssueActivity, TIssuePriorities, TStateGroups } from ".";
import { TUserPermissions } from "./enums";
+import { IIssueActivity, TIssuePriorities, TStateGroups } from ".";
/**
* @description The start of the week for the user
diff --git a/packages/types/src/workspace-notifications.ts b/packages/types/src/workspace-notifications.ts
index 0e0e15af1..b6856154e 100644
--- a/packages/types/src/workspace-notifications.ts
+++ b/packages/types/src/workspace-notifications.ts
@@ -1,5 +1,5 @@
-import type { IUserLite } from "./users";
import { ENotificationFilterType } from "./enums";
+import type { IUserLite } from "./users";
// filters
export type TNotificationFilter = {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index e688e800e..2d6c718e3 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -17,7 +17,7 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"postcss": "postcss styles/globals.css -o styles/output.css --watch",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 94",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
@@ -37,6 +37,7 @@
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/utils": "*",
+ "@plane/types": "*",
"@popperjs/core": "^2.11.8",
"@radix-ui/react-scroll-area": "^1.2.3",
"clsx": "^2.0.0",
diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx
index b2fb2d534..a6ea01329 100644
--- a/packages/ui/src/icons/priority-icon.tsx
+++ b/packages/ui/src/icons/priority-icon.tsx
@@ -2,7 +2,7 @@ import * as React from "react";
import { AlertCircle, Ban, SignalHigh, SignalLow, SignalMedium } from "lucide-react";
import { cn } from "../../helpers";
-type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
+export type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
interface IPriorityIcon {
className?: string;
diff --git a/packages/ui/src/sortable/sortable.stories.tsx b/packages/ui/src/sortable/sortable.stories.tsx
index b701af95d..bc834bfd0 100644
--- a/packages/ui/src/sortable/sortable.stories.tsx
+++ b/packages/ui/src/sortable/sortable.stories.tsx
@@ -8,7 +8,9 @@ const meta: Meta = {
};
export default meta;
-type Story = StoryObj;
+
+type StoryItem = { id: string; name: string };
+type Story = StoryObj>;
const data = [
{ id: "1", name: "John Doe" },
@@ -20,14 +22,12 @@ const data = [
export const Default: Story = {
args: {
data,
- render: (item: any) => (
+ render: (item: StoryItem) => (
//
{item.name}
//
),
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- onChange: (data) => console.log(data.map(({ id }: any) => id)),
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- keyExtractor: (item: any) => item.id,
+ onChange: (data) => console.log(data.map(({ id }) => id)),
+ keyExtractor: (item) => item.id,
},
};
diff --git a/packages/ui/src/sortable/sortable.tsx b/packages/ui/src/sortable/sortable.tsx
index b437f1df1..29198ed8b 100644
--- a/packages/ui/src/sortable/sortable.tsx
+++ b/packages/ui/src/sortable/sortable.tsx
@@ -1,5 +1,5 @@
-import React, { Fragment, useEffect, useMemo } from "react";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import React, { Fragment, useEffect, useMemo } from "react";
import { Draggable } from "./draggable";
type TEnhancedData = T & { __uuid__?: string };
diff --git a/packages/utils/package.json b/packages/utils/package.json
index d1c423120..2efb42092 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -13,7 +13,7 @@
"scripts": {
"build": "tsc --noEmit && tsup --minify",
"dev": "tsup --watch",
- "check:lint": "eslint . --max-warnings 0",
+ "check:lint": "eslint . --max-warnings 20",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts
index 7bcaa9db4..6ffb4ea04 100644
--- a/packages/utils/src/string.ts
+++ b/packages/utils/src/string.ts
@@ -102,26 +102,6 @@ export const getNumberCount = (number: number): string => {
return number.toString();
};
-/**
- * @description Converts object to URL query parameters string
- * @param {Object} obj - Object to convert
- * @returns {string} URL query parameters string
- * @example
- * objToQueryParams({ page: 1, search: "test" }) // returns "page=1&search=test"
- * objToQueryParams({ a: null, b: "test" }) // returns "b=test"
- */
-export const objToQueryParams = (obj: any) => {
- const params = new URLSearchParams();
-
- if (!obj) return params.toString();
-
- for (const [key, value] of Object.entries(obj)) {
- if (value !== undefined && value !== null) params.append(key, value as string);
- }
-
- return params.toString();
-};
-
/**
* @description: This function will capitalize the first letter of a string
* @param str String
@@ -268,7 +248,7 @@ export const substringMatch = (text: string, searchQuery: string): boolean => {
// Not all characters of searchQuery found in order
return false;
- } catch (error) {
+ } catch (_err) {
return false;
}
};
@@ -297,7 +277,7 @@ const fallbackCopyTextToClipboard = (text: string) => {
// FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
document.execCommand("copy");
- } catch (err) {
+ } catch (_err) {
// catch fallback error
}
diff --git a/yarn.lock b/yarn.lock
index eaab6478c..2613f6abc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -574,7 +574,7 @@
dependencies:
eslint-visitor-keys "^3.4.3"
-"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1":
+"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1":
version "4.12.1"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
@@ -2408,7 +2408,7 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
-"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
+"@types/express-serve-static-core@*":
version "5.0.7"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz#2fa94879c9d46b11a5df4c74ac75befd6b283de6"
integrity sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==
@@ -2428,7 +2428,7 @@
"@types/range-parser" "*"
"@types/send" "*"
-"@types/express-ws@^3.0.4":
+"@types/express-ws@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/express-ws/-/express-ws-3.0.5.tgz#5abf3dda3acb0a339351f089c31aca708f234c7c"
integrity sha512-lbWMjoHrm/v85j81UCmb/GNZFO3genxRYBW1Ob7rjRI+zxUBR+4tcFuOpKKsYQ1LYTYiy3356epLeYi/5zxUwA==
@@ -2437,16 +2437,7 @@
"@types/express-serve-static-core" "*"
"@types/ws" "*"
-"@types/express@*":
- version "5.0.3"
- resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956"
- integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==
- dependencies:
- "@types/body-parser" "*"
- "@types/express-serve-static-core" "^5.0.0"
- "@types/serve-static" "*"
-
-"@types/express@^4.17.21":
+"@types/express@*", "@types/express@4.17.23", "@types/express@^4.17.21", "@types/express@^4.17.23":
version "4.17.23"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.23.tgz#35af3193c640bfd4d7fe77191cd0ed411a433bef"
integrity sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==
@@ -2709,7 +2700,7 @@
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8"
integrity sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==
-"@types/semver@^7.3.12", "@types/semver@^7.3.4":
+"@types/semver@^7.3.4":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e"
integrity sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==
@@ -2778,7 +2769,7 @@
resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.5.tgz#8ce8623ed7a36e3a76d1c0b539708dfb2e859bc0"
integrity sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==
-"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin@^8.6.0":
+"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin@^8.36.0", "@typescript-eslint/eslint-plugin@^8.6.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz#332392883f936137cd6252c8eb236d298e514e70"
integrity sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==
@@ -2793,22 +2784,6 @@
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
-"@typescript-eslint/eslint-plugin@^5.48.2":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db"
- integrity sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==
- dependencies:
- "@eslint-community/regexpp" "^4.4.0"
- "@typescript-eslint/scope-manager" "5.62.0"
- "@typescript-eslint/type-utils" "5.62.0"
- "@typescript-eslint/utils" "5.62.0"
- debug "^4.3.4"
- graphemer "^1.4.0"
- ignore "^5.2.0"
- natural-compare-lite "^1.4.0"
- semver "^7.3.7"
- tsutils "^3.21.0"
-
"@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser@^8.6.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.37.0.tgz#b87f6b61e25ad5cc5bbf8baf809b8da889c89804"
@@ -2829,14 +2804,6 @@
"@typescript-eslint/types" "^8.37.0"
debug "^4.3.4"
-"@typescript-eslint/scope-manager@5.62.0":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c"
- integrity sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==
- dependencies:
- "@typescript-eslint/types" "5.62.0"
- "@typescript-eslint/visitor-keys" "5.62.0"
-
"@typescript-eslint/scope-manager@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz#a31a3c80ca2ef4ed58de13742debb692e7d4c0a4"
@@ -2850,16 +2817,6 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz#47a2760d265c6125f8e7864bc5c8537cad2bd053"
integrity sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==
-"@typescript-eslint/type-utils@5.62.0":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
- integrity sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==
- dependencies:
- "@typescript-eslint/typescript-estree" "5.62.0"
- "@typescript-eslint/utils" "5.62.0"
- debug "^4.3.4"
- tsutils "^3.21.0"
-
"@typescript-eslint/type-utils@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz#2a682e4c6ff5886712dad57e9787b5e417124507"
@@ -2871,29 +2828,11 @@
debug "^4.3.4"
ts-api-utils "^2.1.0"
-"@typescript-eslint/types@5.62.0":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
- integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
-
"@typescript-eslint/types@8.37.0", "@typescript-eslint/types@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.37.0.tgz#09517aa9625eb3c68941dde3ac8835740587b6ff"
integrity sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==
-"@typescript-eslint/typescript-estree@5.62.0":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
- integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==
- dependencies:
- "@typescript-eslint/types" "5.62.0"
- "@typescript-eslint/visitor-keys" "5.62.0"
- debug "^4.3.4"
- globby "^11.1.0"
- is-glob "^4.0.3"
- semver "^7.3.7"
- tsutils "^3.21.0"
-
"@typescript-eslint/typescript-estree@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz#a07e4574d8e6e4355a558f61323730c987f5fcbc"
@@ -2910,20 +2849,6 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
-"@typescript-eslint/utils@5.62.0":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
- integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==
- dependencies:
- "@eslint-community/eslint-utils" "^4.2.0"
- "@types/json-schema" "^7.0.9"
- "@types/semver" "^7.3.12"
- "@typescript-eslint/scope-manager" "5.62.0"
- "@typescript-eslint/types" "5.62.0"
- "@typescript-eslint/typescript-estree" "5.62.0"
- eslint-scope "^5.1.1"
- semver "^7.3.7"
-
"@typescript-eslint/utils@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.37.0.tgz#189ea59b2709f5d898614611f091a776751ee335"
@@ -2934,14 +2859,6 @@
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/typescript-estree" "8.37.0"
-"@typescript-eslint/visitor-keys@5.62.0":
- version "5.62.0"
- resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
- integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==
- dependencies:
- "@typescript-eslint/types" "5.62.0"
- eslint-visitor-keys "^3.3.0"
-
"@typescript-eslint/visitor-keys@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz#cdb6a6bd3e8d6dd69bd70c1bdda36e2d18737455"
@@ -3425,11 +3342,6 @@ array-includes@^3.1.6, array-includes@^3.1.8, array-includes@^3.1.9:
is-string "^1.1.1"
math-intrinsics "^1.1.0"
-array-union@^2.1.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
- integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
-
array.prototype.findlast@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904"
@@ -4643,13 +4555,6 @@ diff@^5.0.0:
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
-dir-glob@^3.0.1:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
- integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
- dependencies:
- path-type "^4.0.0"
-
dlv@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
@@ -5235,7 +5140,7 @@ eslint-plugin-turbo@1.13.4:
dependencies:
dotenv "16.0.3"
-eslint-scope@5.1.1, eslint-scope@^5.1.1:
+eslint-scope@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
@@ -5251,7 +5156,7 @@ eslint-scope@^7.2.2:
esrecurse "^4.3.0"
estraverse "^5.2.0"
-eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3:
+eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
@@ -5459,7 +5364,7 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2:
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
-fast-glob@^3.2.9, fast-glob@^3.3.2:
+fast-glob@^3.3.2:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
@@ -5914,18 +5819,6 @@ globalthis@^1.0.4:
define-properties "^1.2.1"
gopd "^1.0.1"
-globby@^11.1.0:
- version "11.1.0"
- resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
- integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
- dependencies:
- array-union "^2.1.0"
- dir-glob "^3.0.1"
- fast-glob "^3.2.9"
- ignore "^5.2.0"
- merge2 "^1.4.1"
- slash "^3.0.0"
-
gopd@^1.0.1, gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@@ -7103,7 +6996,7 @@ merge-stream@^2.0.0:
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
-merge2@^1.3.0, merge2@^1.4.1:
+merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
@@ -7455,11 +7348,6 @@ napi-postinstall@^0.3.0:
resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.2.tgz#03c62080e88b311c4d7423b0f15f0c920bbcc626"
integrity sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw==
-natural-compare-lite@^1.4.0:
- version "1.4.0"
- resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
- integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==
-
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@@ -8207,9 +8095,9 @@ postcss@^8.4.33, postcss@^8.4.38, postcss@^8.4.47, postcss@^8.4.49:
source-map-js "^1.2.1"
posthog-js@^1.131.3:
- version "1.257.0"
- resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.257.0.tgz#7adfffa024756b910ee87a978e0fc6c12a9fa730"
- integrity sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==
+ version "1.257.1"
+ resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.257.1.tgz#7f9cb847779d515cdadbf9540378722d33efe4cd"
+ integrity sha512-29kk3IO/LkPQ8E1cds6a2sWr5iN4BovgL+EMzRK9hQXbI6D3FJnQ7zLU6EUpktt6pHnqGpfO3BTEcflcDYkHBg==
dependencies:
core-js "^3.38.1"
fflate "^0.4.8"
@@ -9399,11 +9287,6 @@ simple-update-notifier@^2.0.0:
dependencies:
semver "^7.5.3"
-slash@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
- integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
-
slash@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce"
@@ -10088,11 +9971,6 @@ tsconfig-paths@^4.2.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
-tslib@^1.8.1:
- version "1.14.1"
- resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
- integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
-
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
@@ -10125,13 +10003,6 @@ tsup@8.4.0:
tinyglobby "^0.2.11"
tree-kill "^1.2.2"
-tsutils@^3.21.0:
- version "3.21.0"
- resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
- integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
- dependencies:
- tslib "^1.8.1"
-
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@@ -10169,7 +10040,7 @@ turbo-windows-arm64@2.5.5:
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.5.5.tgz#0ab29d38fcc67ba38652f1cd273df49177cb55b8"
integrity sha512-AXbF1KmpHUq3PKQwddMGoKMYhHsy5t1YBQO8HZ04HLMR0rWv9adYlQ8kaeQJTko1Ay1anOBFTqaxfVOOsu7+1Q==
-turbo@^2.5.4:
+turbo@^2.5.5:
version "2.5.5"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.5.5.tgz#6057f87c1262acdfa30cf16d4cf74629623f36c9"
integrity sha512-eZ7wI6KjtT1eBqCnh2JPXWNUAxtoxxfi6VdBdZFvil0ychCOTxbm7YLRBi1JSt7U3c+u3CLxpoPxLdvr/Npr3A==
@@ -10863,7 +10734,7 @@ ws@^7.4.6:
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
-ws@^8.17.1, ws@^8.18.0, ws@^8.2.3, ws@^8.5.0:
+ws@^8.17.1, ws@^8.18.0, ws@^8.18.3, ws@^8.2.3, ws@^8.5.0:
version "8.18.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
From f8353d3468ba1c8bf4f6211ca54c052c60893e44 Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Date: Fri, 25 Jul 2025 13:40:25 +0530
Subject: [PATCH 003/178] fix: eslint warnings and errors (#7479)
---
.../src/core/extensions/code/lowlight-plugin.ts | 14 ++++++--------
packages/editor/src/core/helpers/scroll-to-node.ts | 7 ++-----
packages/editor/src/core/types/editor.ts | 3 ++-
.../src/core/types/slash-commands-suggestion.ts | 4 ++--
4 files changed, 12 insertions(+), 16 deletions(-)
diff --git a/packages/editor/src/core/extensions/code/lowlight-plugin.ts b/packages/editor/src/core/extensions/code/lowlight-plugin.ts
index f5bbde612..4d3973ef5 100644
--- a/packages/editor/src/core/extensions/code/lowlight-plugin.ts
+++ b/packages/editor/src/core/extensions/code/lowlight-plugin.ts
@@ -1,3 +1,5 @@
+// TODO: check all the type errors and fix them
+
import { findChildren } from "@tiptap/core";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
@@ -117,19 +119,15 @@ export function LowlightPlugin({
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(
(step) =>
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
+ // @ts-expect-error type error
step.from !== undefined &&
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
+ // @ts-expect-error type error
step.to !== undefined &&
oldNodes.some(
(node) =>
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
+ // @ts-expect-error type error
node.pos >= step.from &&
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-expect-error
+ // @ts-expect-error type error
node.pos + node.node.nodeSize <= step.to
)
))
diff --git a/packages/editor/src/core/helpers/scroll-to-node.ts b/packages/editor/src/core/helpers/scroll-to-node.ts
index d74e7532c..7e5aa0979 100644
--- a/packages/editor/src/core/helpers/scroll-to-node.ts
+++ b/packages/editor/src/core/helpers/scroll-to-node.ts
@@ -32,11 +32,8 @@ function scrollToNode(editor: Editor, pos: number): void {
}
}
-export function scrollToNodeViaDOMCoordinates(
- editor: Editor,
- pos: number,
- behavior?: "auto" | "smooth" | "instant"
-): void {
+// eslint-disable-next-line no-undef
+export function scrollToNodeViaDOMCoordinates(editor: Editor, pos: number, behavior?: ScrollBehavior): void {
const view = editor.view;
// Get the coordinates of the position
diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts
index d409b8828..b91d5fdee 100644
--- a/packages/editor/src/core/types/editor.ts
+++ b/packages/editor/src/core/types/editor.ts
@@ -111,7 +111,8 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
onDocumentInfoChange: (callback: (documentInfo: TDocumentInfo) => void) => () => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
onStateChange: (callback: () => void) => () => void;
- scrollToNodeViaDOMCoordinates: (behavior?: "auto" | "smooth" | "instant", position?: number) => void;
+ // eslint-disable-next-line no-undef
+ scrollToNodeViaDOMCoordinates: (behavior?: ScrollBehavior, position?: number) => void;
setEditorValueAtCursorPosition: (content: string) => void;
setFocusAtPosition: (position: number) => void;
setProviderDocument: (value: Uint8Array) => void;
diff --git a/packages/editor/src/core/types/slash-commands-suggestion.ts b/packages/editor/src/core/types/slash-commands-suggestion.ts
index b5e806884..5027c0309 100644
--- a/packages/editor/src/core/types/slash-commands-suggestion.ts
+++ b/packages/editor/src/core/types/slash-commands-suggestion.ts
@@ -1,5 +1,5 @@
-import { Editor, Range } from "@tiptap/core";
-import { CSSProperties } from "react";
+import type { Editor, Range } from "@tiptap/core";
+import type { CSSProperties } from "react";
// types
import { TEditorCommands } from "@/types";
From e20bfa55d6615621bee04d4626b61e72c8a63665 Mon Sep 17 00:00:00 2001
From: Aaron Heckmann
Date: Fri, 25 Jul 2025 01:11:03 -0700
Subject: [PATCH 004/178] chore: add ci job names (#7478)
* chore: add ci job names
This makes them easier to find in the Github UI.
* chore: increase ci timeout
---
.github/workflows/pull-request-build-lint-api.yml | 3 ++-
.github/workflows/pull-request-build-lint-web-apps.yml | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/pull-request-build-lint-api.yml b/.github/workflows/pull-request-build-lint-api.yml
index 93619b03d..fdeb492f0 100644
--- a/.github/workflows/pull-request-build-lint-api.yml
+++ b/.github/workflows/pull-request-build-lint-api.yml
@@ -10,8 +10,9 @@ on:
jobs:
lint-api:
+ name: Lint API
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
diff --git a/.github/workflows/pull-request-build-lint-web-apps.yml b/.github/workflows/pull-request-build-lint-web-apps.yml
index 8afe78c49..6b00f5570 100644
--- a/.github/workflows/pull-request-build-lint-web-apps.yml
+++ b/.github/workflows/pull-request-build-lint-web-apps.yml
@@ -14,8 +14,9 @@ on:
jobs:
build-and-lint:
+ name: Build and lint web apps
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 25
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.requested_reviewers != null
From 27f74206a3db34a43d6c2aea483b4fad6e5d0b06 Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Date: Fri, 25 Jul 2025 13:57:45 +0530
Subject: [PATCH 005/178] [WIKI-537] refactor: document editor (#7384)
* refactor: document editor
* chore: update user prop
* fix: type warning
* chore: update value prop name
* chore: remove unnecessary exports
* hore: update initialValue type
* chore: revert initialValue type
* refactor: unnecessary string handlers
---
.../components/editor/document/editor.tsx | 92 +++++++++++++++
apps/web/core/components/editor/index.ts | 4 +-
.../editor/lite-text-editor/index.ts | 3 -
.../editor.tsx} | 0
.../core/components/editor/lite-text/index.ts | 3 +
.../read-only-editor.tsx} | 0
.../toolbar.tsx | 0
.../editor/rich-text-editor/index.ts | 1 -
.../editor.tsx} | 0
.../core/components/editor/rich-text/index.ts | 1 +
.../modals/create-modal/issue-description.tsx | 2 +-
.../core/components/pages/version/editor.tsx | 50 ++------
.../src/ce/extensions/document-extensions.tsx | 1 +
.../components/editors/document/editor.tsx | 109 ++++++++++++++++++
.../core/components/editors/document/index.ts | 2 +-
.../editors/document/read-only-editor.tsx | 77 -------------
.../core/hooks/use-collaborative-editor.ts | 1 +
.../src/core/hooks/use-editor-markings.tsx | 39 -------
packages/editor/src/core/hooks/use-editor.ts | 2 +-
packages/editor/src/core/types/config.ts | 7 ++
packages/editor/src/core/types/editor.ts | 16 ++-
packages/editor/src/core/types/hook.ts | 3 +-
packages/editor/src/index.ts | 14 +--
23 files changed, 244 insertions(+), 183 deletions(-)
create mode 100644 apps/web/core/components/editor/document/editor.tsx
delete mode 100644 apps/web/core/components/editor/lite-text-editor/index.ts
rename apps/web/core/components/editor/{lite-text-editor/lite-text-editor.tsx => lite-text/editor.tsx} (100%)
create mode 100644 apps/web/core/components/editor/lite-text/index.ts
rename apps/web/core/components/editor/{lite-text-editor/lite-text-read-only-editor.tsx => lite-text/read-only-editor.tsx} (100%)
rename apps/web/core/components/editor/{lite-text-editor => lite-text}/toolbar.tsx (100%)
delete mode 100644 apps/web/core/components/editor/rich-text-editor/index.ts
rename apps/web/core/components/editor/{rich-text-editor/rich-text-editor.tsx => rich-text/editor.tsx} (100%)
create mode 100644 apps/web/core/components/editor/rich-text/index.ts
create mode 100644 packages/editor/src/core/components/editors/document/editor.tsx
delete mode 100644 packages/editor/src/core/components/editors/document/read-only-editor.tsx
delete mode 100644 packages/editor/src/core/hooks/use-editor-markings.tsx
diff --git a/apps/web/core/components/editor/document/editor.tsx b/apps/web/core/components/editor/document/editor.tsx
new file mode 100644
index 000000000..a579430d9
--- /dev/null
+++ b/apps/web/core/components/editor/document/editor.tsx
@@ -0,0 +1,92 @@
+import React, { forwardRef } from "react";
+// plane imports
+import { DocumentEditorWithRef, EditorRefApi, IDocumentEditorProps, TFileHandler } from "@plane/editor";
+import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
+import { cn } from "@plane/utils";
+// components
+import { EditorMentionsRoot } from "@/components/editor";
+// hooks
+import { useEditorConfig, useEditorMention } from "@/hooks/editor";
+import { useMember } from "@/hooks/store";
+// plane web hooks
+import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
+import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
+
+type DocumentEditorWrapperProps = MakeOptional<
+ Omit,
+ "disabledExtensions" | "editable" | "flaggedExtensions"
+> & {
+ embedHandler?: Partial;
+ workspaceSlug: string;
+ workspaceId: string;
+ projectId?: string;
+} & (
+ | {
+ editable: false;
+ }
+ | {
+ editable: true;
+ searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise;
+ uploadFile: TFileHandler["upload"];
+ }
+ );
+
+export const DocumentEditor = forwardRef((props, ref) => {
+ const {
+ containerClassName,
+ editable,
+ embedHandler,
+ workspaceSlug,
+ workspaceId,
+ projectId,
+ disabledExtensions: additionalDisabledExtensions = [],
+ ...rest
+ } = props;
+ // store hooks
+ const { getUserDetails } = useMember();
+ // editor flaggings
+ const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug);
+ // use editor mention
+ const { fetchMentions } = useEditorMention({
+ searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
+ });
+ // editor config
+ const { getEditorFileHandlers } = useEditorConfig();
+ // issue-embed
+ const { issueEmbedProps } = useIssueEmbed({
+ projectId,
+ workspaceSlug,
+ });
+
+ return (
+ "",
+ workspaceId,
+ workspaceSlug,
+ })}
+ mentionHandler={{
+ searchCallback: async (query) => {
+ const res = await fetchMentions(query);
+ if (!res) throw new Error("Failed in fetching mentions");
+ return res;
+ },
+ renderComponent: EditorMentionsRoot,
+ getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
+ }}
+ embedHandler={{
+ issue: issueEmbedProps,
+ ...embedHandler,
+ }}
+ {...rest}
+ containerClassName={cn("relative pl-3 pb-3", containerClassName)}
+ />
+ );
+});
+
+DocumentEditor.displayName = "DocumentEditor";
diff --git a/apps/web/core/components/editor/index.ts b/apps/web/core/components/editor/index.ts
index 674bbdf15..c80c73352 100644
--- a/apps/web/core/components/editor/index.ts
+++ b/apps/web/core/components/editor/index.ts
@@ -1,5 +1,5 @@
export * from "./embeds";
-export * from "./lite-text-editor";
+export * from "./lite-text";
export * from "./pdf";
-export * from "./rich-text-editor";
+export * from "./rich-text";
export * from "./sticky-editor";
diff --git a/apps/web/core/components/editor/lite-text-editor/index.ts b/apps/web/core/components/editor/lite-text-editor/index.ts
deleted file mode 100644
index 661c8e755..000000000
--- a/apps/web/core/components/editor/lite-text-editor/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./lite-text-editor";
-export * from "./lite-text-read-only-editor";
-export * from "./toolbar";
diff --git a/apps/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/apps/web/core/components/editor/lite-text/editor.tsx
similarity index 100%
rename from apps/web/core/components/editor/lite-text-editor/lite-text-editor.tsx
rename to apps/web/core/components/editor/lite-text/editor.tsx
diff --git a/apps/web/core/components/editor/lite-text/index.ts b/apps/web/core/components/editor/lite-text/index.ts
new file mode 100644
index 000000000..a53feeb82
--- /dev/null
+++ b/apps/web/core/components/editor/lite-text/index.ts
@@ -0,0 +1,3 @@
+export * from "./editor";
+export * from "./read-only-editor";
+export * from "./toolbar";
diff --git a/apps/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx b/apps/web/core/components/editor/lite-text/read-only-editor.tsx
similarity index 100%
rename from apps/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx
rename to apps/web/core/components/editor/lite-text/read-only-editor.tsx
diff --git a/apps/web/core/components/editor/lite-text-editor/toolbar.tsx b/apps/web/core/components/editor/lite-text/toolbar.tsx
similarity index 100%
rename from apps/web/core/components/editor/lite-text-editor/toolbar.tsx
rename to apps/web/core/components/editor/lite-text/toolbar.tsx
diff --git a/apps/web/core/components/editor/rich-text-editor/index.ts b/apps/web/core/components/editor/rich-text-editor/index.ts
deleted file mode 100644
index 49fdb69dd..000000000
--- a/apps/web/core/components/editor/rich-text-editor/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./rich-text-editor";
diff --git a/apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx b/apps/web/core/components/editor/rich-text/editor.tsx
similarity index 100%
rename from apps/web/core/components/editor/rich-text-editor/rich-text-editor.tsx
rename to apps/web/core/components/editor/rich-text/editor.tsx
diff --git a/apps/web/core/components/editor/rich-text/index.ts b/apps/web/core/components/editor/rich-text/index.ts
new file mode 100644
index 000000000..8b1fd904b
--- /dev/null
+++ b/apps/web/core/components/editor/rich-text/index.ts
@@ -0,0 +1 @@
+export * from "./editor";
diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx
index 907f48e70..09679cf31 100644
--- a/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx
+++ b/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx
@@ -10,7 +10,7 @@ import { EFileAssetType, TIssue } from "@plane/types";
import { Loader } from "@plane/ui";
import { getDescriptionPlaceholderI18n, getTabIndex } from "@plane/utils";
// components
-import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor";
+import { RichTextEditor } from "@/components/editor/rich-text/editor";
// hooks
import { useEditorAsset, useProjectInbox } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
diff --git a/apps/web/core/components/pages/version/editor.tsx b/apps/web/core/components/pages/version/editor.tsx
index 1a2c23e28..b4aec1bb5 100644
--- a/apps/web/core/components/pages/version/editor.tsx
+++ b/apps/web/core/components/pages/version/editor.tsx
@@ -1,18 +1,14 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
-import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
+import { TDisplayConfig } from "@plane/editor";
import { TPageVersion } from "@plane/types";
import { Loader } from "@plane/ui";
// components
-import { EditorMentionsRoot } from "@/components/editor";
+import { DocumentEditor } from "@/components/editor/document/editor";
// hooks
-import { useEditorConfig } from "@/hooks/editor";
-import { useMember, useWorkspace } from "@/hooks/store";
+import { useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
-// plane web hooks
-import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
-import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
export type TVersionEditorProps = {
activeVersion: string | null;
@@ -21,23 +17,12 @@ export type TVersionEditorProps = {
export const PagesVersionEditor: React.FC = observer((props) => {
const { activeVersion, versionDetails } = props;
- // store hooks
- const { getUserDetails } = useMember();
// params
const { workspaceSlug, projectId } = useParams();
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? "");
- // editor flaggings
- const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
- // editor config
- const { getReadOnlyEditorFileHandlers } = useEditorConfig();
- // issue-embed
- const { issueEmbedProps } = useIssueEmbed({
- projectId: projectId?.toString() ?? "",
- workspaceSlug: workspaceSlug?.toString() ?? "",
- });
// page filters
const { fontSize, fontStyle } = usePageFilters();
@@ -89,32 +74,21 @@ export const PagesVersionEditor: React.FC = observer((props
);
- const description = versionDetails?.description_html;
- if (description === undefined || description?.trim() === "") return null;
+ const description = versionDetails?.description_json;
+ if (!description) return null;
return (
-
"}
+ value={description}
containerClassName="p-0 pb-64 border-none"
- disabledExtensions={documentEditorExtensions.disabled}
- flaggedExtensions={documentEditorExtensions.flagged}
displayConfig={displayConfig}
editorClassName="pl-10"
- fileHandler={getReadOnlyEditorFileHandlers({
- projectId: projectId?.toString() ?? "",
- workspaceId: workspaceDetails?.id ?? "",
- workspaceSlug: workspaceSlug?.toString() ?? "",
- })}
- mentionHandler={{
- renderComponent: (props) => ,
- getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
- }}
- embedHandler={{
- issue: {
- widgetCallback: issueEmbedProps.widgetCallback,
- },
- }}
+ projectId={projectId?.toString()}
+ workspaceId={workspaceDetails?.id ?? ""}
+ workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
);
});
diff --git a/packages/editor/src/ce/extensions/document-extensions.tsx b/packages/editor/src/ce/extensions/document-extensions.tsx
index 8815e2d26..8260da7d4 100644
--- a/packages/editor/src/ce/extensions/document-extensions.tsx
+++ b/packages/editor/src/ce/extensions/document-extensions.tsx
@@ -11,6 +11,7 @@ export type TDocumentEditorAdditionalExtensionsProps = Pick<
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
+ isEditable: boolean;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};
diff --git a/packages/editor/src/core/components/editors/document/editor.tsx b/packages/editor/src/core/components/editors/document/editor.tsx
new file mode 100644
index 000000000..150d943fd
--- /dev/null
+++ b/packages/editor/src/core/components/editors/document/editor.tsx
@@ -0,0 +1,109 @@
+import { Extensions } from "@tiptap/core";
+import { forwardRef, MutableRefObject, useMemo } from "react";
+// plane imports
+import { cn } from "@plane/utils";
+// components
+import { PageRenderer } from "@/components/editors";
+// constants
+import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
+// extensions
+import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from "@/extensions";
+// helpers
+import { getEditorClassNames } from "@/helpers/common";
+// hooks
+import { useEditor } from "@/hooks/use-editor";
+// plane editor extensions
+import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
+// types
+import { EditorRefApi, IDocumentEditorProps } from "@/types";
+
+const DocumentEditor = (props: IDocumentEditorProps) => {
+ const {
+ bubbleMenuEnabled = false,
+ containerClassName,
+ disabledExtensions,
+ displayConfig = DEFAULT_DISPLAY_CONFIG,
+ editable,
+ editorClassName = "",
+ embedHandler,
+ fileHandler,
+ flaggedExtensions,
+ forwardedRef,
+ id,
+ handleEditorReady,
+ mentionHandler,
+ onChange,
+ user,
+ value,
+ } = props;
+ const extensions: Extensions = useMemo(() => {
+ const additionalExtensions: Extensions = [];
+ if (embedHandler?.issue) {
+ additionalExtensions.push(
+ WorkItemEmbedExtension({
+ widgetCallback: embedHandler.issue.widgetCallback,
+ })
+ );
+ }
+ additionalExtensions.push(
+ SideMenuExtension({
+ aiEnabled: !disabledExtensions?.includes("ai"),
+ dragDropEnabled: true,
+ }),
+ HeadingListExtension,
+ ...DocumentEditorAdditionalExtensions({
+ disabledExtensions,
+ embedConfig: embedHandler,
+ flaggedExtensions,
+ isEditable: editable,
+ fileHandler,
+ userDetails: user ?? {
+ id: "",
+ name: "",
+ color: "",
+ },
+ })
+ );
+ return additionalExtensions;
+ }, []);
+
+ const editor = useEditor({
+ disabledExtensions,
+ editable,
+ editorClassName,
+ enableHistory: true,
+ extensions,
+ fileHandler,
+ flaggedExtensions,
+ forwardedRef,
+ handleEditorReady,
+ id,
+ initialValue: value,
+ mentionHandler,
+ onChange,
+ });
+
+ const editorContainerClassName = getEditorClassNames({
+ containerClassName,
+ });
+
+ if (!editor) return null;
+
+ return (
+
+ );
+};
+
+const DocumentEditorWithRef = forwardRef((props, ref) => (
+ } />
+));
+
+DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
+
+export { DocumentEditorWithRef };
diff --git a/packages/editor/src/core/components/editors/document/index.ts b/packages/editor/src/core/components/editors/document/index.ts
index 571cb7e9a..8a5bffd17 100644
--- a/packages/editor/src/core/components/editors/document/index.ts
+++ b/packages/editor/src/core/components/editors/document/index.ts
@@ -1,4 +1,4 @@
export * from "./collaborative-editor";
+export * from "./editor";
export * from "./loader";
export * from "./page-renderer";
-export * from "./read-only-editor";
diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx
deleted file mode 100644
index 8f0d67ddc..000000000
--- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Extensions } from "@tiptap/core";
-import React, { forwardRef, MutableRefObject } from "react";
-// plane imports
-import { cn } from "@plane/utils";
-// components
-import { PageRenderer } from "@/components/editors";
-// constants
-import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
-// extensions
-import { WorkItemEmbedExtension } from "@/extensions";
-// helpers
-import { getEditorClassNames } from "@/helpers/common";
-// hooks
-import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
-// types
-import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
-
-const DocumentReadOnlyEditor: React.FC = (props) => {
- const {
- containerClassName,
- disabledExtensions,
- displayConfig = DEFAULT_DISPLAY_CONFIG,
- editorClassName = "",
- embedHandler,
- fileHandler,
- flaggedExtensions,
- id,
- forwardedRef,
- handleEditorReady,
- initialValue,
- mentionHandler,
- } = props;
- const extensions: Extensions = [];
- if (embedHandler?.issue) {
- extensions.push(
- WorkItemEmbedExtension({
- widgetCallback: embedHandler.issue.widgetCallback,
- })
- );
- }
-
- const editor = useReadOnlyEditor({
- disabledExtensions,
- editorClassName,
- extensions,
- fileHandler,
- flaggedExtensions,
- forwardedRef,
- handleEditorReady,
- initialValue,
- mentionHandler,
- });
-
- const editorContainerClassName = getEditorClassNames({
- containerClassName,
- });
-
- if (!editor) return null;
-
- return (
-
- );
-};
-
-const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => (
- } />
-));
-
-DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
-
-export { DocumentReadOnlyEditorWithRef };
diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts
index 3b4b333e6..e6c20b13f 100644
--- a/packages/editor/src/core/hooks/use-collaborative-editor.ts
+++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts
@@ -98,6 +98,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
embedConfig: embedHandler,
fileHandler,
flaggedExtensions,
+ isEditable: editable,
provider,
userDetails: user,
}),
diff --git a/packages/editor/src/core/hooks/use-editor-markings.tsx b/packages/editor/src/core/hooks/use-editor-markings.tsx
deleted file mode 100644
index 76d02cd68..000000000
--- a/packages/editor/src/core/hooks/use-editor-markings.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useCallback, useState } from "react";
-
-export interface IMarking {
- type: "heading";
- level: number;
- text: string;
- sequence: number;
-}
-
-export const useEditorMarkings = () => {
- const [markings, setMarkings] = useState([]);
-
- const updateMarkings = useCallback((html: string) => {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, "text/html");
- const headings = doc.querySelectorAll("h1, h2, h3");
- const tempMarkings: IMarking[] = [];
- let h1Sequence: number = 0;
- let h2Sequence: number = 0;
- let h3Sequence: number = 0;
-
- headings.forEach((heading) => {
- const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3
- tempMarkings.push({
- type: "heading",
- level: level,
- text: heading.textContent || "",
- sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
- });
- });
-
- setMarkings(tempMarkings);
- }, []);
-
- return {
- updateMarkings,
- markings,
- };
-};
diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts
index 1b82e0246..954ef61b8 100644
--- a/packages/editor/src/core/hooks/use-editor.ts
+++ b/packages/editor/src/core/hooks/use-editor.ts
@@ -70,7 +70,7 @@ export const useEditor = (props: TEditorHookProps) => {
}),
...extensions,
],
- content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "",
+ content: initialValue,
onCreate: () => handleEditorReady?.(true),
onTransaction: () => {
onTransaction?.();
diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts
index 7ef685ad0..8c1903cf2 100644
--- a/packages/editor/src/core/types/config.ts
+++ b/packages/editor/src/core/types/config.ts
@@ -46,3 +46,10 @@ export type TRealtimeConfig = {
url: string;
queryParams: TWebhookConnectionQueryParams;
};
+
+export type IMarking = {
+ type: "heading";
+ level: number;
+ text: string;
+ sequence: number;
+};
diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts
index b91d5fdee..d511bb7f1 100644
--- a/packages/editor/src/core/types/editor.ts
+++ b/packages/editor/src/core/types/editor.ts
@@ -1,5 +1,5 @@
-import { Extensions, JSONContent } from "@tiptap/core";
-import { Selection } from "@tiptap/pm/state";
+import type { Content, Extensions, JSONContent } from "@tiptap/core";
+import type { Selection } from "@tiptap/pm/state";
// extension types
import type { TTextAlign } from "@/extensions";
// helpers
@@ -160,6 +160,14 @@ export interface ICollaborativeDocumentEditorProps
user: TUserDetails;
}
+export interface IDocumentEditorProps extends Omit {
+ aiHandler?: TAIHandler;
+ editable: boolean;
+ embedHandler: TEmbedConfig;
+ user?: TUserDetails;
+ value: Content;
+}
+
// read only editor props
export interface IReadOnlyEditorProps
extends Pick<
@@ -181,10 +189,6 @@ export interface IReadOnlyEditorProps
export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;
-export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps {
- embedHandler: TEmbedConfig;
-}
-
export interface EditorEvents {
beforeCreate: never;
create: never;
diff --git a/packages/editor/src/core/types/hook.ts b/packages/editor/src/core/types/hook.ts
index 40974981b..fa014ceb9 100644
--- a/packages/editor/src/core/types/hook.ts
+++ b/packages/editor/src/core/types/hook.ts
@@ -1,4 +1,5 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
+import type { Content } from "@tiptap/core";
import type { EditorProps } from "@tiptap/pm/view";
// local imports
import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from "./editor";
@@ -27,7 +28,7 @@ export type TEditorHookProps = TCoreHookProps &
> & {
editable: boolean;
enableHistory: boolean;
- initialValue?: string;
+ initialValue?: Content;
provider?: HocuspocusProvider;
};
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index 9cb374dce..2cfc68915 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -9,30 +9,18 @@ import "./styles/drag-drop.css";
// editors
export {
CollaborativeDocumentEditorWithRef,
- DocumentReadOnlyEditorWithRef,
+ DocumentEditorWithRef,
LiteTextEditorWithRef,
LiteTextReadOnlyEditorWithRef,
RichTextEditorWithRef,
} from "@/components/editors";
-export { isCellSelection } from "@/extensions/table/table/utilities/helpers";
-
// constants
export * from "@/constants/common";
// helpers
export * from "@/helpers/common";
-export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs-utils";
-export * from "@/extensions/table/table";
-
-// components
-export * from "@/components/menus";
-
-// hooks
-export { useEditor } from "@/hooks/use-editor";
-export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
-export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
export { CORE_EXTENSIONS } from "@/constants/extension";
export { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions";
From 63d025cbf45cf753f9685469ed30ab072fd24c9a Mon Sep 17 00:00:00 2001
From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Date: Fri, 25 Jul 2025 16:50:35 +0530
Subject: [PATCH 006/178] [WEB-4544] chore: added field validations in
serializer (#7460)
* chore: added field validations in serializer
* chore: added enum for roles
---
apps/api/plane/api/serializers/issue.py | 17 ++++-
apps/api/plane/app/serializers/draft.py | 90 ++++++++++++++++++++++---
apps/api/plane/app/serializers/issue.py | 66 ++++++++++++++++--
3 files changed, 157 insertions(+), 16 deletions(-)
diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py
index e23a356da..1e76daac2 100644
--- a/apps/api/plane/api/serializers/issue.py
+++ b/apps/api/plane/api/serializers/issue.py
@@ -20,6 +20,7 @@ from plane.db.models import (
ProjectMember,
State,
User,
+ EstimatePoint,
)
from .base import BaseSerializer
@@ -112,13 +113,27 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
- workspace_id=self.context.get("workspace_id"), pk=data.get("parent").id
+ workspace_id=self.context.get("workspace_id"),
+ project_id=self.context.get("project_id"),
+ pk=data.get("parent").id,
).exists()
):
raise serializers.ValidationError(
"Parent is not valid issue_id please pass a valid issue_id"
)
+ if (
+ data.get("estimate_point")
+ and not EstimatePoint.objects.filter(
+ workspace_id=self.context.get("workspace_id"),
+ project_id=self.context.get("project_id"),
+ pk=data.get("estimate_point").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "Estimate point is not valid please pass a valid estimate_point_id"
+ )
+
return data
def create(self, validated_data):
diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py
index f30835263..86a5e3686 100644
--- a/apps/api/plane/app/serializers/draft.py
+++ b/apps/api/plane/app/serializers/draft.py
@@ -1,3 +1,5 @@
+from lxml import html
+
# Django imports
from django.utils import timezone
@@ -16,7 +18,10 @@ from plane.db.models import (
DraftIssueLabel,
DraftIssueCycle,
DraftIssueModule,
+ ProjectMember,
+ EstimatePoint,
)
+from plane.app.permissions import ROLE
class DraftIssueCreateSerializer(BaseSerializer):
@@ -57,14 +62,77 @@ class DraftIssueCreateSerializer(BaseSerializer):
data["label_ids"] = label_ids if label_ids else []
return data
- def validate(self, data):
+ def validate(self, attrs):
if (
- data.get("start_date", None) is not None
- and data.get("target_date", None) is not None
- and data.get("start_date", None) > data.get("target_date", None)
+ attrs.get("start_date", None) is not None
+ and attrs.get("target_date", None) is not None
+ and attrs.get("start_date", None) > attrs.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
- return data
+
+ try:
+ if attrs.get("description_html", None) is not None:
+ parsed = html.fromstring(attrs["description_html"])
+ parsed_str = html.tostring(parsed, encoding="unicode")
+ attrs["description_html"] = parsed_str
+
+ except Exception:
+ raise serializers.ValidationError("Invalid HTML passed")
+
+ # Validate assignees are from project
+ if attrs.get("assignee_ids", []):
+ attrs["assignee_ids"] = ProjectMember.objects.filter(
+ project_id=self.context["project_id"],
+ role__gte=ROLE.MEMBER.value,
+ is_active=True,
+ member_id__in=attrs["assignee_ids"],
+ ).values_list("member_id", flat=True)
+
+ # Validate labels are from project
+ if attrs.get("label_ids"):
+ label_ids = [label.id for label in attrs["label_ids"]]
+ attrs["label_ids"] = list(
+ Label.objects.filter(
+ project_id=self.context.get("project_id"), id__in=label_ids
+ ).values_list("id", flat=True)
+ )
+
+ # # Check state is from the project only else raise validation error
+ if (
+ attrs.get("state")
+ and not State.objects.filter(
+ project_id=self.context.get("project_id"),
+ pk=attrs.get("state").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "State is not valid please pass a valid state_id"
+ )
+
+ # # Check parent issue is from workspace as it can be cross workspace
+ if (
+ attrs.get("parent")
+ and not Issue.objects.filter(
+ project_id=self.context.get("project_id"),
+ pk=attrs.get("parent").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "Parent is not valid issue_id please pass a valid issue_id"
+ )
+
+ if (
+ attrs.get("estimate_point")
+ and not EstimatePoint.objects.filter(
+ project_id=self.context.get("project_id"),
+ pk=attrs.get("estimate_point").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "Estimate point is not valid please pass a valid estimate_point_id"
+ )
+
+ return attrs
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
@@ -89,14 +157,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
- assignee=user,
+ assignee_id=assignee_id,
draft_issue=issue,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
- for user in assignees
+ for assignee_id in assignees
],
batch_size=10,
)
@@ -105,14 +173,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueLabel.objects.bulk_create(
[
DraftIssueLabel(
- label=label,
+ label_id=label_id,
draft_issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
- for label in labels
+ for label_id in labels
],
batch_size=10,
)
@@ -163,14 +231,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
- assignee=user,
+ assignee_id=assignee_id,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
- for user in assignees
+ for assignee_id in assignees
],
batch_size=10,
)
diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py
index 965d78aa2..7f3301126 100644
--- a/apps/api/plane/app/serializers/issue.py
+++ b/apps/api/plane/app/serializers/issue.py
@@ -1,3 +1,5 @@
+from lxml import html
+
# Django imports
from django.utils import timezone
from django.core.validators import URLValidator
@@ -37,6 +39,7 @@ from plane.db.models import (
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
+ EstimatePoint,
)
@@ -119,6 +122,16 @@ class IssueCreateSerializer(BaseSerializer):
):
raise serializers.ValidationError("Start date cannot exceed target date")
+ try:
+ if attrs.get("description_html", None) is not None:
+ parsed = html.fromstring(attrs["description_html"])
+ parsed_str = html.tostring(parsed, encoding="unicode")
+ attrs["description_html"] = parsed_str
+
+ except Exception:
+ raise serializers.ValidationError("Invalid HTML passed")
+
+ # Validate assignees are from project
if attrs.get("assignee_ids", []):
attrs["assignee_ids"] = ProjectMember.objects.filter(
project_id=self.context["project_id"],
@@ -127,6 +140,51 @@ class IssueCreateSerializer(BaseSerializer):
member_id__in=attrs["assignee_ids"],
).values_list("member_id", flat=True)
+ # Validate labels are from project
+ if attrs.get("label_ids"):
+ label_ids = [label.id for label in attrs["label_ids"]]
+ attrs["label_ids"] = list(
+ Label.objects.filter(
+ project_id=self.context.get("project_id"),
+ id__in=label_ids,
+ ).values_list("id", flat=True)
+ )
+
+ # Check state is from the project only else raise validation error
+ if (
+ attrs.get("state")
+ and not State.objects.filter(
+ project_id=self.context.get("project_id"),
+ pk=attrs.get("state").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "State is not valid please pass a valid state_id"
+ )
+
+ # Check parent issue is from workspace as it can be cross workspace
+ if (
+ attrs.get("parent")
+ and not Issue.objects.filter(
+ project_id=self.context.get("project_id"),
+ pk=attrs.get("parent").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "Parent is not valid issue_id please pass a valid issue_id"
+ )
+
+ if (
+ attrs.get("estimate_point")
+ and not EstimatePoint.objects.filter(
+ project_id=self.context.get("project_id"),
+ pk=attrs.get("estimate_point").id,
+ ).exists()
+ ):
+ raise serializers.ValidationError(
+ "Estimate point is not valid please pass a valid estimate_point_id"
+ )
+
return attrs
def create(self, validated_data):
@@ -190,14 +248,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel.objects.bulk_create(
[
IssueLabel(
- label=label,
+ label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
- for label in labels
+ for label_id in labels
],
batch_size=10,
)
@@ -243,14 +301,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueLabel.objects.bulk_create(
[
IssueLabel(
- label=label,
+ label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
- for label in labels
+ for label_id in labels
],
batch_size=10,
ignore_conflicts=True,
From a5f3bd15b1af7af2d3fbff13327e64a2e951a372 Mon Sep 17 00:00:00 2001
From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Date: Fri, 25 Jul 2025 16:56:46 +0530
Subject: [PATCH 007/178] [WEB-4513] refactor: consolidate password strength
meter into shared ui package (#7462)
* refactor: consolidate password strength indicator into shared UI package
* chore: remove old password strength meter implementations
* chore: update package dependencies for password strength refactor
* chore: code refactor
* fix: lock file
---------
Co-authored-by: sriramveeraghanta
---
.../common/password-strength-meter.tsx | 89 --------------
.../core/components/instance/setup-form.tsx | 5 +-
apps/admin/package.json | 4 +-
.../account/auth-forms/password.tsx | 11 +-
.../core/components/account/helpers/index.ts | 1 -
.../helpers/password-strength-meter.tsx | 94 --------------
apps/space/core/components/account/index.ts | 1 -
apps/space/helpers/password.helper.ts | 67 ----------
apps/space/package.json | 4 +-
.../settings/account/security/page.tsx | 5 +-
.../(all)/accounts/reset-password/page.tsx | 6 +-
.../app/(all)/accounts/set-password/page.tsx | 5 +-
apps/web/app/(all)/profile/security/page.tsx | 5 +-
.../account/auth-forms/password.tsx | 6 +-
apps/web/core/components/account/index.ts | 1 -
.../account/password-strength-meter.tsx | 91 --------------
.../components/onboarding/profile-setup.tsx | 8 +-
apps/web/package.json | 4 +-
packages/ui/src/form-fields/index.ts | 1 +
.../ui/src/form-fields/password/helper.tsx | 65 ++++++++++
packages/ui/src/form-fields/password/index.ts | 2 +
.../ui/src/form-fields/password/indicator.tsx | 75 ++++++++++++
packages/utils/package.json | 4 +-
packages/utils/src/auth.ts | 115 ++++++++----------
yarn.lock | 99 +++++++++++++--
25 files changed, 310 insertions(+), 458 deletions(-)
delete mode 100644 apps/admin/core/components/common/password-strength-meter.tsx
delete mode 100644 apps/space/core/components/account/helpers/index.ts
delete mode 100644 apps/space/core/components/account/helpers/password-strength-meter.tsx
delete mode 100644 apps/space/helpers/password.helper.ts
delete mode 100644 apps/web/core/components/account/password-strength-meter.tsx
create mode 100644 packages/ui/src/form-fields/password/helper.tsx
create mode 100644 packages/ui/src/form-fields/password/index.ts
create mode 100644 packages/ui/src/form-fields/password/indicator.tsx
diff --git a/apps/admin/core/components/common/password-strength-meter.tsx b/apps/admin/core/components/common/password-strength-meter.tsx
deleted file mode 100644
index f4349b24a..000000000
--- a/apps/admin/core/components/common/password-strength-meter.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-"use client";
-
-import { FC, useMemo } from "react";
-// plane internal packages
-import { E_PASSWORD_STRENGTH } from "@plane/constants";
-import { cn, getPasswordStrength } from "@plane/utils";
-
-type TPasswordStrengthMeter = {
- password: string;
- isFocused?: boolean;
-};
-
-export const PasswordStrengthMeter: FC = (props) => {
- const { password, isFocused = false } = props;
- // derived values
- const strength = useMemo(() => getPasswordStrength(password), [password]);
- const strengthBars = useMemo(() => {
- switch (strength) {
- case E_PASSWORD_STRENGTH.EMPTY: {
- return {
- bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Please enter your password.",
- textColor: "text-custom-text-100",
- };
- }
- case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
- return {
- bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Password length should me more than 8 characters.",
- textColor: "text-red-500",
- };
- }
- case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
- return {
- bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Password is weak.",
- textColor: "text-red-500",
- };
- }
- case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
- return {
- bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
- text: "Password is strong.",
- textColor: "text-green-500",
- };
- }
- default: {
- return {
- bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Please enter your password.",
- textColor: "text-custom-text-100",
- };
- }
- }
- }, [strength]);
-
- const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
-
- if (!isPasswordMeterVisible) return <>>;
- return (
-
-
-
- {strengthBars?.bars.map((color, index) => (
-
- ))}
-
-
- {strengthBars?.text}
-
-
-
- {/*
- {PASSWORD_CRITERIA.map((criteria) => (
-
-
- {criteria.label}
-
- ))}
-
*/}
-
- );
-};
diff --git a/apps/admin/core/components/instance/setup-form.tsx b/apps/admin/core/components/instance/setup-form.tsx
index 4e771e91b..e3169ed63 100644
--- a/apps/admin/core/components/instance/setup-form.tsx
+++ b/apps/admin/core/components/instance/setup-form.tsx
@@ -7,11 +7,10 @@ import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
-import { Button, Checkbox, Input, Spinner } from "@plane/ui";
+import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { Banner } from "@/components/common/banner";
-import { PasswordStrengthMeter } from "@/components/common/password-strength-meter";
// service initialization
const authService = new AuthService();
@@ -274,7 +273,7 @@ export const InstanceSetupForm: FC = (props) => {
{errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD && errorData.message && (
{errorData.message}
)}
-
+
diff --git a/apps/admin/package.json b/apps/admin/package.json
index f4c293977..e8ef35497 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -40,8 +40,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
- "uuid": "^9.0.1",
- "zxcvbn": "^4.4.2"
+ "uuid": "^9.0.1"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -51,7 +50,6 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
- "@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
}
}
diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx
index 08db783e3..1dbae80a3 100644
--- a/apps/space/core/components/account/auth-forms/password.tsx
+++ b/apps/space/core/components/account/auth-forms/password.tsx
@@ -4,13 +4,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
// plane imports
-import { API_BASE_URL } from "@plane/constants";
+import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { AuthService } from "@plane/services";
-import { Button, Input, Spinner } from "@plane/ui";
-// components
-import { PasswordStrengthMeter } from "@/components/account";
-// helpers
-import { E_PASSWORD_STRENGTH, getPasswordStrength } from "@/helpers/password.helper";
+import { Button, Input, Spinner, PasswordStrengthIndicator } from "@plane/ui";
+import { getPasswordStrength } from "@plane/utils";
// types
import { EAuthModes, EAuthSteps } from "@/types/auth";
@@ -72,7 +69,7 @@ export const AuthPasswordForm: React.FC
= observer((props: Props) => {
const passwordSupport = passwordFormData.password.length > 0 &&
mode === EAuthModes.SIGN_UP &&
getPasswordStrength(passwordFormData.password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
-
+
);
const isButtonDisabled = useMemo(
diff --git a/apps/space/core/components/account/helpers/index.ts b/apps/space/core/components/account/helpers/index.ts
deleted file mode 100644
index e3e821656..000000000
--- a/apps/space/core/components/account/helpers/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./password-strength-meter";
diff --git a/apps/space/core/components/account/helpers/password-strength-meter.tsx b/apps/space/core/components/account/helpers/password-strength-meter.tsx
deleted file mode 100644
index 611067355..000000000
--- a/apps/space/core/components/account/helpers/password-strength-meter.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-"use client";
-
-import { FC, useMemo } from "react";
-// import { CircleCheck } from "lucide-react";
-// helpers
-import { cn } from "@plane/utils";
-import {
- E_PASSWORD_STRENGTH,
- // PASSWORD_CRITERIA,
- getPasswordStrength,
-} from "@/helpers/password.helper";
-
-type TPasswordStrengthMeter = {
- password: string;
- isFocused?: boolean;
-};
-
-export const PasswordStrengthMeter: FC = (props) => {
- const { password, isFocused = false } = props;
- // derived values
- const strength = useMemo(() => getPasswordStrength(password), [password]);
- const strengthBars = useMemo(() => {
- switch (strength) {
- case E_PASSWORD_STRENGTH.EMPTY: {
- return {
- bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Please enter your password.",
- textColor: "text-custom-text-100",
- };
- }
- case E_PASSWORD_STRENGTH.LENGTH_NOT_VALID: {
- return {
- bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Password length should me more than 8 characters.",
- textColor: "text-red-500",
- };
- }
- case E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID: {
- return {
- bars: [`bg-red-500`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Password is weak.",
- textColor: "text-red-500",
- };
- }
- case E_PASSWORD_STRENGTH.STRENGTH_VALID: {
- return {
- bars: [`bg-green-500`, `bg-green-500`, `bg-green-500`],
- text: "Password is strong.",
- textColor: "text-green-500",
- };
- }
- default: {
- return {
- bars: [`bg-custom-text-100`, `bg-custom-text-100`, `bg-custom-text-100`],
- text: "Please enter your password.",
- textColor: "text-custom-text-100",
- };
- }
- }
- }, [strength]);
-
- const isPasswordMeterVisible = isFocused ? true : strength === E_PASSWORD_STRENGTH.STRENGTH_VALID ? false : true;
-
- if (!isPasswordMeterVisible) return <>>;
- return (
-
-
-
- {strengthBars?.bars.map((color, index) => (
-
- ))}
-
-
- {strengthBars?.text}
-
-
-
- {/*
- {PASSWORD_CRITERIA.map((criteria) => (
-
-
- {criteria.label}
-
- ))}
-
*/}
-
- );
-};
diff --git a/apps/space/core/components/account/index.ts b/apps/space/core/components/account/index.ts
index cf1b7d8cd..bfa38e895 100644
--- a/apps/space/core/components/account/index.ts
+++ b/apps/space/core/components/account/index.ts
@@ -1,5 +1,4 @@
export * from "./auth-forms";
export * from "./oauth";
export * from "./terms-and-conditions";
-export * from "./helpers";
export * from "./user-logged-in";
diff --git a/apps/space/helpers/password.helper.ts b/apps/space/helpers/password.helper.ts
deleted file mode 100644
index dfe9a5c65..000000000
--- a/apps/space/helpers/password.helper.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import zxcvbn from "zxcvbn";
-
-export enum E_PASSWORD_STRENGTH {
- EMPTY = "empty",
- LENGTH_NOT_VALID = "length_not_valid",
- STRENGTH_NOT_VALID = "strength_not_valid",
- STRENGTH_VALID = "strength_valid",
-}
-
-const PASSWORD_MIN_LENGTH = 8;
-// const PASSWORD_NUMBER_REGEX = /\d/;
-// const PASSWORD_CHAR_CAPS_REGEX = /[A-Z]/;
-// const PASSWORD_SPECIAL_CHAR_REGEX = /[`!@#$%^&*()_\-+=\[\]{};':"\\|,.<>\/?~ ]/;
-
-export const PASSWORD_CRITERIA = [
- {
- key: "min_8_char",
- label: "Min 8 characters",
- isCriteriaValid: (password: string) => password.length >= PASSWORD_MIN_LENGTH,
- },
- // {
- // key: "min_1_upper_case",
- // label: "Min 1 upper-case letter",
- // isCriteriaValid: (password: string) => PASSWORD_NUMBER_REGEX.test(password),
- // },
- // {
- // key: "min_1_number",
- // label: "Min 1 number",
- // isCriteriaValid: (password: string) => PASSWORD_CHAR_CAPS_REGEX.test(password),
- // },
- // {
- // key: "min_1_special_char",
- // label: "Min 1 special character",
- // isCriteriaValid: (password: string) => PASSWORD_SPECIAL_CHAR_REGEX.test(password),
- // },
-];
-
-export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
- let passwordStrength: E_PASSWORD_STRENGTH = E_PASSWORD_STRENGTH.EMPTY;
-
- if (!password || password === "" || password.length <= 0) {
- return passwordStrength;
- }
-
- if (password.length >= PASSWORD_MIN_LENGTH) {
- passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
- } else {
- passwordStrength = E_PASSWORD_STRENGTH.LENGTH_NOT_VALID;
- return passwordStrength;
- }
-
- const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
- (criterion) => criterion
- );
- const passwordStrengthScore = zxcvbn(password).score;
-
- if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
- passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_NOT_VALID;
- return passwordStrength;
- }
-
- if (passwordCriteriaValidation === true && passwordStrengthScore >= 3) {
- passwordStrength = E_PASSWORD_STRENGTH.STRENGTH_VALID;
- }
-
- return passwordStrength;
-};
diff --git a/apps/space/package.json b/apps/space/package.json
index 77b430425..d0fe8c6a6 100644
--- a/apps/space/package.json
+++ b/apps/space/package.json
@@ -49,8 +49,7 @@
"react-popper": "^2.3.0",
"swr": "^2.2.2",
"tailwind-merge": "^2.0.0",
- "uuid": "^9.0.0",
- "zxcvbn": "^4.4.2"
+ "uuid": "^9.0.0"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -63,7 +62,6 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.1",
- "@types/zxcvbn": "^4.4.4",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"typescript": "5.8.3"
}
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx
index 4e1b23ba9..2a405e0b0 100644
--- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx
+++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx
@@ -7,10 +7,9 @@ import { Eye, EyeOff } from "lucide-react";
// plane imports
import { E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
-import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
+import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
-import { PasswordStrengthMeter } from "@/components/account";
import { PageHead } from "@/components/core";
import { ProfileSettingContentHeader } from "@/components/profile";
// helpers
@@ -114,7 +113,7 @@ const SecurityPage = observer(() => {
const passwordSupport = password.length > 0 &&
getPasswordStrength(password) != E_PASSWORD_STRENGTH.STRENGTH_VALID && (
-
+
);
const renderPasswordMatchError = !isRetryPasswordInputFocused || confirmPassword.length >= password.length;
diff --git a/apps/web/app/(all)/accounts/reset-password/page.tsx b/apps/web/app/(all)/accounts/reset-password/page.tsx
index 388e7a02d..bf6980e9b 100644
--- a/apps/web/app/(all)/accounts/reset-password/page.tsx
+++ b/apps/web/app/(all)/accounts/reset-password/page.tsx
@@ -11,10 +11,10 @@ import { Eye, EyeOff } from "lucide-react";
// ui
import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
-import { Button, Input } from "@plane/ui";
+import { Button, Input, PasswordStrengthIndicator } from "@plane/ui";
// components
import { getPasswordStrength } from "@plane/utils";
-import { AuthBanner, PasswordStrengthMeter } from "@/components/account";
+import { AuthBanner } from "@/components/account";
// helpers
import {
EAuthenticationErrorCodes,
@@ -192,7 +192,7 @@ const ResetPasswordPage = observer(() => {
/>
)}
-
+
-
+
)}
/>
-
+