[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 <nikhilschacko@gmail.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
98a00f5bde
commit
514686d9d5
64 changed files with 7800 additions and 668 deletions
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
123
apps/api/plane/api/serializers/asset.py
Normal file
123
apps/api/plane/api/serializers/asset.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
40
apps/api/plane/api/urls/asset.py
Normal file
40
apps/api/plane/api/urls/asset.py
Normal file
|
|
@ -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/<uuid:asset_id>/",
|
||||
UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
|
||||
name="user-assets-detail",
|
||||
),
|
||||
path(
|
||||
"assets/user-assets/server/",
|
||||
UserServerAssetEndpoint.as_view(http_method_names=["post"]),
|
||||
name="user-server-assets",
|
||||
),
|
||||
path(
|
||||
"assets/user-assets/<uuid:asset_id>/server/",
|
||||
UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]),
|
||||
name="user-server-assets-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/assets/",
|
||||
GenericAssetEndpoint.as_view(http_method_names=["post"]),
|
||||
name="generic-asset",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/assets/<uuid:asset_id>/",
|
||||
GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]),
|
||||
name="generic-asset-detail",
|
||||
),
|
||||
]
|
||||
|
|
@ -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/<str:slug>/projects/<uuid:project_id>/cycles/",
|
||||
CycleAPIEndpoint.as_view(),
|
||||
CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="cycles",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
|
||||
CycleAPIEndpoint.as_view(),
|
||||
CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="cycles",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/",
|
||||
CycleIssueAPIEndpoint.as_view(),
|
||||
CycleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="cycle-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
|
||||
CycleIssueAPIEndpoint.as_view(),
|
||||
CycleIssueDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
|
||||
name="cycle-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/transfer-issues/",
|
||||
TransferCycleIssueAPIEndpoint.as_view(),
|
||||
TransferCycleIssueAPIEndpoint.as_view(http_method_names=["post"]),
|
||||
name="transfer-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/archive/",
|
||||
CycleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
|
||||
name="cycle-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/",
|
||||
CycleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="cycle-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-cycles/<uuid:pk>/unarchive/",
|
||||
CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
|
||||
name="cycle-archive-unarchive",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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/<str:slug>/projects/<uuid:project_id>/intake-issues/",
|
||||
IntakeIssueAPIEndpoint.as_view(),
|
||||
IntakeIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="intake-issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/<uuid:issue_id>/",
|
||||
IntakeIssueAPIEndpoint.as_view(),
|
||||
IntakeIssueDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="intake-issue",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(),
|
||||
"workspaces/<str:slug>/issues/search/",
|
||||
IssueSearchEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/issues/<str:project_identifier>-<str:issue_identifier>/",
|
||||
WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="issue-by-identifier",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
|
||||
IssueAPIEndpoint.as_view(),
|
||||
IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
|
||||
IssueAPIEndpoint.as_view(),
|
||||
IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="issue",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
|
||||
LabelAPIEndpoint.as_view(),
|
||||
LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
|
||||
LabelAPIEndpoint.as_view(),
|
||||
LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="label",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
|
||||
IssueLinkAPIEndpoint.as_view(),
|
||||
IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
|
||||
IssueLinkAPIEndpoint.as_view(),
|
||||
IssueLinkDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="link",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
|
||||
IssueCommentAPIEndpoint.as_view(),
|
||||
IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentAPIEndpoint.as_view(),
|
||||
IssueCommentDetailAPIEndpoint.as_view(
|
||||
http_method_names=["get", "patch", "delete"]
|
||||
),
|
||||
name="comment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
|
||||
IssueActivityAPIEndpoint.as_view(),
|
||||
IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
|
||||
IssueActivityAPIEndpoint.as_view(),
|
||||
IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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/<str:slug>/projects/<str:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(),
|
||||
name="users",
|
||||
)
|
||||
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/members/",
|
||||
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="workspace-members",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,40 +1,47 @@
|
|||
from django.urls import path
|
||||
|
||||
from plane.api.views import (
|
||||
ModuleAPIEndpoint,
|
||||
ModuleIssueAPIEndpoint,
|
||||
ModuleListCreateAPIEndpoint,
|
||||
ModuleDetailAPIEndpoint,
|
||||
ModuleIssueListCreateAPIEndpoint,
|
||||
ModuleIssueDetailAPIEndpoint,
|
||||
ModuleArchiveUnarchiveAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/",
|
||||
ModuleAPIEndpoint.as_view(),
|
||||
ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="modules",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
|
||||
ModuleAPIEndpoint.as_view(),
|
||||
name="modules",
|
||||
ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="modules-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
|
||||
ModuleIssueAPIEndpoint.as_view(),
|
||||
ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="module-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
|
||||
ModuleIssueAPIEndpoint.as_view(),
|
||||
name="module-issues",
|
||||
ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]),
|
||||
name="module-issues-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/archive/",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||
name="module-archive-unarchive",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]),
|
||||
name="module-archive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(),
|
||||
name="module-archive-unarchive",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="module-archive-list",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-modules/<uuid:pk>/unarchive/",
|
||||
ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]),
|
||||
name="module-unarchive",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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/<str:slug>/projects/", ProjectAPIEndpoint.as_view(), name="project"
|
||||
"workspaces/<str:slug>/projects/",
|
||||
ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:pk>/",
|
||||
ProjectAPIEndpoint.as_view(),
|
||||
ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archive/",
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(),
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(
|
||||
http_method_names=["post", "delete"]
|
||||
),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
20
apps/api/plane/api/urls/schema.py
Normal file
20
apps/api/plane/api/urls/schema.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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/<str:slug>/projects/<uuid:project_id>/states/",
|
||||
StateAPIEndpoint.as_view(),
|
||||
StateListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
name="states",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
|
||||
StateAPIEndpoint.as_view(),
|
||||
StateDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||
name="states",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
11
apps/api/plane/api/urls/user.py
Normal file
11
apps/api/plane/api/urls/user.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
629
apps/api/plane/api/views/asset.py
Normal file
629
apps/api/plane/api/views/asset.py
Normal file
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
37
apps/api/plane/api/views/user.py
Normal file
37
apps/api/plane/api/views/user.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ from plane.db.models import (
|
|||
IssueView,
|
||||
ProjectPage,
|
||||
Workspace,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import time
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from plane.db.models import Workspace
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
272
apps/api/plane/settings/openapi.py
Normal file
272
apps/api/plane/settings/openapi.py
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from django.utils import timezone
|
|||
from rest_framework import status
|
||||
from django.test import Client
|
||||
from django.core.exceptions import ValidationError
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from plane.db.models import User
|
||||
from plane.settings.redis import redis_instance
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
102
apps/api/plane/utils/openapi/README.md
Normal file
102
apps/api/plane/utils/openapi/README.md
Normal file
|
|
@ -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
|
||||
315
apps/api/plane/utils/openapi/__init__.py
Normal file
315
apps/api/plane/utils/openapi/__init__.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
29
apps/api/plane/utils/openapi/auth.py
Normal file
29
apps/api/plane/utils/openapi/auth.py
Normal file
|
|
@ -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.",
|
||||
}
|
||||
264
apps/api/plane/utils/openapi/decorators.py
Normal file
264
apps/api/plane/utils/openapi/decorators.py
Normal file
|
|
@ -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))
|
||||
816
apps/api/plane/utils/openapi/examples.py
Normal file
816
apps/api/plane/utils/openapi/examples.py
Normal file
|
|
@ -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": "<p>New comment content</p>",
|
||||
"external_id": "1234567890",
|
||||
"external_source": "github",
|
||||
},
|
||||
description="Example request for creating an issue comment",
|
||||
)
|
||||
|
||||
ISSUE_COMMENT_UPDATE_EXAMPLE = OpenApiExample(
|
||||
"IssueCommentCreateSerializer",
|
||||
value={
|
||||
"comment_html": "<p>Updated comment content</p>",
|
||||
"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": "<p>This issue has been resolved by implementing OAuth 2.0 flow.</p>",
|
||||
"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": "<p>This issue needs more investigation. I'll look into the database connection timeout.</p>",
|
||||
"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)
|
||||
56
apps/api/plane/utils/openapi/hooks.py
Normal file
56
apps/api/plane/utils/openapi/hooks.py
Normal file
|
|
@ -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}")
|
||||
493
apps/api/plane/utils/openapi/parameters.py
Normal file
493
apps/api/plane/utils/openapi/parameters.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
],
|
||||
)
|
||||
492
apps/api/plane/utils/openapi/responses.py
Normal file
492
apps/api/plane/utils/openapi/responses.py
Normal file
|
|
@ -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"})
|
||||
],
|
||||
)
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue