diff --git a/apps/api/plane/api/apps.py b/apps/api/plane/api/apps.py index 6ba36e7e558..b48a9a949de 100644 --- a/apps/api/plane/api/apps.py +++ b/apps/api/plane/api/apps.py @@ -3,3 +3,10 @@ class ApiConfig(AppConfig): name = "plane.api" + + def ready(self): + # Import authentication extensions to register them with drf-spectacular + try: + import plane.utils.openapi.auth # noqa + except ImportError: + pass \ No newline at end of file diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 8c84b2328f5..7596915eb40 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -1,8 +1,14 @@ from .user import UserLiteSerializer from .workspace import WorkspaceLiteSerializer -from .project import ProjectSerializer, ProjectLiteSerializer +from .project import ( + ProjectSerializer, + ProjectLiteSerializer, + ProjectCreateSerializer, + ProjectUpdateSerializer, +) from .issue import ( IssueSerializer, + LabelCreateUpdateSerializer, LabelSerializer, IssueLinkSerializer, IssueCommentSerializer, @@ -10,9 +16,40 @@ IssueActivitySerializer, IssueExpandSerializer, IssueLiteSerializer, + IssueAttachmentUploadSerializer, + IssueSearchSerializer, + IssueCommentCreateSerializer, + IssueLinkCreateSerializer, + IssueLinkUpdateSerializer, ) from .state import StateLiteSerializer, StateSerializer -from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer -from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer -from .intake import IntakeIssueSerializer +from .cycle import ( + CycleSerializer, + CycleIssueSerializer, + CycleLiteSerializer, + CycleIssueRequestSerializer, + TransferCycleIssueRequestSerializer, + CycleCreateSerializer, + CycleUpdateSerializer, +) +from .module import ( + ModuleSerializer, + ModuleIssueSerializer, + ModuleLiteSerializer, + ModuleIssueRequestSerializer, + ModuleCreateSerializer, + ModuleUpdateSerializer, +) +from .intake import ( + IntakeIssueSerializer, + IntakeIssueCreateSerializer, + IntakeIssueUpdateSerializer, +) from .estimate import EstimatePointSerializer +from .asset import ( + UserAssetUploadSerializer, + AssetUpdateSerializer, + GenericAssetUploadSerializer, + GenericAssetUpdateSerializer, + FileAssetSerializer, +) diff --git a/apps/api/plane/api/serializers/asset.py b/apps/api/plane/api/serializers/asset.py new file mode 100644 index 00000000000..b63dc7ebb4c --- /dev/null +++ b/apps/api/plane/api/serializers/asset.py @@ -0,0 +1,123 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import FileAsset + + +class UserAssetUploadSerializer(serializers.Serializer): + """ + Serializer for user asset upload requests. + + This serializer validates the metadata required to generate a presigned URL + for uploading user profile assets (avatar or cover image) directly to S3 storage. + Supports JPEG, PNG, WebP, JPG, and GIF image formats with size validation. + """ + + name = serializers.CharField(help_text="Original filename of the asset") + type = serializers.ChoiceField( + choices=[ + ("image/jpeg", "JPEG"), + ("image/png", "PNG"), + ("image/webp", "WebP"), + ("image/jpg", "JPG"), + ("image/gif", "GIF"), + ], + default="image/jpeg", + help_text="MIME type of the file", + style={"placeholder": "image/jpeg"}, + ) + size = serializers.IntegerField(help_text="File size in bytes") + entity_type = serializers.ChoiceField( + choices=[ + (FileAsset.EntityTypeContext.USER_AVATAR, "User Avatar"), + (FileAsset.EntityTypeContext.USER_COVER, "User Cover"), + ], + help_text="Type of user asset", + ) + + +class AssetUpdateSerializer(serializers.Serializer): + """ + Serializer for asset status updates after successful upload completion. + + Handles post-upload asset metadata updates including attribute modifications + and upload confirmation for S3-based file storage workflows. + """ + + attributes = serializers.JSONField( + required=False, help_text="Additional attributes to update for the asset" + ) + + +class GenericAssetUploadSerializer(serializers.Serializer): + """ + Serializer for generic asset upload requests with project association. + + Validates metadata for generating presigned URLs for workspace assets including + project association, external system tracking, and file validation for + document management and content storage workflows. + """ + + name = serializers.CharField(help_text="Original filename of the asset") + type = serializers.CharField(required=False, help_text="MIME type of the file") + size = serializers.IntegerField(help_text="File size in bytes") + project_id = serializers.UUIDField( + required=False, + help_text="UUID of the project to associate with the asset", + style={"placeholder": "123e4567-e89b-12d3-a456-426614174000"}, + ) + external_id = serializers.CharField( + required=False, + help_text="External identifier for the asset (for integration tracking)", + ) + external_source = serializers.CharField( + required=False, help_text="External source system (for integration tracking)" + ) + + +class GenericAssetUpdateSerializer(serializers.Serializer): + """ + Serializer for generic asset upload confirmation and status management. + + Handles post-upload status updates for workspace assets including + upload completion marking and metadata finalization. + """ + + is_uploaded = serializers.BooleanField( + default=True, help_text="Whether the asset has been successfully uploaded" + ) + + +class FileAssetSerializer(BaseSerializer): + """ + Comprehensive file asset serializer with complete metadata and URL generation. + + Provides full file asset information including storage metadata, access URLs, + relationship data, and upload status for complete asset management workflows. + """ + + asset_url = serializers.CharField(read_only=True) + + class Meta: + model = FileAsset + fields = "__all__" + read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", + "workspace", + "project", + "issue", + "comment", + "page", + "draft_issue", + "user", + "is_deleted", + "deleted_at", + "storage_metadata", + "asset_url", + ] diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py index 4b1e5470764..4f89a98c7ca 100644 --- a/apps/api/plane/api/serializers/base.py +++ b/apps/api/plane/api/serializers/base.py @@ -3,6 +3,13 @@ class BaseSerializer(serializers.ModelSerializer): + """ + Base serializer providing common functionality for all model serializers. + + Features field filtering, dynamic expansion of related fields, and standardized + primary key handling for consistent API responses across the application. + """ + id = serializers.PrimaryKeyRelatedField(read_only=True) def __init__(self, *args, **kwargs): diff --git a/apps/api/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py index 7a78b66649a..cf057d842ca 100644 --- a/apps/api/plane/api/serializers/cycle.py +++ b/apps/api/plane/api/serializers/cycle.py @@ -8,16 +8,13 @@ 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 @@ def __init__(self, *args, **kwargs): 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 @@ def validate(self, data): ) 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 Meta: 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 Meta: class CycleLiteSerializer(BaseSerializer): + """ + Lightweight cycle serializer for minimal data transfer. + + Provides essential cycle information without computed metrics, + optimized for list views and reference lookups. + """ + class Meta: model = Cycle fields = "__all__" + + +class CycleIssueRequestSerializer(serializers.Serializer): + """ + Serializer for bulk work item assignment to cycles. + + Validates work item ID lists for batch operations including + cycle assignment and sprint planning workflows. + """ + + issues = serializers.ListField( + child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle" + ) + + +class TransferCycleIssueRequestSerializer(serializers.Serializer): + """ + Serializer for transferring work items between cycles. + + Handles work item migration between cycles including validation + and relationship updates for sprint reallocation workflows. + """ + + new_cycle_id = serializers.UUIDField( + help_text="ID of the target cycle to transfer issues to" + ) diff --git a/apps/api/plane/api/serializers/estimate.py b/apps/api/plane/api/serializers/estimate.py index 0d9235dadd6..b670006d53d 100644 --- a/apps/api/plane/api/serializers/estimate.py +++ b/apps/api/plane/api/serializers/estimate.py @@ -4,6 +4,13 @@ class EstimatePointSerializer(BaseSerializer): + """ + Serializer for project estimation points and story point values. + + Handles numeric estimation data for work item sizing and sprint planning, + providing standardized point values for project velocity calculations. + """ + class Meta: model = EstimatePoint fields = ["id", "value"] diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py index 69c85ed6156..32f8bf2dacd 100644 --- a/apps/api/plane/api/serializers/intake.py +++ b/apps/api/plane/api/serializers/intake.py @@ -1,11 +1,77 @@ # Module improts from .base import BaseSerializer from .issue import IssueExpandSerializer -from plane.db.models import IntakeIssue +from plane.db.models import IntakeIssue, Issue from rest_framework import serializers +class IssueForIntakeSerializer(BaseSerializer): + """ + Serializer for work item data within intake submissions. + + Handles essential work item fields for intake processing including + content validation and priority assignment for triage workflows. + """ + + class Meta: + model = Issue + fields = [ + "name", + "description", + "description_html", + "priority", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IntakeIssueCreateSerializer(BaseSerializer): + """ + Serializer for creating intake work items with embedded issue data. + + Manages intake work item creation including nested issue creation, + status assignment, and source tracking for issue queue management. + """ + + issue = IssueForIntakeSerializer(help_text="Issue data for the intake issue") + + class Meta: + model = IntakeIssue + fields = [ + "issue", + "intake", + "status", + "snoozed_till", + "duplicate_to", + "source", + "source_email", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + class IntakeIssueSerializer(BaseSerializer): + """ + Comprehensive serializer for intake work items with expanded issue details. + + Provides full intake work item data including embedded issue information, + status tracking, and triage metadata for issue queue management. + """ + issue_detail = IssueExpandSerializer(read_only=True, source="issue") inbox = serializers.UUIDField(source="intake.id", read_only=True) @@ -22,3 +88,53 @@ class Meta: "created_at", "updated_at", ] + + +class IntakeIssueUpdateSerializer(BaseSerializer): + """ + Serializer for updating intake work items and their associated issues. + + Handles intake work item modifications including status changes, triage decisions, + and embedded issue updates for issue queue processing workflows. + """ + + issue = IssueForIntakeSerializer( + required=False, help_text="Issue data to update in the intake issue" + ) + + class Meta: + model = IntakeIssue + fields = [ + "status", + "snoozed_till", + "duplicate_to", + "source", + "source_email", + "issue", + ] + read_only_fields = [ + "id", + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueDataSerializer(serializers.Serializer): + """ + Serializer for nested work item data in intake request payloads. + + Validates core work item fields within intake requests including + content formatting, priority levels, and metadata for issue creation. + """ + + name = serializers.CharField(max_length=255, help_text="Issue name") + description_html = serializers.CharField( + required=False, allow_null=True, help_text="Issue description HTML" + ) + priority = serializers.ChoiceField( + choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority" + ) diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index f906d4085f3..e23a356da3f 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -34,6 +34,13 @@ 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 @@ def to_representation(self, instance): 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 Meta: ] -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 @@ def create(self, validated_data): ) 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 @@ def update(self, instance, validated_data): 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 Meta: ] +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 @@ def validate(self, data): 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 Meta: 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 Meta: 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 Meta: "created_at", "updated_at", ] + + +class IssueAttachmentUploadSerializer(serializers.Serializer): + """ + Serializer for work item attachment upload request validation. + + Handles file upload metadata validation including size, type, and external + integration tracking for secure work item document attachment workflows. + """ + + name = serializers.CharField(help_text="Original filename of the asset") + type = serializers.CharField(required=False, help_text="MIME type of the file") + size = serializers.IntegerField(help_text="File size in bytes") + external_id = serializers.CharField( + required=False, + help_text="External identifier for the asset (for integration tracking)", + ) + external_source = serializers.CharField( + required=False, help_text="External source system (for integration tracking)" + ) + + +class IssueSearchSerializer(serializers.Serializer): + """ + Serializer for work item search result data formatting. + + Provides standardized search result structure including work item identifiers, + project context, and workspace information for search API responses. + """ + + id = serializers.CharField(required=True, help_text="Issue ID") + name = serializers.CharField(required=True, help_text="Issue name") + sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID") + project__identifier = serializers.CharField( + required=True, help_text="Project identifier" + ) + project_id = serializers.CharField(required=True, help_text="Project ID") + workspace__slug = serializers.CharField(required=True, help_text="Workspace slug") diff --git a/apps/api/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py index ace4e15c84b..1673869974e 100644 --- a/apps/api/plane/api/serializers/module.py +++ b/apps/api/plane/api/serializers/module.py @@ -13,24 +13,33 @@ ) -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 Meta: "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 @@ def create(self, validated_data): 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 @@ def update(self, instance, validated_data): 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 Meta: 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 @@ def create(self, validated_data): class ModuleLiteSerializer(BaseSerializer): + """ + Lightweight module serializer for minimal data transfer. + + Provides essential module information without computed metrics, + optimized for list views and reference lookups. + """ + class Meta: model = Module fields = "__all__" + + +class ModuleIssueRequestSerializer(serializers.Serializer): + """ + Serializer for bulk work item assignment to modules. + + Validates work item ID lists for batch operations including + module assignment and work item organization workflows. + """ + + issues = serializers.ListField( + child=serializers.UUIDField(), + help_text="List of issue IDs to add to the module", + ) diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index c76652e1e7c..e0b62484034 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -2,12 +2,146 @@ from rest_framework import serializers # Module imports -from plane.db.models import Project, ProjectIdentifier, WorkspaceMember +from plane.db.models import ( + Project, + ProjectIdentifier, + WorkspaceMember, + State, + Estimate, +) from .base import BaseSerializer +class ProjectCreateSerializer(BaseSerializer): + """ + Serializer for creating projects with workspace validation. + + Handles project creation including identifier validation, member verification, + and workspace association for new project initialization. + """ + + class Meta: + model = Project + fields = [ + "name", + "description", + "project_lead", + "default_assignee", + "identifier", + "icon_prop", + "emoji", + "cover_image", + "module_view", + "cycle_view", + "issue_views_view", + "page_view", + "intake_view", + "guest_view_all_features", + "archive_in", + "close_in", + "timezone", + ] + + read_only_fields = [ + "id", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + def validate(self, data): + if data.get("project_lead", None) is not None: + # Check if the project lead is a member of the workspace + if not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("project_lead"), + ).exists(): + raise serializers.ValidationError( + "Project lead should be a user in the workspace" + ) + + if data.get("default_assignee", None) is not None: + # Check if the default assignee is a member of the workspace + if not WorkspaceMember.objects.filter( + workspace_id=self.context["workspace_id"], + member_id=data.get("default_assignee"), + ).exists(): + raise serializers.ValidationError( + "Default assignee should be a user in the workspace" + ) + + return data + + def create(self, validated_data): + identifier = validated_data.get("identifier", "").strip().upper() + if identifier == "": + raise serializers.ValidationError(detail="Project Identifier is required") + + if ProjectIdentifier.objects.filter( + name=identifier, workspace_id=self.context["workspace_id"] + ).exists(): + raise serializers.ValidationError(detail="Project Identifier is taken") + + project = Project.objects.create( + **validated_data, workspace_id=self.context["workspace_id"] + ) + return project + + +class ProjectUpdateSerializer(ProjectCreateSerializer): + """ + Serializer for updating projects with enhanced state and estimation management. + + Extends project creation with update-specific validations including default state + assignment, estimation configuration, and project setting modifications. + """ + + class Meta(ProjectCreateSerializer.Meta): + model = Project + fields = ProjectCreateSerializer.Meta.fields + [ + "default_state", + "estimate", + ] + + read_only_fields = ProjectCreateSerializer.Meta.read_only_fields + + def update(self, instance, validated_data): + """Update a project""" + if ( + validated_data.get("default_state", None) is not None + and not State.objects.filter( + project=instance, id=validated_data.get("default_state") + ).exists() + ): + # Check if the default state is a state in the project + raise serializers.ValidationError( + "Default state should be a state in the project" + ) + + if ( + validated_data.get("estimate", None) is not None + and not Estimate.objects.filter( + project=instance, id=validated_data.get("estimate") + ).exists() + ): + # Check if the estimate is a estimate in the project + raise serializers.ValidationError( + "Estimate should be a estimate in the project" + ) + return super().update(instance, validated_data) + + class ProjectSerializer(BaseSerializer): + """ + Comprehensive project serializer with metrics and member context. + + Provides complete project data including member counts, cycle/module totals, + deployment status, and user-specific context for project management. + """ + total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True) @@ -81,6 +215,13 @@ def create(self, validated_data): class ProjectLiteSerializer(BaseSerializer): + """ + Lightweight project serializer for minimal data transfer. + + Provides essential project information including identifiers, visual properties, + and basic metadata optimized for list views and references. + """ + cover_image_url = serializers.CharField(read_only=True) class Meta: diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py index 85b4c41edee..150c238fcfb 100644 --- a/apps/api/plane/api/serializers/state.py +++ b/apps/api/plane/api/serializers/state.py @@ -4,6 +4,13 @@ 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 Meta: "workspace", "project", "deleted_at", + "slug", ] class StateLiteSerializer(BaseSerializer): + """ + Lightweight state serializer for minimal data transfer. + + Provides essential state information including visual properties + and grouping data optimized for UI display and filtering. + """ + class Meta: model = State fields = ["id", "name", "color", "group"] diff --git a/apps/api/plane/api/serializers/user.py b/apps/api/plane/api/serializers/user.py index b266d7d545b..805eb9fe1e9 100644 --- a/apps/api/plane/api/serializers/user.py +++ b/apps/api/plane/api/serializers/user.py @@ -1,3 +1,5 @@ +from rest_framework import serializers + # Module imports from plane.db.models import User @@ -5,6 +7,18 @@ class UserLiteSerializer(BaseSerializer): + """ + Lightweight user serializer for minimal data transfer. + + Provides essential user information including names, avatar, and contact details + optimized for member lists, assignee displays, and user references. + """ + + avatar_url = serializers.CharField( + help_text="Avatar URL", + read_only=True, + ) + class Meta: model = User fields = [ diff --git a/apps/api/plane/api/serializers/workspace.py b/apps/api/plane/api/serializers/workspace.py index 84453b8e0cf..e98683c2fd2 100644 --- a/apps/api/plane/api/serializers/workspace.py +++ b/apps/api/plane/api/serializers/workspace.py @@ -4,7 +4,12 @@ class WorkspaceLiteSerializer(BaseSerializer): - """Lite serializer with only required fields""" + """ + Lightweight workspace serializer for minimal data transfer. + + Provides essential workspace identifiers including name, slug, and ID + optimized for navigation, references, and performance-critical operations. + """ class Meta: model = Workspace diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index d9b55e20e12..ed187549d61 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -5,8 +5,11 @@ 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 @@ *module_patterns, *intake_patterns, *member_patterns, + *user_patterns, ] diff --git a/apps/api/plane/api/urls/asset.py b/apps/api/plane/api/urls/asset.py new file mode 100644 index 00000000000..5bdd4d914c6 --- /dev/null +++ b/apps/api/plane/api/urls/asset.py @@ -0,0 +1,40 @@ +from django.urls import path + +from plane.api.views import ( + UserAssetEndpoint, + UserServerAssetEndpoint, + GenericAssetEndpoint, +) + +urlpatterns = [ + path( + "assets/user-assets/", + UserAssetEndpoint.as_view(http_method_names=["post"]), + name="user-assets", + ), + path( + "assets/user-assets//", + UserAssetEndpoint.as_view(http_method_names=["patch", "delete"]), + name="user-assets-detail", + ), + path( + "assets/user-assets/server/", + UserServerAssetEndpoint.as_view(http_method_names=["post"]), + name="user-server-assets", + ), + path( + "assets/user-assets//server/", + UserServerAssetEndpoint.as_view(http_method_names=["patch", "delete"]), + name="user-server-assets-detail", + ), + path( + "workspaces//assets/", + GenericAssetEndpoint.as_view(http_method_names=["post"]), + name="generic-asset", + ), + path( + "workspaces//assets//", + GenericAssetEndpoint.as_view(http_method_names=["get", "patch"]), + name="generic-asset-detail", + ), +] diff --git a/apps/api/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py index b0ae21174ca..bd7136aa2de 100644 --- a/apps/api/plane/api/urls/cycle.py +++ b/apps/api/plane/api/urls/cycle.py @@ -1,8 +1,10 @@ from django.urls import path from plane.api.views.cycle import ( - CycleAPIEndpoint, - CycleIssueAPIEndpoint, + CycleListCreateAPIEndpoint, + CycleDetailAPIEndpoint, + CycleIssueListCreateAPIEndpoint, + CycleIssueDetailAPIEndpoint, TransferCycleIssueAPIEndpoint, CycleArchiveUnarchiveAPIEndpoint, ) @@ -10,37 +12,42 @@ urlpatterns = [ path( "workspaces//projects//cycles/", - CycleAPIEndpoint.as_view(), + CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="cycles", ), path( "workspaces//projects//cycles//", - CycleAPIEndpoint.as_view(), + CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="cycles", ), path( "workspaces//projects//cycles//cycle-issues/", - CycleIssueAPIEndpoint.as_view(), + CycleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="cycle-issues", ), path( "workspaces//projects//cycles//cycle-issues//", - CycleIssueAPIEndpoint.as_view(), + CycleIssueDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]), name="cycle-issues", ), path( "workspaces//projects//cycles//transfer-issues/", - TransferCycleIssueAPIEndpoint.as_view(), + TransferCycleIssueAPIEndpoint.as_view(http_method_names=["post"]), name="transfer-issues", ), path( "workspaces//projects//cycles//archive/", - CycleArchiveUnarchiveAPIEndpoint.as_view(), + CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]), name="cycle-archive-unarchive", ), path( "workspaces//projects//archived-cycles/", - CycleArchiveUnarchiveAPIEndpoint.as_view(), + CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]), + name="cycle-archive-unarchive", + ), + path( + "workspaces//projects//archived-cycles//unarchive/", + CycleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]), name="cycle-archive-unarchive", ), ] diff --git a/apps/api/plane/api/urls/intake.py b/apps/api/plane/api/urls/intake.py index 4ef41d5f022..6af4aa4a8f2 100644 --- a/apps/api/plane/api/urls/intake.py +++ b/apps/api/plane/api/urls/intake.py @@ -1,17 +1,22 @@ from django.urls import path -from plane.api.views import IntakeIssueAPIEndpoint +from plane.api.views import ( + IntakeIssueListCreateAPIEndpoint, + IntakeIssueDetailAPIEndpoint, +) urlpatterns = [ path( "workspaces//projects//intake-issues/", - IntakeIssueAPIEndpoint.as_view(), + IntakeIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="intake-issue", ), path( "workspaces//projects//intake-issues//", - IntakeIssueAPIEndpoint.as_view(), + IntakeIssueDetailAPIEndpoint.as_view( + http_method_names=["get", "patch", "delete"] + ), name="intake-issue", ), ] diff --git a/apps/api/plane/api/urls/issue.py b/apps/api/plane/api/urls/issue.py index 71ab39855cd..c8d1ea4afe1 100644 --- a/apps/api/plane/api/urls/issue.py +++ b/apps/api/plane/api/urls/issue.py @@ -1,79 +1,95 @@ from django.urls import path from plane.api.views import ( - IssueAPIEndpoint, - LabelAPIEndpoint, - IssueLinkAPIEndpoint, - IssueCommentAPIEndpoint, - IssueActivityAPIEndpoint, + IssueListCreateAPIEndpoint, + IssueDetailAPIEndpoint, + LabelListCreateAPIEndpoint, + LabelDetailAPIEndpoint, + IssueLinkListCreateAPIEndpoint, + IssueLinkDetailAPIEndpoint, + IssueCommentListCreateAPIEndpoint, + IssueCommentDetailAPIEndpoint, + IssueActivityListAPIEndpoint, + IssueActivityDetailAPIEndpoint, + IssueAttachmentListCreateAPIEndpoint, + IssueAttachmentDetailAPIEndpoint, WorkspaceIssueAPIEndpoint, - IssueAttachmentEndpoint, + IssueSearchEndpoint, ) urlpatterns = [ path( - "workspaces//issues/-/", - WorkspaceIssueAPIEndpoint.as_view(), + "workspaces//issues/search/", + IssueSearchEndpoint.as_view(http_method_names=["get"]), + name="issue-search", + ), + path( + "workspaces//issues/-/", + WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), name="issue-by-identifier", ), path( "workspaces//projects//issues/", - IssueAPIEndpoint.as_view(), + IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="issue", ), path( "workspaces//projects//issues//", - IssueAPIEndpoint.as_view(), + IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="issue", ), path( "workspaces//projects//labels/", - LabelAPIEndpoint.as_view(), + LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="label", ), path( "workspaces//projects//labels//", - LabelAPIEndpoint.as_view(), + LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="label", ), path( "workspaces//projects//issues//links/", - IssueLinkAPIEndpoint.as_view(), + IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="link", ), path( "workspaces//projects//issues//links//", - IssueLinkAPIEndpoint.as_view(), + IssueLinkDetailAPIEndpoint.as_view( + http_method_names=["get", "patch", "delete"] + ), name="link", ), path( "workspaces//projects//issues//comments/", - IssueCommentAPIEndpoint.as_view(), + IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="comment", ), path( "workspaces//projects//issues//comments//", - IssueCommentAPIEndpoint.as_view(), + IssueCommentDetailAPIEndpoint.as_view( + http_method_names=["get", "patch", "delete"] + ), name="comment", ), path( "workspaces//projects//issues//activities/", - IssueActivityAPIEndpoint.as_view(), + IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]), name="activity", ), path( "workspaces//projects//issues//activities//", - IssueActivityAPIEndpoint.as_view(), + IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]), name="activity", ), path( "workspaces//projects//issues//issue-attachments/", - IssueAttachmentEndpoint.as_view(), + IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="attachment", ), path( "workspaces//projects//issues//issue-attachments//", - IssueAttachmentEndpoint.as_view(), + IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "delete"]), name="issue-attachment", ), ] diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index 1ec9cddb3d6..14a09c832c3 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -1,11 +1,16 @@ from django.urls import path -from plane.api.views import ProjectMemberAPIEndpoint +from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint urlpatterns = [ path( "workspaces//projects//members/", - ProjectMemberAPIEndpoint.as_view(), - name="users", - ) + ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]), + name="project-members", + ), + path( + "workspaces//members/", + WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]), + name="workspace-members", + ), ] diff --git a/apps/api/plane/api/urls/module.py b/apps/api/plane/api/urls/module.py index a131f4d4f92..578f5c860c3 100644 --- a/apps/api/plane/api/urls/module.py +++ b/apps/api/plane/api/urls/module.py @@ -1,40 +1,47 @@ from django.urls import path from plane.api.views import ( - ModuleAPIEndpoint, - ModuleIssueAPIEndpoint, + ModuleListCreateAPIEndpoint, + ModuleDetailAPIEndpoint, + ModuleIssueListCreateAPIEndpoint, + ModuleIssueDetailAPIEndpoint, ModuleArchiveUnarchiveAPIEndpoint, ) urlpatterns = [ path( "workspaces//projects//modules/", - ModuleAPIEndpoint.as_view(), + ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="modules", ), path( "workspaces//projects//modules//", - ModuleAPIEndpoint.as_view(), - name="modules", + ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="modules-detail", ), path( "workspaces//projects//modules//module-issues/", - ModuleIssueAPIEndpoint.as_view(), + ModuleIssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="module-issues", ), path( "workspaces//projects//modules//module-issues//", - ModuleIssueAPIEndpoint.as_view(), - name="module-issues", + ModuleIssueDetailAPIEndpoint.as_view(http_method_names=["delete"]), + name="module-issues-detail", ), path( "workspaces//projects//modules//archive/", - ModuleArchiveUnarchiveAPIEndpoint.as_view(), - name="module-archive-unarchive", + ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post"]), + name="module-archive", ), path( "workspaces//projects//archived-modules/", - ModuleArchiveUnarchiveAPIEndpoint.as_view(), - name="module-archive-unarchive", + ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["get"]), + name="module-archive-list", + ), + path( + "workspaces//projects//archived-modules//unarchive/", + ModuleArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["delete"]), + name="module-unarchive", ), ] diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py index d35c2cdd5a0..4cfc5a19861 100644 --- a/apps/api/plane/api/urls/project.py +++ b/apps/api/plane/api/urls/project.py @@ -1,19 +1,27 @@ from django.urls import path -from plane.api.views import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint +from plane.api.views import ( + ProjectListCreateAPIEndpoint, + ProjectDetailAPIEndpoint, + ProjectArchiveUnarchiveAPIEndpoint, +) urlpatterns = [ path( - "workspaces//projects/", ProjectAPIEndpoint.as_view(), name="project" + "workspaces//projects/", + ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="project", ), path( "workspaces//projects//", - ProjectAPIEndpoint.as_view(), + ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="project", ), path( "workspaces//projects//archive/", - ProjectArchiveUnarchiveAPIEndpoint.as_view(), + ProjectArchiveUnarchiveAPIEndpoint.as_view( + http_method_names=["post", "delete"] + ), name="project-archive-unarchive", ), ] diff --git a/apps/api/plane/api/urls/schema.py b/apps/api/plane/api/urls/schema.py new file mode 100644 index 00000000000..781dbe9deb4 --- /dev/null +++ b/apps/api/plane/api/urls/schema.py @@ -0,0 +1,20 @@ +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) +from django.urls import path + +urlpatterns = [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), +] diff --git a/apps/api/plane/api/urls/state.py b/apps/api/plane/api/urls/state.py index b03f386e648..e35012a2009 100644 --- a/apps/api/plane/api/urls/state.py +++ b/apps/api/plane/api/urls/state.py @@ -1,16 +1,19 @@ from django.urls import path -from plane.api.views import StateAPIEndpoint +from plane.api.views import ( + StateListCreateAPIEndpoint, + StateDetailAPIEndpoint, +) urlpatterns = [ path( "workspaces//projects//states/", - StateAPIEndpoint.as_view(), + StateListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="states", ), path( "workspaces//projects//states//", - StateAPIEndpoint.as_view(), + StateDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="states", ), ] diff --git a/apps/api/plane/api/urls/user.py b/apps/api/plane/api/urls/user.py new file mode 100644 index 00000000000..461b083339e --- /dev/null +++ b/apps/api/plane/api/urls/user.py @@ -0,0 +1,11 @@ +from django.urls import path + +from plane.api.views import UserEndpoint + +urlpatterns = [ + path( + "users/me/", + UserEndpoint.as_view(http_method_names=["get"]), + name="users", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 2299f7ec5b9..8535d4858bc 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -1,30 +1,55 @@ -from .project import ProjectAPIEndpoint, ProjectArchiveUnarchiveAPIEndpoint +from .project import ( + ProjectListCreateAPIEndpoint, + ProjectDetailAPIEndpoint, + ProjectArchiveUnarchiveAPIEndpoint, +) -from .state import StateAPIEndpoint +from .state import ( + StateListCreateAPIEndpoint, + StateDetailAPIEndpoint, +) from .issue import ( WorkspaceIssueAPIEndpoint, - IssueAPIEndpoint, - LabelAPIEndpoint, - IssueLinkAPIEndpoint, - IssueCommentAPIEndpoint, - IssueActivityAPIEndpoint, - IssueAttachmentEndpoint, + IssueListCreateAPIEndpoint, + IssueDetailAPIEndpoint, + LabelListCreateAPIEndpoint, + LabelDetailAPIEndpoint, + IssueLinkListCreateAPIEndpoint, + IssueLinkDetailAPIEndpoint, + IssueCommentListCreateAPIEndpoint, + IssueCommentDetailAPIEndpoint, + IssueActivityListAPIEndpoint, + IssueActivityDetailAPIEndpoint, + IssueAttachmentListCreateAPIEndpoint, + IssueAttachmentDetailAPIEndpoint, + IssueSearchEndpoint, ) from .cycle import ( - CycleAPIEndpoint, - CycleIssueAPIEndpoint, + CycleListCreateAPIEndpoint, + CycleDetailAPIEndpoint, + CycleIssueListCreateAPIEndpoint, + CycleIssueDetailAPIEndpoint, TransferCycleIssueAPIEndpoint, CycleArchiveUnarchiveAPIEndpoint, ) from .module import ( - ModuleAPIEndpoint, - ModuleIssueAPIEndpoint, + ModuleListCreateAPIEndpoint, + ModuleDetailAPIEndpoint, + ModuleIssueListCreateAPIEndpoint, + ModuleIssueDetailAPIEndpoint, ModuleArchiveUnarchiveAPIEndpoint, ) -from .member import ProjectMemberAPIEndpoint +from .member import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint + +from .intake import ( + IntakeIssueListCreateAPIEndpoint, + IntakeIssueDetailAPIEndpoint, +) + +from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint -from .intake import IntakeIssueAPIEndpoint +from .user import UserEndpoint diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py new file mode 100644 index 00000000000..061a790105d --- /dev/null +++ b/apps/api/plane/api/views/asset.py @@ -0,0 +1,629 @@ +# Python Imports +import uuid + +# Django Imports +from django.utils import timezone +from django.conf import settings + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes + +# Module Imports +from plane.bgtasks.storage_metadata_task import get_asset_object_metadata +from plane.settings.storage import S3Storage +from plane.db.models import FileAsset, User, Workspace +from plane.api.views.base import BaseAPIView +from plane.api.serializers import ( + UserAssetUploadSerializer, + AssetUpdateSerializer, + GenericAssetUploadSerializer, + GenericAssetUpdateSerializer, +) +from plane.utils.openapi import ( + ASSET_ID_PARAMETER, + WORKSPACE_SLUG_PARAMETER, + PRESIGNED_URL_SUCCESS_RESPONSE, + GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE, + GENERIC_ASSET_VALIDATION_ERROR_RESPONSE, + ASSET_CONFLICT_RESPONSE, + ASSET_DOWNLOAD_SUCCESS_RESPONSE, + ASSET_DOWNLOAD_ERROR_RESPONSE, + ASSET_UPDATED_RESPONSE, + ASSET_DELETED_RESPONSE, + VALIDATION_ERROR_RESPONSE, + ASSET_NOT_FOUND_RESPONSE, + NOT_FOUND_RESPONSE, + UNAUTHORIZED_RESPONSE, + asset_docs, +) +from plane.utils.exception_logger import log_exception + + +class UserAssetEndpoint(BaseAPIView): + """This endpoint is used to upload user profile images.""" + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + if asset is None: + return + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save(update_fields=["is_deleted", "deleted_at"]) + return + + def entity_asset_delete(self, entity_type, asset, request): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar_asset_id = None + user.save() + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image_asset_id = None + user.save() + return + return + + @asset_docs( + operation_id="create_user_asset_upload", + summary="Generate presigned URL for user asset upload", + description="Generate presigned URL for user asset upload", + request=OpenApiRequest( + request=UserAssetUploadSerializer, + examples=[ + OpenApiExample( + "User Avatar Upload", + value={ + "name": "profile.jpg", + "type": "image/jpeg", + "size": 1024000, + "entity_type": "USER_AVATAR", + }, + description="Example request for uploading a user avatar", + ), + OpenApiExample( + "User Cover Upload", + value={ + "name": "cover.jpg", + "type": "image/jpeg", + "size": 1024000, + "entity_type": "USER_COVER", + }, + description="Example request for uploading a user cover", + ), + ], + ), + responses={ + 200: PRESIGNED_URL_SUCCESS_RESPONSE, + 400: VALIDATION_ERROR_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + }, + ) + def post(self, request): + """Generate presigned URL for user asset upload. + + Create a presigned URL for uploading user profile assets (avatar or cover image). + This endpoint generates the necessary credentials for direct S3 upload. + """ + # get the asset key + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", False) + + # Check if the file size is within the limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Check if the entity type is allowed + if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + user=request.user, + created_by=request.user, + entity_type=entity_type, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @asset_docs( + operation_id="update_user_asset", + summary="Mark user asset as uploaded", + description="Mark user asset as uploaded", + parameters=[ASSET_ID_PARAMETER], + request=OpenApiRequest( + request=AssetUpdateSerializer, + examples=[ + OpenApiExample( + "Update Asset Attributes", + value={ + "attributes": { + "name": "updated_profile.jpg", + "type": "image/jpeg", + "size": 1024000, + }, + "entity_type": "USER_AVATAR", + }, + description="Example request for updating asset attributes", + ), + ], + ), + responses={ + 204: ASSET_UPDATED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, asset_id): + """Update user asset after upload completion. + + Update the asset status and attributes after the file has been uploaded to S3. + This endpoint should be called after completing the S3 upload to mark the asset as uploaded. + """ + # get the asset id + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save(update_fields=["is_uploaded", "attributes"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + @asset_docs( + operation_id="delete_user_asset", + summary="Delete user asset", + parameters=[ASSET_ID_PARAMETER], + responses={ + 204: ASSET_DELETED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, asset_id): + """Delete user asset. + + Delete a user profile asset (avatar or cover image) and remove its reference from the user profile. + This performs a soft delete by marking the asset as deleted and updating the user's profile. + """ + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserServerAssetEndpoint(BaseAPIView): + """This endpoint is used to upload user profile images.""" + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + if asset is None: + return + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save(update_fields=["is_deleted", "deleted_at"]) + return + + def entity_asset_delete(self, entity_type, asset, request): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar_asset_id = None + user.save() + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image_asset_id = None + user.save() + return + return + + @asset_docs( + operation_id="create_user_server_asset_upload", + summary="Generate presigned URL for user server asset upload", + request=UserAssetUploadSerializer, + responses={ + 200: PRESIGNED_URL_SUCCESS_RESPONSE, + 400: VALIDATION_ERROR_RESPONSE, + }, + ) + def post(self, request): + """Generate presigned URL for user server asset upload. + + Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials. + This endpoint generates the necessary credentials for direct S3 upload with server-side authentication. + """ + # get the asset key + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", False) + + # Check if the file size is within the limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Check if the entity type is allowed + if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]: + return Response( + {"error": "Invalid entity type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = [ + "image/jpeg", + "image/png", + "image/webp", + "image/jpg", + "image/gif", + ] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + user=request.user, + created_by=request.user, + entity_type=entity_type, + ) + + # Get the presigned URL + storage = S3Storage(request=request, is_server=True) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @asset_docs( + operation_id="update_user_server_asset", + summary="Mark user server asset as uploaded", + parameters=[ASSET_ID_PARAMETER], + request=AssetUpdateSerializer, + responses={ + 204: ASSET_UPDATED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, asset_id): + """Update user server asset after upload completion. + + Update the asset status and attributes after the file has been uploaded to S3 using server credentials. + This endpoint should be called after completing the S3 upload to mark the asset as uploaded. + """ + # get the asset id + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save(update_fields=["is_uploaded", "attributes"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + @asset_docs( + operation_id="delete_user_server_asset", + summary="Delete user server asset", + parameters=[ASSET_ID_PARAMETER], + responses={ + 204: ASSET_DELETED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, asset_id): + """Delete user server asset. + + Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile. + This performs a soft delete by marking the asset as deleted and updating the user's profile. + """ + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete( + entity_type=asset.entity_type, asset=asset, request=request + ) + asset.save(update_fields=["is_deleted", "deleted_at"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class GenericAssetEndpoint(BaseAPIView): + """This endpoint is used to upload generic assets that can be later bound to entities.""" + + @asset_docs( + operation_id="get_generic_asset", + summary="Get presigned URL for asset download", + description="Get presigned URL for asset download", + parameters=[WORKSPACE_SLUG_PARAMETER], + responses={ + 200: ASSET_DOWNLOAD_SUCCESS_RESPONSE, + 400: ASSET_DOWNLOAD_ERROR_RESPONSE, + 404: ASSET_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, asset_id): + """Get presigned URL for asset download. + + Generate a presigned URL for downloading a generic asset. + The asset must be uploaded and associated with the specified workspace. + """ + try: + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # Get the asset + asset = FileAsset.objects.get( + id=asset_id, workspace_id=workspace.id, is_deleted=False + ) + + # Check if the asset exists and is uploaded + if not asset.is_uploaded: + return Response( + {"error": "Asset not yet uploaded"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Generate presigned URL for GET + storage = S3Storage(request=request, is_server=True) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, filename=asset.attributes.get("name") + ) + + return Response( + { + "asset_id": str(asset.id), + "asset_url": presigned_url, + "asset_name": asset.attributes.get("name", ""), + "asset_type": asset.attributes.get("type", ""), + }, + status=status.HTTP_200_OK, + ) + + except Workspace.DoesNotExist: + return Response( + {"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND + ) + except FileAsset.DoesNotExist: + return Response( + {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + log_exception(e) + return Response( + {"error": "Internal server error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @asset_docs( + operation_id="create_generic_asset_upload", + summary="Generate presigned URL for generic asset upload", + description="Generate presigned URL for generic asset upload", + parameters=[WORKSPACE_SLUG_PARAMETER], + request=OpenApiRequest( + request=GenericAssetUploadSerializer, + examples=[ + OpenApiExample( + "GenericAssetUploadSerializer", + value={ + "name": "image.jpg", + "type": "image/jpeg", + "size": 1024000, + "project_id": "123e4567-e89b-12d3-a456-426614174000", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for uploading a generic asset", + ), + ], + ), + responses={ + 200: GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE, + 400: GENERIC_ASSET_VALIDATION_ERROR_RESPONSE, + 404: NOT_FOUND_RESPONSE, + 409: ASSET_CONFLICT_RESPONSE, + }, + ) + def post(self, request, slug): + """Generate presigned URL for generic asset upload. + + Create a presigned URL for uploading generic assets that can be bound to entities like work items. + Supports various file types and includes external source tracking for integrations. + """ + name = request.data.get("name") + type = request.data.get("type") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + project_id = request.data.get("project_id") + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + + # Check if the request is valid + if not name or not size: + return Response( + {"error": "Name and size are required fields.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file size is within the limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Check if the file type is allowed + if not type or type not in settings.ATTACHMENT_MIME_TYPES: + return Response( + {"error": "Invalid file type.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Check for existing asset with same external details if provided + if external_id and external_source: + existing_asset = FileAsset.objects.filter( + workspace__slug=slug, + external_source=external_source, + external_id=external_id, + is_deleted=False, + ).first() + + if existing_asset: + return Response( + { + "message": "Asset with same external id and source already exists", + "asset_id": str(existing_asset.id), + "asset_url": existing_asset.asset_url, + }, + status=status.HTTP_409_CONFLICT, + ) + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={"name": name, "type": type, "size": size_limit}, + asset=asset_key, + size=size_limit, + workspace_id=workspace.id, + project_id=project_id, + created_by=request.user, + external_id=external_id, + external_source=external_source, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues + ) + + # Get the presigned URL + storage = S3Storage(request=request, is_server=True) + presigned_url = storage.generate_presigned_post( + object_name=asset_key, file_type=type, file_size=size_limit + ) + + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @asset_docs( + operation_id="update_generic_asset", + summary="Update generic asset after upload completion", + description="Update generic asset after upload completion", + parameters=[WORKSPACE_SLUG_PARAMETER, ASSET_ID_PARAMETER], + request=OpenApiRequest( + request=GenericAssetUpdateSerializer, + examples=[ + OpenApiExample( + "GenericAssetUpdateSerializer", + value={"is_uploaded": True}, + description="Example request for updating a generic asset", + ) + ], + ), + responses={ + 204: ASSET_UPDATED_RESPONSE, + 404: ASSET_NOT_FOUND_RESPONSE, + }, + ) + def patch(self, request, slug, asset_id): + """Update generic asset after upload completion. + + Update the asset status after the file has been uploaded to S3. + This endpoint should be called after completing the S3 upload to mark the asset as uploaded + and trigger metadata extraction. + """ + try: + asset = FileAsset.objects.get( + id=asset_id, workspace__slug=slug, is_deleted=False + ) + + # Update is_uploaded status + asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded) + + # Update storage metadata if not present + if not asset.storage_metadata: + get_asset_object_metadata.delay(asset_id=str(asset_id)) + + asset.save(update_fields=["is_uploaded"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + except FileAsset.DoesNotExist: + return Response( + {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND + ) diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index c79c2f853a3..a4c14cf0dbf 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -13,7 +13,7 @@ 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 @@ def initial(self, request, *args, **kwargs): timezone.deactivate() -class BaseAPIView(TimezoneMixin, APIView, BasePaginator): +class BaseAPIView(TimezoneMixin, GenericAPIView, BasePaginator): authentication_classes = [APIKeyAuthentication] permission_classes = [IsAuthenticated] diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py index 457671b93a1..e7a7b8fcc56 100644 --- a/apps/api/plane/api/views/cycle.py +++ b/apps/api/plane/api/views/cycle.py @@ -23,9 +23,18 @@ # 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.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 @@ def get_queryset(self): .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 @@ def get(self, request, slug, project_id, pk=None): ).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 @@ def post(self, request, slug, project_id): 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 @@ def post(self, request, slug, project_id): # 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 @@ def post(self, request, slug, project_id): 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 @@ def patch(self, request, slug, project_id, pk): 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 @@ def patch(self, request, slug, project_id, pk): # 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 @@ def delete(self, request, slug, project_id, pk): class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): + """Cycle Archive and Unarchive Endpoint""" + permission_classes = [ProjectEntityPermission] def get_queryset(self): @@ -509,7 +738,27 @@ def get_queryset(self): .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 @@ def get(self, request, slug, project_id): ).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 @@ def post(self, request, slug, project_id, cycle_id): ).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 @@ def delete(self, request, slug, project_id, cycle_id): 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 @@ def get_queryset(self): .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 @@ def get(self, request, slug, project_id, cycle_id, issue_id=None): ).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 @@ def post(self, request, slug, project_id, cycle_id): status=status.HTTP_200_OK, ) + +class CycleIssueDetailAPIEndpoint(BaseAPIView): + """ + This viewset automatically provides `list`, `create`, + and `destroy` actions related to cycle issues. + + """ + + serializer_class = CycleIssueSerializer + model = CycleIssue + webhook_event = "cycle_issue" + bulk = True + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + CycleIssue.objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(cycle_id=self.kwargs.get("cycle_id")) + .select_related("project") + .select_related("workspace") + .select_related("cycle") + .select_related("issue", "issue__state", "issue__project") + .prefetch_related("issue__assignees", "issue__labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @cycle_docs( + operation_id="retrieve_cycle_work_item", + summary="Retrieve cycle work item", + description="Retrieve details of a specific cycle work item.", + responses={ + 200: OpenApiResponse( + description="Cycle work items", + response=CycleIssueSerializer, + examples=[CYCLE_ISSUE_EXAMPLE], + ), + }, + ) + def get(self, request, slug, project_id, cycle_id, issue_id): + """Retrieve cycle work item + + Retrieve details of a specific cycle work item. + Returns paginated results with work item details, assignees, and labels. + """ + cycle_issue = CycleIssue.objects.get( + workspace__slug=slug, + project_id=project_id, + cycle_id=cycle_id, + issue_id=issue_id, + ) + serializer = CycleIssueSerializer( + cycle_issue, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @cycle_docs( + operation_id="delete_cycle_work_item", + summary="Delete cycle work item", + description="Remove a work item from a cycle while keeping the work item in the project.", + responses={ + 204: DELETED_RESPONSE, + }, + ) def delete(self, request, slug, project_id, cycle_id, issue_id): + """Remove cycle work item + + Remove a work item from a cycle while keeping the work item in the project. + Records the removal activity for tracking purposes. + """ cycle_issue = CycleIssue.objects.get( issue_id=issue_id, workspace__slug=slug, @@ -764,7 +1146,54 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] + @cycle_docs( + operation_id="transfer_cycle_work_items", + summary="Transfer cycle work items", + description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", + request=OpenApiRequest( + request=TransferCycleIssueRequestSerializer, + examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work items transferred successfully", + response={ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Success message", + "example": "Success", + }, + }, + }, + examples=[TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE], + ), + 400: OpenApiResponse( + description="Bad request", + response={ + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message", + "example": "New Cycle Id is required", + }, + }, + }, + examples=[ + TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE, + TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE, + ], + ), + }, + ) def post(self, request, slug, project_id, cycle_id): + """Transfer cycle issues + + Move incomplete issues from the current cycle to a new target cycle. + Captures progress snapshot and transfers only unfinished work items. + """ new_cycle_id = request.data.get("new_cycle_id", False) if not new_cycle_id: diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py index 93acb06649a..3ee977d2a56 100644 --- a/apps/api/plane/api/views/intake.py +++ b/apps/api/plane/api/views/intake.py @@ -12,30 +12,47 @@ # 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 - - -class IntakeIssueAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to intake issues. - - """ - - permission_classes = [ProjectLitePermission] +from plane.utils.openapi import ( + intake_docs, + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + INTAKE_ISSUE_CREATE_EXAMPLE, + INTAKE_ISSUE_UPDATE_EXAMPLE, + # Response Examples + INTAKE_ISSUE_EXAMPLE, + INVALID_REQUEST_RESPONSE, + DELETED_RESPONSE, +) + + +class IntakeIssueListCreateAPIEndpoint(BaseAPIView): + """Intake Work Item List and Create Endpoint""" 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 @@ def get_queryset(self): .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 @@ def get(self, request, slug, project_id, issue_id=None): ).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 @@ def post(self, request, slug, project_id): ) 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 @@ def patch(self, request, slug, project_id, issue_id): 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 @@ def patch(self, request, slug, project_id, issue_id): # 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 @@ def patch(self, request, slug, project_id, issue_id): 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 @@ def patch(self, request, slug, project_id, issue_id): 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 @@ def delete(self, request, slug, project_id, issue_id): ).exists() ): return Response( - {"error": "Only admin or creator can delete the issue"}, + {"error": "Only admin or creator can delete the work item"}, status=status.HTTP_403_FORBIDDEN, ) issue.delete() diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 6a5016bec46..5ae15ea2efb 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -1,6 +1,7 @@ # Python imports import json import uuid +import re # Django imports from django.core.serializers.json import DjangoJSONEncoder @@ -26,6 +27,16 @@ from rest_framework import status from rest_framework.response import Response +# drf-spectacular imports +from drf_spectacular.utils import ( + extend_schema, + OpenApiParameter, + OpenApiResponse, + OpenApiExample, + OpenApiRequest, +) +from drf_spectacular.types import OpenApiTypes + # Module imports from plane.api.serializers import ( IssueAttachmentSerializer, @@ -34,6 +45,12 @@ IssueLinkSerializer, IssueSerializer, LabelSerializer, + IssueAttachmentUploadSerializer, + IssueSearchSerializer, + IssueCommentCreateSerializer, + IssueLinkCreateSerializer, + IssueLinkUpdateSerializer, + LabelCreateUpdateSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -58,6 +75,74 @@ from .base import BaseAPIView from plane.utils.host import base_host from plane.bgtasks.webhook_task import model_activity + +from plane.utils.openapi import ( + work_item_docs, + label_docs, + issue_link_docs, + issue_comment_docs, + issue_activity_docs, + issue_attachment_docs, + WORKSPACE_SLUG_PARAMETER, + PROJECT_IDENTIFIER_PARAMETER, + ISSUE_IDENTIFIER_PARAMETER, + PROJECT_ID_PARAMETER, + ISSUE_ID_PARAMETER, + LABEL_ID_PARAMETER, + COMMENT_ID_PARAMETER, + LINK_ID_PARAMETER, + ATTACHMENT_ID_PARAMETER, + ACTIVITY_ID_PARAMETER, + PROJECT_ID_QUERY_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + SEARCH_PARAMETER, + SEARCH_PARAMETER_REQUIRED, + LIMIT_PARAMETER, + WORKSPACE_SEARCH_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + ISSUE_CREATE_EXAMPLE, + ISSUE_UPDATE_EXAMPLE, + ISSUE_UPSERT_EXAMPLE, + LABEL_CREATE_EXAMPLE, + LABEL_UPDATE_EXAMPLE, + ISSUE_LINK_CREATE_EXAMPLE, + ISSUE_LINK_UPDATE_EXAMPLE, + ISSUE_COMMENT_CREATE_EXAMPLE, + ISSUE_COMMENT_UPDATE_EXAMPLE, + ISSUE_ATTACHMENT_UPLOAD_EXAMPLE, + ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE, + # Response Examples + ISSUE_EXAMPLE, + LABEL_EXAMPLE, + ISSUE_LINK_EXAMPLE, + ISSUE_COMMENT_EXAMPLE, + ISSUE_ATTACHMENT_EXAMPLE, + ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE, + ISSUE_SEARCH_EXAMPLE, + WORK_ITEM_NOT_FOUND_RESPONSE, + ISSUE_NOT_FOUND_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, + DELETED_RESPONSE, + ADMIN_ONLY_RESPONSE, + LABEL_NOT_FOUND_RESPONSE, + LABEL_NAME_EXISTS_RESPONSE, + INVALID_REQUEST_RESPONSE, + LINK_NOT_FOUND_RESPONSE, + COMMENT_NOT_FOUND_RESPONSE, + ATTACHMENT_NOT_FOUND_RESPONSE, + BAD_SEARCH_REQUEST_RESPONSE, + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, +) from plane.bgtasks.work_item_link_task import crawl_work_item_link_title @@ -73,8 +158,8 @@ class WorkspaceIssueAPIEndpoint(BaseAPIView): serializer_class = IssueSerializer @property - def project__identifier(self): - return self.kwargs.get("project__identifier", None) + def project_identifier(self): + return self.kwargs.get("project_identifier", None) def get_queryset(self): return ( @@ -85,7 +170,7 @@ def get_queryset(self): .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project__identifier=self.kwargs.get("project__identifier")) + .filter(project__identifier=self.kwargs.get("project_identifier")) .select_related("project") .select_related("workspace") .select_related("state") @@ -95,8 +180,32 @@ def get_queryset(self): .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() - def get(self, request, slug, project__identifier=None, issue__identifier=None): - if issue__identifier and project__identifier: + @extend_schema( + operation_id="get_workspace_work_item", + summary="Retrieve work item by identifiers", + description="Retrieve a specific work item using workspace slug, project identifier, and issue identifier.", + tags=["Work Items"], + parameters=[ + WORKSPACE_SLUG_PARAMETER, + PROJECT_IDENTIFIER_PARAMETER, + ISSUE_IDENTIFIER_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item details", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_identifier=None, issue_identifier=None): + """Retrieve work item by identifiers + + Retrieve a specific work item using workspace slug, project identifier, and issue identifier. + This endpoint provides workspace-level access to work items. + """ + if issue_identifier and project_identifier: issue = Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() @@ -104,8 +213,8 @@ def get(self, request, slug, project__identifier=None, issue__identifier=None): .values("count") ).get( workspace__slug=slug, - project__identifier=project__identifier, - sequence_id=issue__identifier, + project__identifier=project_identifier, + sequence_id=issue_identifier, ) return Response( IssueSerializer(issue, fields=self.fields, expand=self.expand).data, @@ -113,11 +222,9 @@ def get(self, request, slug, project__identifier=None, issue__identifier=None): ) -class IssueAPIEndpoint(BaseAPIView): +class IssueListCreateAPIEndpoint(BaseAPIView): """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to issue. - + This viewset provides `list` and `create` on issue level """ model = Issue @@ -144,7 +251,37 @@ def get_queryset(self): .order_by(self.kwargs.get("order_by", "-created_at")) ).distinct() - def get(self, request, slug, project_id, pk=None): + @work_item_docs( + operation_id="list_work_items", + summary="List work items", + description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueSerializer, + "PaginatedWorkItemResponse", + "Paginated list of work items", + "Paginated Work Items", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id): + """List work items + + Retrieve a paginated list of all work items in a project. + Supports filtering, ordering, and field selection through query parameters. + """ + external_id = request.GET.get("external_id") external_source = request.GET.get("external_source") @@ -160,18 +297,6 @@ def get(self, request, slug, project_id, pk=None): status=status.HTTP_200_OK, ) - if pk: - issue = Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ).get(workspace__slug=slug, project_id=project_id, pk=pk) - return Response( - IssueSerializer(issue, fields=self.fields, expand=self.expand).data, - status=status.HTTP_200_OK, - ) - # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", "none"] state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] @@ -268,7 +393,31 @@ def get(self, request, slug, project_id, pk=None): ).data, ) + @work_item_docs( + operation_id="create_work_item", + summary="Create work item", + description="Create a new work item in the specified project with the provided details.", + request=OpenApiRequest( + request=IssueSerializer, + examples=[ISSUE_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Work Item created successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) def post(self, request, slug, project_id): + """Create work item + + Create a new work item in the specified project with the provided details. + Supports external ID tracking for integration purposes. + """ project = Project.objects.get(pk=project_id) serializer = IssueSerializer( @@ -338,7 +487,103 @@ def post(self, request, slug, project_id): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class IssueDetailAPIEndpoint(BaseAPIView): + """Issue Detail Endpoint""" + + model = Issue + webhook_event = "issue" + permission_classes = [ProjectEntityPermission] + serializer_class = IssueSerializer + + def get_queryset(self): + return ( + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("state") + .select_related("parent") + .prefetch_related("assignees") + .prefetch_related("labels") + .order_by(self.kwargs.get("order_by", "-created_at")) + ).distinct() + + @work_item_docs( + operation_id="retrieve_work_item", + summary="Retrieve work item", + description="Retrieve details of a specific work item.", + parameters=[ + PROJECT_ID_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="List of issues or issue details", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, pk): + """Retrieve work item + + Retrieve details of a specific work item. + Supports filtering, ordering, and field selection through query parameters. + """ + + issue = Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ).get(workspace__slug=slug, project_id=project_id, pk=pk) + return Response( + IssueSerializer(issue, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + @work_item_docs( + operation_id="put_work_item", + summary="Update or create work item", + description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.", + request=OpenApiRequest( + request=IssueSerializer, + examples=[ISSUE_UPSERT_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work Item updated successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 201: OpenApiResponse( + description="Work Item created successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) def put(self, request, slug, project_id): + """Update or create work item + + Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. + Requires external_id and external_source parameters for identification. + """ # Get the entities required for putting the issue, external_id and # external_source are must to identify the issue here project = Project.objects.get(pk=project_id) @@ -448,7 +693,34 @@ def put(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) - def patch(self, request, slug, project_id, pk=None): + @work_item_docs( + operation_id="update_work_item", + summary="Partially update work item", + description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.", + parameters=[ + PROJECT_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueSerializer, + examples=[ISSUE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work Item patched successfully", + response=IssueSerializer, + examples=[ISSUE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, pk): + """Update work item + + Partially update an existing work item with the provided fields. + Supports external ID validation to prevent conflicts. + """ issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) project = Project.objects.get(pk=project_id) current_instance = json.dumps( @@ -495,7 +767,25 @@ def patch(self, request, slug, project_id, pk=None): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, slug, project_id, pk=None): + @work_item_docs( + operation_id="delete_work_item", + summary="Delete work item", + description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.", + parameters=[ + PROJECT_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 403: ADMIN_ONLY_RESPONSE, + 404: WORK_ITEM_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete work item + + Permanently delete an existing work item from the project. + Only admins or the item creator can perform this action. + """ issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if issue.created_by_id != request.user.id and ( not ProjectMember.objects.filter( @@ -507,7 +797,7 @@ def delete(self, request, slug, project_id, pk=None): ).exists() ): return Response( - {"error": "Only admin or creator can delete the issue"}, + {"error": "Only admin or creator can delete the work item"}, status=status.HTTP_403_FORBIDDEN, ) current_instance = json.dumps( @@ -526,12 +816,8 @@ def delete(self, request, slug, project_id, pk=None): return Response(status=status.HTTP_204_NO_CONTENT) -class LabelAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to the labels. - - """ +class LabelListCreateAPIEndpoint(BaseAPIView): + """Label List and Create Endpoint""" serializer_class = LabelSerializer model = Label @@ -553,9 +839,31 @@ def get_queryset(self): .order_by(self.kwargs.get("order_by", "-created_at")) ) + @label_docs( + operation_id="create_label", + description="Create a new label in the specified project with name, color, and description.", + request=OpenApiRequest( + request=LabelCreateUpdateSerializer, + examples=[LABEL_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Label created successfully", + response=LabelSerializer, + examples=[LABEL_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 409: LABEL_NAME_EXISTS_RESPONSE, + }, + ) def post(self, request, slug, project_id): + """Create label + + Create a new label in the specified project with name, color, and description. + Supports external ID tracking for integration purposes. + """ try: - serializer = LabelSerializer(data=request.data) + serializer = LabelCreateUpdateSerializer(data=request.data) if serializer.is_valid(): if ( request.data.get("external_id") @@ -582,6 +890,8 @@ def post(self, request, slug, project_id): ) serializer.save(project_id=project_id) + label = Label.objects.get(pk=serializer.instance.id) + serializer = LabelSerializer(label) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError: @@ -598,22 +908,101 @@ def post(self, request, slug, project_id): status=status.HTTP_409_CONFLICT, ) - def get(self, request, slug, project_id, pk=None): - if pk is None: - return self.paginate( - request=request, - queryset=(self.get_queryset()), - on_results=lambda labels: LabelSerializer( - labels, many=True, fields=self.fields, expand=self.expand - ).data, - ) + @label_docs( + operation_id="list_labels", + description="Retrieve all labels in a project. Supports filtering by name and color.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + LabelSerializer, + "PaginatedLabelResponse", + "Paginated list of labels", + "Paginated Labels", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id): + """List labels + + Retrieve all labels in the project. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda labels: LabelSerializer( + labels, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + +class LabelDetailAPIEndpoint(BaseAPIView): + """Label Detail Endpoint""" + + serializer_class = LabelSerializer + model = Label + permission_classes = [ProjectMemberPermission] + + @label_docs( + operation_id="get_labels", + description="Retrieve details of a specific label.", + parameters=[ + LABEL_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Labels", + response=LabelSerializer, + examples=[LABEL_EXAMPLE], + ), + 404: LABEL_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, pk): + """Retrieve label + + Retrieve details of a specific label. + """ label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer(label, fields=self.fields, expand=self.expand) + serializer = LabelSerializer(label) return Response(serializer.data, status=status.HTTP_200_OK) - def patch(self, request, slug, project_id, pk=None): + @label_docs( + operation_id="update_label", + description="Partially update an existing label's properties like name, color, or description.", + parameters=[ + LABEL_ID_PARAMETER, + ], + request=OpenApiRequest( + request=LabelCreateUpdateSerializer, + examples=[LABEL_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Label updated successfully", + response=LabelSerializer, + examples=[LABEL_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: LABEL_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, pk): + """Update label + + Partially update an existing label's properties like name, color, or description. + Validates external ID uniqueness if provided. + """ label = self.get_queryset().get(pk=pk) - serializer = LabelSerializer(label, data=request.data, partial=True) + serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True) if serializer.is_valid(): if ( str(request.data.get("external_id")) @@ -635,21 +1024,140 @@ def patch(self, request, slug, project_id, pk=None): status=status.HTTP_409_CONFLICT, ) serializer.save() + label = Label.objects.get(pk=serializer.instance.id) + serializer = LabelSerializer(label) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, slug, project_id, pk=None): + @label_docs( + operation_id="delete_label", + description="Permanently remove a label from the project. This action cannot be undone.", + parameters=[ + LABEL_ID_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 404: LABEL_NOT_FOUND_RESPONSE, + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete label + + Permanently remove a label from the project. + This action cannot be undone. + """ label = self.get_queryset().get(pk=pk) label.delete() return Response(status=status.HTTP_204_NO_CONTENT) -class IssueLinkAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to the links of the particular issue. +class IssueLinkListCreateAPIEndpoint(BaseAPIView): + """Work Item Link List and Create Endpoint""" - """ + serializer_class = IssueLinkSerializer + model = IssueLink + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @issue_link_docs( + operation_id="list_work_item_links", + description="Retrieve all links associated with a work item. Supports filtering by URL, title, and metadata.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueLinkSerializer, + "PaginatedIssueLinkResponse", + "Paginated list of work item links", + "Paginated Work Item Links", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List work item links + + Retrieve all links associated with a work item. + """ + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_links: IssueLinkSerializer( + issue_links, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + @issue_link_docs( + operation_id="create_work_item_link", + description="Add a new external link to a work item with URL, title, and metadata.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueLinkCreateSerializer, + examples=[ISSUE_LINK_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Work item link created successfully", + response=IssueLinkSerializer, + examples=[ISSUE_LINK_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, issue_id): + """Create issue link + + Add a new external link to a work item with URL, title, and metadata. + Automatically tracks link creation activity. + """ + serializer = IssueLinkCreateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id, issue_id=issue_id) + crawl_work_item_link_title.delay( + serializer.instance.id, serializer.instance.url + ) + link = IssueLink.objects.get(pk=serializer.instance.id) + link.created_by_id = request.data.get("created_by", request.user.id) + link.save(update_fields=["created_by"]) + issue_activity.delay( + type="link.activity.created", + requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), + issue_id=str(self.kwargs.get("issue_id")), + project_id=str(self.kwargs.get("project_id")), + actor_id=str(link.created_by_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + ) + serializer = IssueLinkSerializer(link) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class IssueLinkDetailAPIEndpoint(BaseAPIView): + """Issue Link Detail Endpoint""" permission_classes = [ProjectEntityPermission] @@ -670,7 +1178,32 @@ def get_queryset(self): .distinct() ) - def get(self, request, slug, project_id, issue_id, pk=None): + @issue_link_docs( + operation_id="retrieve_work_item_link", + description="Retrieve details of a specific work item link.", + parameters=[ + ISSUE_ID_PARAMETER, + LINK_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueLinkSerializer, + "PaginatedIssueLinkDetailResponse", + "Work item link details or paginated list", + "Work Item Link Details", + ), + 404: OpenApiResponse(description="Issue not found"), + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve work item link + + Retrieve details of a specific work item link. + """ if pk is None: issue_links = self.get_queryset() serializer = IssueLinkSerializer( @@ -689,30 +1222,33 @@ def get(self, request, slug, project_id, issue_id, pk=None): ) return Response(serializer.data, status=status.HTTP_200_OK) - def post(self, request, slug, project_id, issue_id): - serializer = IssueLinkSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) - crawl_work_item_link_title.delay( - serializer.data.get("id"), serializer.data.get("url") - ) - - link = IssueLink.objects.get(pk=serializer.data["id"]) - link.created_by_id = request.data.get("created_by", request.user.id) - link.save(update_fields=["created_by"]) - issue_activity.delay( - type="link.activity.created", - requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), - issue_id=str(self.kwargs.get("issue_id")), - project_id=str(self.kwargs.get("project_id")), - actor_id=str(link.created_by_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + @issue_link_docs( + operation_id="update_issue_link", + description="Modify the URL, title, or metadata of an existing issue link.", + parameters=[ + ISSUE_ID_PARAMETER, + LINK_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueLinkUpdateSerializer, + examples=[ISSUE_LINK_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Issue link updated successfully", + response=IssueLinkSerializer, + examples=[ISSUE_LINK_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: LINK_NOT_FOUND_RESPONSE, + }, + ) def patch(self, request, slug, project_id, issue_id, pk): + """Update issue link + + Modify the URL, title, or metadata of an existing issue link. + Tracks all changes in issue activity logs. + """ issue_link = IssueLink.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) @@ -735,10 +1271,28 @@ def patch(self, request, slug, project_id, issue_id, pk): current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) + serializer = IssueLinkSerializer(issue_link) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @issue_link_docs( + operation_id="delete_work_item_link", + description="Permanently remove an external link from a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + LINK_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse(description="Work item link deleted successfully"), + 404: OpenApiResponse(description="Work item link not found"), + }, + ) def delete(self, request, slug, project_id, issue_id, pk): + """Delete work item link + + Permanently remove an external link from a work item. + Records deletion activity for audit purposes. + """ issue_link = IssueLink.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) @@ -758,12 +1312,8 @@ def delete(self, request, slug, project_id, issue_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueCommentAPIEndpoint(BaseAPIView): - """ - This viewset automatically provides `list`, `create`, `retrieve`, - `update` and `destroy` actions related to comments of the particular issue. - - """ +class IssueCommentListCreateAPIEndpoint(BaseAPIView): + """Issue Comment List and Create Endpoint""" serializer_class = IssueCommentSerializer model = IssueComment @@ -795,22 +1345,67 @@ def get_queryset(self): .distinct() ) - def get(self, request, slug, project_id, issue_id, pk=None): - if pk: - issue_comment = self.get_queryset().get(pk=pk) - serializer = IssueCommentSerializer( - issue_comment, fields=self.fields, expand=self.expand - ) - return Response(serializer.data, status=status.HTTP_200_OK) + @issue_comment_docs( + operation_id="list_work_item_comments", + description="Retrieve all comments for a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueCommentSerializer, + "PaginatedIssueCommentResponse", + "Paginated list of work item comments", + "Paginated Work Item Comments", + ), + 404: OpenApiResponse(description="Issue not found"), + }, + ) + def get(self, request, slug, project_id, issue_id): + """List work item comments + + Retrieve all comments for a work item. + """ return self.paginate( request=request, queryset=(self.get_queryset()), - on_results=lambda issue_comment: IssueCommentSerializer( - issue_comment, many=True, fields=self.fields, expand=self.expand + on_results=lambda issue_comments: IssueCommentSerializer( + issue_comments, many=True, fields=self.fields, expand=self.expand ).data, ) + @issue_comment_docs( + operation_id="create_work_item_comment", + description="Add a new comment to a work item with HTML content.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueCommentCreateSerializer, + examples=[ISSUE_COMMENT_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Work item comment created successfully", + response=IssueCommentSerializer, + examples=[ISSUE_COMMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) def post(self, request, slug, project_id, issue_id): + """Create work item comment + + Add a new comment to a work item with HTML content. + Supports external ID tracking for integration purposes. + """ # Validation check if the issue already exists if ( request.data.get("external_id") @@ -830,18 +1425,18 @@ def post(self, request, slug, project_id, issue_id): ).first() return Response( { - "error": "Issue Comment with the same external id and external source already exists", + "error": "Work item comment with the same external id and external source already exists", "id": str(issue_comment.id), }, status=status.HTTP_409_CONFLICT, ) - serializer = IssueCommentSerializer(data=request.data) + serializer = IssueCommentCreateSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, issue_id=issue_id, actor=request.user ) - issue_comment = IssueComment.objects.get(pk=serializer.data.get("id")) + issue_comment = IssueComment.objects.get(pk=serializer.instance.id) # Update the created_at and the created_by and save the comment issue_comment.created_at = request.data.get("created_at", timezone.now()) issue_comment.created_by_id = request.data.get( @@ -858,6 +1453,7 @@ def post(self, request, slug, project_id, issue_id): current_instance=None, epoch=int(timezone.now().timestamp()), ) + # Send the model activity model_activity.delay( model_name="issue_comment", @@ -868,10 +1464,101 @@ def post(self, request, slug, project_id, issue_id): slug=slug, origin=base_host(request=request, is_app=True), ) + + serializer = IssueCommentSerializer(issue_comment) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class IssueCommentDetailAPIEndpoint(BaseAPIView): + """Work Item Comment Detail Endpoint""" + + serializer_class = IssueCommentSerializer + model = IssueComment + webhook_event = "issue_comment" + permission_classes = [ProjectLitePermission] + + def get_queryset(self): + return ( + IssueComment.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("workspace", "project", "issue", "actor") + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + member_id=self.request.user.id, + is_active=True, + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @issue_comment_docs( + operation_id="retrieve_work_item_comment", + description="Retrieve details of a specific comment.", + parameters=[ + ISSUE_ID_PARAMETER, + COMMENT_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item comments", + response=IssueCommentSerializer, + examples=[ISSUE_COMMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve issue comment + + Retrieve details of a specific comment. + """ + issue_comment = self.get_queryset().get(pk=pk) + serializer = IssueCommentSerializer( + issue_comment, fields=self.fields, expand=self.expand + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @issue_comment_docs( + operation_id="update_work_item_comment", + description="Modify the content of an existing comment on a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + COMMENT_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueCommentCreateSerializer, + examples=[ISSUE_COMMENT_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Work item comment updated successfully", + response=IssueCommentSerializer, + examples=[ISSUE_COMMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: COMMENT_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) def patch(self, request, slug, project_id, issue_id, pk): + """Update work item comment + + Modify the content of an existing comment on a work item. + Validates external ID uniqueness if provided. + """ issue_comment = IssueComment.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) @@ -895,13 +1582,13 @@ def patch(self, request, slug, project_id, issue_id, pk): ): return Response( { - "error": "Issue Comment with the same external id and external source already exists", + "error": "Work item comment with the same external id and external source already exists", "id": str(issue_comment.id), }, status=status.HTTP_409_CONFLICT, ) - serializer = IssueCommentSerializer( + serializer = IssueCommentCreateSerializer( issue_comment, data=request.data, partial=True ) if serializer.is_valid(): @@ -925,10 +1612,30 @@ def patch(self, request, slug, project_id, issue_id, pk): slug=slug, origin=base_host(request=request, is_app=True), ) + + issue_comment = IssueComment.objects.get(pk=serializer.instance.id) + serializer = IssueCommentSerializer(issue_comment) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @issue_comment_docs( + operation_id="delete_work_item_comment", + description="Permanently remove a comment from a work item. Records deletion activity for audit purposes.", + parameters=[ + ISSUE_ID_PARAMETER, + COMMENT_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse(description="Work item comment deleted successfully"), + 404: COMMENT_NOT_FOUND_RESPONSE, + }, + ) def delete(self, request, slug, project_id, issue_id, pk): + """Delete issue comment + + Permanently remove a comment from a work item. + Records deletion activity for audit purposes. + """ issue_comment = IssueComment.objects.get( workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk ) @@ -948,10 +1655,37 @@ def delete(self, request, slug, project_id, issue_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) -class IssueActivityAPIEndpoint(BaseAPIView): +class IssueActivityListAPIEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] - def get(self, request, slug, project_id, issue_id, pk=None): + @issue_activity_docs( + operation_id="list_work_item_activities", + description="Retrieve all activities for a work item. Supports filtering by activity type and date range.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueActivitySerializer, + "PaginatedIssueActivityResponse", + "Paginated list of issue activities", + "Paginated Issue Activities", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List issue activities + + Retrieve chronological activity logs for an issue. + Excludes comment, vote, reaction, and draft activities. + """ issue_activities = ( IssueActivity.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id @@ -965,10 +1699,61 @@ def get(self, request, slug, project_id, issue_id, pk=None): .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) - if pk: - issue_activities = issue_activities.get(pk=pk) - serializer = IssueActivitySerializer(issue_activities) - return Response(serializer.data, status=status.HTTP_200_OK) + return self.paginate( + request=request, + queryset=(issue_activities), + on_results=lambda issue_activity: IssueActivitySerializer( + issue_activity, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + +class IssueActivityDetailAPIEndpoint(BaseAPIView): + """Issue Activity Detail Endpoint""" + + permission_classes = [ProjectEntityPermission] + + @issue_activity_docs( + operation_id="retrieve_work_item_activity", + description="Retrieve details of a specific activity.", + parameters=[ + ISSUE_ID_PARAMETER, + ACTIVITY_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueActivitySerializer, + "PaginatedIssueActivityDetailResponse", + "Paginated list of work item activities", + "Work Item Activity Details", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve issue activity + + Retrieve details of a specific activity. + Excludes comment, vote, reaction, and draft activities. + """ + issue_activities = ( + IssueActivity.objects.filter( + issue_id=issue_id, workspace__slug=slug, project_id=project_id + ) + .filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("actor", "workspace", "issue", "project") + ).order_by(request.GET.get("order_by", "created_at")) return self.paginate( request=request, @@ -979,12 +1764,93 @@ def get(self, request, slug, project_id, issue_id, pk=None): ) -class IssueAttachmentEndpoint(BaseAPIView): +class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): + """Issue Attachment List and Create Endpoint""" + serializer_class = IssueAttachmentSerializer - permission_classes = [ProjectEntityPermission] model = FileAsset + permission_classes = [ProjectEntityPermission] + @issue_attachment_docs( + operation_id="create_work_item_attachment", + description="Generate presigned URL for uploading file attachments to a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueAttachmentUploadSerializer, + examples=[ISSUE_ATTACHMENT_UPLOAD_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Presigned download URL generated successfully", + examples=[ + OpenApiExample( + name="Work Item Attachment Response", + value={ + "upload_data": { + "url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + "fields": { + "key": "file.pdf", + "AWSAccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "policy": "EXAMPLE", + "signature": "EXAMPLE", + "acl": "public-read", + "Content-Type": "application/pdf", + }, + }, + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + "attachment": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "file.pdf", + "type": "application/pdf", + "size": 1234567890, + "url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + }, + }, + ) + ], + ), + 400: OpenApiResponse( + description="Validation error", + examples=[ + OpenApiExample( + name="Missing required fields", + value={ + "error": "Name and size are required fields.", + "status": False, + }, + ), + OpenApiExample( + name="Invalid file type", + value={"error": "Invalid file type.", "status": False}, + ), + ], + ), + 404: OpenApiResponse( + description="Issue or Project or Workspace not found", + examples=[ + OpenApiExample( + name="Workspace not found", + value={"error": "Workspace not found"}, + ), + OpenApiExample( + name="Project not found", value={"error": "Project not found"} + ), + OpenApiExample( + name="Issue not found", value={"error": "Issue not found"} + ), + ], + ), + }, + ) def post(self, request, slug, project_id, issue_id): + """Create work item attachment + + Generate presigned URL for uploading file attachments to a work item. + Validates file type and size before creating the attachment record. + """ name = request.data.get("name") type = request.data.get("type", False) size = request.data.get("size") @@ -1071,7 +1937,66 @@ def post(self, request, slug, project_id, issue_id): status=status.HTTP_200_OK, ) + @issue_attachment_docs( + operation_id="list_work_item_attachments", + description="Retrieve all attachments for a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item attachment", + response=IssueAttachmentSerializer, + examples=[ISSUE_ATTACHMENT_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List issue attachments + + List all attachments for an issue. + """ + # Get all the attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + # Serialize the attachments + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueAttachmentDetailAPIEndpoint(BaseAPIView): + """Issue Attachment Detail Endpoint""" + + serializer_class = IssueAttachmentSerializer + permission_classes = [ProjectEntityPermission] + model = FileAsset + + @issue_attachment_docs( + operation_id="delete_work_item_attachment", + description="Permanently remove an attachment from a work item. Records deletion activity for audit purposes.", + parameters=[ + ATTACHMENT_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse( + description="Work item attachment deleted successfully" + ), + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) def delete(self, request, slug, project_id, issue_id, pk): + """Delete work item attachment + + Soft delete an attachment from a work item by marking it as deleted. + Records deletion activity and triggers metadata cleanup. + """ issue_attachment = FileAsset.objects.get( pk=pk, workspace__slug=slug, project_id=project_id ) @@ -1097,41 +2022,97 @@ def delete(self, request, slug, project_id, issue_id, pk): issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT) - def get(self, request, slug, project_id, issue_id, pk=None): - if pk: - # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace__slug=slug, project_id=project_id - ) - - # Check if the asset is uploaded - if not asset.is_uploaded: - return Response( - {"error": "The asset is not uploaded.", "status": False}, - status=status.HTTP_400_BAD_REQUEST, - ) + @issue_attachment_docs( + operation_id="retrieve_work_item_attachment", + description="Download attachment file. Returns a redirect to the presigned download URL.", + parameters=[ + ATTACHMENT_ID_PARAMETER, + ], + responses={ + 302: OpenApiResponse( + description="Redirect to presigned download URL", + ), + 400: OpenApiResponse( + description="Asset not uploaded", + response={ + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message", + "example": "The asset is not uploaded.", + }, + "status": { + "type": "boolean", + "description": "Request status", + "example": False, + }, + }, + }, + examples=[ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE], + ), + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id, pk): + """Retrieve work item attachment + + Retrieve details of a specific attachment. + """ + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) - storage = S3Storage(request=request) - presigned_url = storage.generate_presigned_url( - object_name=asset.asset.name, - disposition="attachment", - filename=asset.attributes.get("name"), + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + {"error": "The asset is not uploaded.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, ) - return HttpResponseRedirect(presigned_url) - # Get all the attachments - issue_attachments = FileAsset.objects.filter( - issue_id=issue_id, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - workspace__slug=slug, - project_id=project_id, - is_uploaded=True, + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), ) - # Serialize the attachments - serializer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - + return HttpResponseRedirect(presigned_url) + + @issue_attachment_docs( + operation_id="upload_work_item_attachment", + description="Mark an attachment as uploaded after successful file transfer to storage.", + parameters=[ + ATTACHMENT_ID_PARAMETER, + ], + request=OpenApiRequest( + request={ + "application/json": { + "type": "object", + "properties": { + "is_uploaded": { + "type": "boolean", + "description": "Mark attachment as uploaded", + } + }, + } + }, + examples=[ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE], + ), + responses={ + 204: OpenApiResponse( + description="Work item attachment uploaded successfully" + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ATTACHMENT_NOT_FOUND_RESPONSE, + }, + ) def patch(self, request, slug, project_id, issue_id, pk): + """Confirm attachment upload + + Mark an attachment as uploaded after successful file transfer to storage. + Triggers activity logging and metadata extraction. + """ issue_attachment = FileAsset.objects.get( pk=pk, workspace__slug=slug, project_id=project_id ) @@ -1160,3 +2141,81 @@ def patch(self, request, slug, project_id, issue_id, pk): get_asset_object_metadata.delay(str(issue_attachment.id)) issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueSearchEndpoint(BaseAPIView): + """Endpoint to search across multiple fields in the issues""" + + @extend_schema( + operation_id="search_work_items", + tags=["Work Items"], + description="Perform semantic search across issue names, sequence IDs, and project identifiers.", + parameters=[ + WORKSPACE_SLUG_PARAMETER, + SEARCH_PARAMETER_REQUIRED, + LIMIT_PARAMETER, + WORKSPACE_SEARCH_PARAMETER, + PROJECT_ID_QUERY_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Work item search results", + response=IssueSearchSerializer, + examples=[ISSUE_SEARCH_EXAMPLE], + ), + 400: BAD_SEARCH_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: WORKSPACE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug): + """Search work items + + Perform semantic search across work item names, sequence IDs, and project identifiers. + Supports workspace-wide or project-specific search with configurable result limits. + """ + query = request.query_params.get("search", False) + limit = request.query_params.get("limit", 10) + workspace_search = request.query_params.get("workspace_search", "false") + project_id = request.query_params.get("project_id", False) + + if not query: + return Response({"issues": []}, status=status.HTTP_200_OK) + + # Build search query + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + # Filter issues + issues = Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + # Apply project filter if not searching across workspace + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + # Get results + issue_results = issues.distinct().values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + )[: int(limit)] + + return Response({"issues": issue_results}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 954ee030b1d..a6d7176d73d 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -1,29 +1,122 @@ -# Python imports -import uuid - -# Django imports -from django.contrib.auth.hashers import make_password -from django.core.validators import validate_email -from django.core.exceptions import ValidationError - # Third Party imports from rest_framework.response import Response from rest_framework import status +from drf_spectacular.utils import ( + extend_schema, + OpenApiResponse, +) # Module imports from .base import BaseAPIView from plane.api.serializers import UserLiteSerializer -from plane.db.models import User, Workspace, Project, WorkspaceMember, ProjectMember +from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember +from plane.app.permissions import ProjectMemberPermission, WorkSpaceAdminPermission +from plane.utils.openapi import ( + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + WORKSPACE_MEMBER_EXAMPLE, + PROJECT_MEMBER_EXAMPLE, +) + + +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 -from plane.app.permissions import ProjectMemberPermission + 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 @@ def get(self, request, slug, project_id): ).data return Response(users, status=status.HTTP_200_OK) - - # Insert a new user inside the workspace, and assign the user to the project - def post(self, request, slug, project_id): - # Check if user with email already exists, and send bad request if it's - # not present, check for workspace and valid project mandat - # ------------------- Validation ------------------- - if ( - request.data.get("email") is None - or request.data.get("display_name") is None - ): - return Response( - { - "error": "Expected email, display_name, workspace_slug, project_id, one or more of the fields are missing." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - email = request.data.get("email") - - try: - validate_email(email) - except ValidationError: - return Response( - {"error": "Invalid email provided"}, status=status.HTTP_400_BAD_REQUEST - ) - - workspace = Workspace.objects.filter(slug=slug).first() - project = Project.objects.filter(pk=project_id).first() - - if not all([workspace, project]): - return Response( - {"error": "Provided workspace or project does not exist"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if user exists - user = User.objects.filter(email=email).first() - workspace_member = None - project_member = None - - if user: - # Check if user is part of the workspace - workspace_member = WorkspaceMember.objects.filter( - workspace=workspace, member=user - ).first() - if workspace_member: - # Check if user is part of the project - project_member = ProjectMember.objects.filter( - project=project, member=user - ).first() - if project_member: - return Response( - {"error": "User is already part of the workspace and project"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # If user does not exist, create the user - if not user: - user = User.objects.create( - email=email, - display_name=request.data.get("display_name"), - first_name=request.data.get("first_name", ""), - last_name=request.data.get("last_name", ""), - username=uuid.uuid4().hex, - password=make_password(uuid.uuid4().hex), - is_password_autoset=True, - is_active=False, - ) - user.save() - - # Create a workspace member for the user if not already a member - if not workspace_member: - workspace_member = WorkspaceMember.objects.create( - workspace=workspace, member=user, role=request.data.get("role", 5) - ) - workspace_member.save() - - # Create a project member for the user if not already a member - if not project_member: - project_member = ProjectMember.objects.create( - project=project, member=user, role=request.data.get("role", 5) - ) - project_member.save() - - # Serialize the user and return the response - user_data = UserLiteSerializer(user).data - - return Response(user_data, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py index 9995bb806f5..e0392dfba38 100644 --- a/apps/api/plane/api/views/module.py +++ b/apps/api/plane/api/views/module.py @@ -10,12 +10,16 @@ # 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,19 +38,48 @@ 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""" - model = Module - permission_classes = [ProjectEntityPermission] serializer_class = ModuleSerializer + model = Module webhook_event = "module" + permission_classes = [ProjectEntityPermission] def get_queryset(self): return ( @@ -136,9 +169,33 @@ def get_queryset(self): .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 = ModuleSerializer( + serializer = ModuleCreateSerializer( data=request.data, context={"project_id": project_id, "workspace_id": project.workspace_id}, ) @@ -170,19 +227,184 @@ def post(self, request, slug, project_id): # 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=None, actor_id=request.user.id, slug=slug, origin=base_host(request=request, is_app=True), ) - module = Module.objects.get(pk=serializer.data["id"]) + 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] + serializer_class = ModuleSerializer + webhook_event = "module" + + def get_queryset(self): + return ( + Module.objects.filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related("module", "created_by"), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + issue_module__deleted_at__isnull=True, + ), + distinct=True, + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + @module_docs( + operation_id="update_module", + summary="Update module", + description="Modify an existing module's properties like name, description, status, or timeline.", + parameters=[ + MODULE_PK_PARAMETER, + ], + request=OpenApiRequest( + request=ModuleUpdateSerializer, + examples=[MODULE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Module updated successfully", + response=ModuleSerializer, + examples=[MODULE_EXAMPLE], + ), + 400: OpenApiResponse( + description="Invalid request data", + response=ModuleSerializer, + examples=[MODULE_UPDATE_EXAMPLE], + ), + 404: OpenApiResponse(description="Module not found"), + 409: OpenApiResponse( + description="Module with same external ID already exists" + ), + }, + ) def patch(self, request, slug, project_id, pk): + """Update module + + Modify an existing module's properties like name, description, status, or timeline. + Tracks all changes in model activity logs for audit purposes. + """ module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) current_instance = json.dumps( @@ -222,7 +444,7 @@ def patch(self, request, slug, project_id, pk): # 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 @@ def patch(self, request, slug, project_id, pk): 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 @@ def delete(self, request, slug, project_id, pk): 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 @@ def get_queryset(self): .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 @@ def get(self, request, slug, project_id, module_id): ).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 @@ def post(self, request, slug, project_id, module_id): 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 @@ def get_queryset(self): .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 @@ def get(self, request, slug, project_id, pk): ).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 @@ def post(self, request, slug, project_id, pk): ).delete() return Response(status=status.HTTP_204_NO_CONTENT) + @module_docs( + operation_id="unarchive_module", + summary="Unarchive module", + description="Restore an archived module to active status, making it available for regular use.", + parameters=[ + MODULE_PK_PARAMETER, + ], + responses={ + 204: UNARCHIVED_RESPONSE, + 404: MODULE_NOT_FOUND_RESPONSE, + }, + ) def delete(self, request, slug, project_id, pk): + """Unarchive module + + Restore an archived module to active status, making it available for regular use. + The module will reappear in active module lists and become fully functional. + """ module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) module.archived_at = None module.save() diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index 038d4faec89..b89129a7f8c 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -11,9 +11,8 @@ 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,15 +30,42 @@ 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): @@ -104,42 +130,87 @@ def get_queryset(self): .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"), - ) + @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 = 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) + .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 = ProjectSerializer( + serializer = ProjectCreateSerializer( data={**request.data}, context={"workspace_id": workspace.id} ) if serializer.is_valid(): @@ -147,25 +218,25 @@ def post(self, request, slug): # Add the user as Administrator to the project _ = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + project_id=serializer.instance.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 + project_id=serializer.instance.id, user=request.user ) - if serializer.data["project_lead"] is not None and str( - serializer.data["project_lead"] + if serializer.instance.project_lead is not None and str( + serializer.instance.project_lead ) != str(request.user.id): ProjectMember.objects.create( - project_id=serializer.data["id"], - member_id=serializer.data["project_lead"], + 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.data["id"], - user_id=serializer.data["project_lead"], + project_id=serializer.instance.id, + user_id=serializer.instance.project_lead, ) # Default states @@ -219,7 +290,7 @@ def post(self, request, slug): ] ) - project = self.get_queryset().filter(pk=serializer.data["id"]).first() + project = self.get_queryset().filter(pk=serializer.instance.id).first() # Model activity model_activity.delay( @@ -251,7 +322,130 @@ def post(self, request, slug): status=status.HTTP_409_CONFLICT, ) + +class ProjectDetailAPIEndpoint(BaseAPIView): + """Project Endpoints to update, retrieve and delete endpoint""" + + serializer_class = ProjectSerializer + model = Project + webhook_event = "project" + + permission_classes = [ProjectBasePermission] + + def get_queryset(self): + return ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + .select_related( + "workspace", "workspace__owner", "default_assignee", "project_lead" + ) + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + ) + ) + .annotate( + total_members=ProjectMember.objects.filter( + project_id=OuterRef("id"), member__is_bot=False, is_active=True + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_modules=Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + member_role=ProjectMember.objects.filter( + project_id=OuterRef("pk"), + member_id=self.request.user.id, + is_active=True, + ).values("role") + ) + .annotate( + is_deployed=Exists( + DeployBoard.objects.filter( + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @project_docs( + operation_id="retrieve_project", + summary="Retrieve project", + description="Retrieve details of a specific project.", + parameters=[ + PROJECT_PK_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Project details", + response=ProjectSerializer, + examples=[PROJECT_EXAMPLE], + ), + 404: PROJECT_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, pk): + """Retrieve project + + Retrieve details of a specific project. + """ + project = self.get_queryset().get(workspace__slug=slug, pk=pk) + serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + @project_docs( + operation_id="update_project", + summary="Update project", + description="Partially update an existing project's properties like name, description, or settings.", + parameters=[ + PROJECT_PK_PARAMETER, + ], + request=OpenApiRequest( + request=ProjectUpdateSerializer, + examples=[PROJECT_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Project updated successfully", + response=ProjectSerializer, + examples=[PROJECT_EXAMPLE], + ), + 404: PROJECT_NOT_FOUND_RESPONSE, + 409: PROJECT_NAME_TAKEN_RESPONSE, + }, + ) def patch(self, request, slug, pk): + """Update project + + Partially update an existing project's properties like name, description, or settings. + Tracks changes in model activity logs for audit purposes. + """ try: workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) @@ -267,7 +461,7 @@ def patch(self, request, slug, pk): 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 @@ def patch(self, request, slug, pk): 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 @@ def patch(self, request, slug, pk): 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 @@ def delete(self, request, slug, pk): class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView): + """Project Archive and Unarchive Endpoint""" + permission_classes = [ProjectBasePermission] + @project_docs( + operation_id="archive_project", + summary="Archive project", + description="Move a project to archived status, hiding it from active project lists.", + parameters=[ + PROJECT_ID_PARAMETER, + ], + request={}, + responses={ + 204: ARCHIVED_RESPONSE, + }, + ) def post(self, request, slug, project_id): + """Archive project + + Move a project to archived status, hiding it from active project lists. + Archived projects remain accessible but are excluded from regular workflows. + """ project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = timezone.now() project.save() UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() return Response(status=status.HTTP_204_NO_CONTENT) + @project_docs( + operation_id="unarchive_project", + summary="Unarchive project", + description="Restore an archived project to active status, making it available in regular workflows.", + parameters=[ + PROJECT_ID_PARAMETER, + ], + request={}, + responses={ + 204: UNARCHIVED_RESPONSE, + }, + ) def delete(self, request, slug, project_id): + """Unarchive project + + Restore an archived project to active status, making it available in regular workflows. + The project will reappear in active project lists and become fully functional. + """ project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = None project.save() diff --git a/apps/api/plane/api/views/state.py b/apps/api/plane/api/views/state.py index 0fbbd222a9d..327c6c89050 100644 --- a/apps/api/plane/api/views/state.py +++ b/apps/api/plane/api/views/state.py @@ -4,16 +4,37 @@ # 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 @@ def get_queryset(self): .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 @@ def post(self, request, slug, project_id): 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 @@ def get(self, request, slug, project_id, state_id=None): ).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 @@ def delete(self, request, slug, project_id, state_id): state.delete() return Response(status=status.HTTP_204_NO_CONTENT) - def patch(self, request, slug, project_id, state_id=None): + @state_docs( + operation_id="update_state", + summary="Update state", + description="Partially update an existing workflow state's properties like name, color, or group.", + parameters=[ + STATE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=StateSerializer, + examples=[STATE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="State updated", + response=StateSerializer, + examples=[STATE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, state_id): + """Update state + + Partially update an existing workflow state's properties like name, color, or group. + Validates external ID uniqueness if provided. + """ state = State.objects.get( workspace__slug=slug, project_id=project_id, pk=state_id ) diff --git a/apps/api/plane/api/views/user.py b/apps/api/plane/api/views/user.py new file mode 100644 index 00000000000..b874cec18b4 --- /dev/null +++ b/apps/api/plane/api/views/user.py @@ -0,0 +1,37 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse + +# Module imports +from plane.api.serializers import UserLiteSerializer +from plane.api.views.base import BaseAPIView +from plane.db.models import User +from plane.utils.openapi.decorators import user_docs +from plane.utils.openapi import USER_EXAMPLE + + +class UserEndpoint(BaseAPIView): + serializer_class = UserLiteSerializer + model = User + + @user_docs( + operation_id="get_current_user", + summary="Get current user", + description="Retrieve the authenticated user's profile information including basic details.", + responses={ + 200: OpenApiResponse( + description="Current user profile", + response=UserLiteSerializer, + examples=[USER_EXAMPLE], + ), + }, + ) + def get(self, request): + """Get current user + + Retrieve the authenticated user's profile information including basic details. + Returns user data based on the current authentication context. + """ + serializer = UserLiteSerializer(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index 470960fcc62..1596d90b37b 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -75,12 +75,12 @@ def has_permission(self, request, view): return False # Handle requests based on project__identifier - if hasattr(view, "project__identifier") and view.project__identifier: + if hasattr(view, "project_identifier") and view.project_identifier: if request.method in SAFE_METHODS: return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - project__identifier=view.project__identifier, + project__identifier=view.project_identifier, is_active=True, ).exists() diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py index c690fbe7dc2..8a47cdd02a9 100644 --- a/apps/api/plane/app/views/analytic/advance.py +++ b/apps/api/plane/app/views/analytic/advance.py @@ -16,8 +16,6 @@ IssueView, ProjectPage, Workspace, - CycleIssue, - ModuleIssue, ProjectMember, ) from plane.utils.build_chart import build_analytics_chart diff --git a/apps/api/plane/app/views/workspace/cycle.py b/apps/api/plane/app/views/workspace/cycle.py index eb899553da7..73deca0594e 100644 --- a/apps/api/plane/app/views/workspace/cycle.py +++ b/apps/api/plane/app/views/workspace/cycle.py @@ -10,7 +10,6 @@ from plane.db.models import Cycle from plane.app.permissions import WorkspaceViewerPermission from plane.app.serializers.cycle import CycleSerializer -from plane.utils.timezone_converter import user_timezone_converter class WorkspaceCyclesEndpoint(BaseAPIView): diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py index d8116cec372..ecf7ed183a4 100644 --- a/apps/api/plane/authentication/provider/oauth/github.py +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -18,7 +18,7 @@ class GitHubOAuthProvider(OauthAdapter): token_url = "https://github.com/login/oauth/access_token" userinfo_url = "https://api.github.com/user" - org_membership_url = f"https://api.github.com/orgs" + org_membership_url = "https://api.github.com/orgs" provider = "github" scope = "read:user user:email" diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index 4def8e8caaf..f768feac3aa 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -30,7 +30,6 @@ ) from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception -from plane.bgtasks.webhook_task import webhook_activity from plane.utils.issue_relation_mapper import get_inverse_relation from plane.utils.uuid import is_valid_uuid diff --git a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py index 48600e66251..f4a9285ee56 100644 --- a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py +++ b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py @@ -1,4 +1,3 @@ -import time from django.core.management.base import BaseCommand from django.db import transaction from plane.db.models import Workspace diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py index 6449fd145fa..26b152c6cf0 100644 --- a/apps/api/plane/db/models/cycle.py +++ b/apps/api/plane/db/models/cycle.py @@ -71,7 +71,7 @@ class Cycle(ProjectBaseModel): archived_at = models.DateTimeField(null=True) logo_props = models.JSONField(default=dict) # timezone - TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) version = models.IntegerField(default=1) diff --git a/apps/api/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py index 2f698ae1bf0..c6c366c9efd 100644 --- a/apps/api/plane/db/models/intake.py +++ b/apps/api/plane/db/models/intake.py @@ -35,6 +35,14 @@ class SourceType(models.TextChoices): IN_APP = "IN_APP" +class IntakeIssueStatus(models.IntegerChoices): + PENDING = -2 + REJECTED = -1 + SNOOZED = 0 + ACCEPTED = 1 + DUPLICATE = 2 + + class IntakeIssue(ProjectBaseModel): intake = models.ForeignKey( "db.Intake", related_name="issue_intake", on_delete=models.CASCADE diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py index 6fba4d03c7c..6015461d533 100644 --- a/apps/api/plane/db/models/module.py +++ b/apps/api/plane/db/models/module.py @@ -51,6 +51,15 @@ def get_default_display_properties(): } +class ModuleStatus(models.TextChoices): + BACKLOG = "backlog" + PLANNED = "planned" + IN_PROGRESS = "in-progress" + PAUSED = "paused" + COMPLETED = "completed" + CANCELLED = "cancelled" + + class Module(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Module Name") description = models.TextField(verbose_name="Module Description", blank=True) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 79a0707d38a..e58f60e804b 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -120,7 +120,7 @@ class Project(BaseModel): ) archived_at = models.DateTimeField(null=True) # timezone - TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) # external_id for imports external_source = models.CharField(max_length=255, null=True, blank=True) diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index b2613a42782..bad81b4ceb2 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -101,7 +101,7 @@ class User(AbstractBaseUser, PermissionsMixin): ) # timezone - USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + USER_TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) user_timezone = models.CharField( max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES ) diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 7e5103a70bb..3f9f612dce0 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -1,9 +1,6 @@ # Python imports -from django.db.models.functions import Ln import pytz -import time -from django.utils import timezone -from typing import Optional, Any, Tuple, Dict +from typing import Optional, Any # Django imports from django.conf import settings @@ -115,7 +112,7 @@ def slug_validator(value): class Workspace(BaseModel): - TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones)) + TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.TextField(verbose_name="Logo", blank=True, null=True) diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 38d2ac6e0ad..8d59f81927b 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -75,6 +75,8 @@ "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 @@ # Seed directory path SEED_DIR = os.path.join(BASE_DIR, "seeds") + +ENABLE_DRF_SPECTACULAR = os.environ.get("ENABLE_DRF_SPECTACULAR", "0") == "1" + +if ENABLE_DRF_SPECTACULAR: + REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema" + INSTALLED_APPS.append("drf_spectacular") + from .openapi import SPECTACULAR_SETTINGS # noqa: F401 diff --git a/apps/api/plane/settings/openapi.py b/apps/api/plane/settings/openapi.py new file mode 100644 index 00000000000..b79daeecf30 --- /dev/null +++ b/apps/api/plane/settings/openapi.py @@ -0,0 +1,272 @@ +""" +OpenAPI/Swagger configuration for drf-spectacular. + +This file contains the complete configuration for API documentation generation. +""" + +SPECTACULAR_SETTINGS = { + # ======================================================================== + # Basic API Information + # ======================================================================== + "TITLE": "The Plane REST API", + "DESCRIPTION": ( + "The Plane REST API\n\n" + "Visit our quick start guide and full API documentation at " + "[developers.plane.so](https://developers.plane.so/api-reference/introduction)." + ), + "CONTACT": { + "name": "Plane", + "url": "https://plane.so", + "email": "support@plane.so", + }, + "VERSION": "0.0.1", + "LICENSE": { + "name": "GNU AGPLv3", + "url": "https://github.com/makeplane/plane/blob/preview/LICENSE.txt", + }, + # ======================================================================== + # Schema Generation Settings + # ======================================================================== + "SERVE_INCLUDE_SCHEMA": False, + "SCHEMA_PATH_PREFIX": "/api/v1/", + "SCHEMA_CACHE_TIMEOUT": 0, # disables caching + # ======================================================================== + # Processing Hooks + # ======================================================================== + "PREPROCESSING_HOOKS": [ + "plane.utils.openapi.hooks.preprocess_filter_api_v1_paths", + ], + # ======================================================================== + # Server Configuration + # ======================================================================== + "SERVERS": [ + {"url": "http://localhost:8000", "description": "Local"}, + {"url": "https://api.plane.so", "description": "Production"}, + ], + # ======================================================================== + # API Tag Definitions + # ======================================================================== + "TAGS": [ + # System Features + { + "name": "Assets", + "description": ( + "**File Upload & Presigned URLs**\n\n" + "Generate presigned URLs for direct file uploads to cloud storage. Handle user avatars, " + "cover images, and generic project assets with secure upload workflows.\n\n" + "*Key Features:*\n" + "- Generate presigned URLs for S3 uploads\n" + "- Support for user avatars and cover images\n" + "- Generic asset upload for projects\n" + "- File validation and size limits\n\n" + "*Use Cases:* User profile images, project file uploads, secure direct-to-cloud uploads." + ), + }, + # Project Organization + { + "name": "Cycles", + "description": ( + "**Sprint & Development Cycles**\n\n" + "Create and manage development cycles (sprints) to organize work into time-boxed iterations. " + "Track progress, assign work items, and monitor team velocity.\n\n" + "*Key Features:*\n" + "- Create and configure development cycles\n" + "- Assign work items to cycles\n" + "- Track cycle progress and completion\n" + "- Generate cycle analytics and reports\n\n" + "*Use Cases:* Sprint planning, iterative development, progress tracking, team velocity." + ), + }, + # System Features + { + "name": "Intake", + "description": ( + "**Work Item Intake Queue**\n\n" + "Manage incoming work items through a dedicated intake queue for triage and review. " + "Submit, update, and process work items before they enter the main project workflow.\n\n" + "*Key Features:*\n" + "- Submit work items to intake queue\n" + "- Review and triage incoming work items\n" + "- Update intake work item status and properties\n" + "- Accept, reject, or modify work items before approval\n\n" + "*Use Cases:* Work item triage, external submissions, quality review, approval workflows." + ), + }, + # Project Organization + { + "name": "Labels", + "description": ( + "**Labels & Tags**\n\n" + "Create and manage labels to categorize and organize work items. Use color-coded labels " + "for easy identification, filtering, and project organization.\n\n" + "*Key Features:*\n" + "- Create custom labels with colors and descriptions\n" + "- Apply labels to work items for categorization\n" + "- Filter and search by labels\n" + "- Organize labels across projects\n\n" + "*Use Cases:* Priority marking, feature categorization, bug classification, team organization." + ), + }, + # Team & User Management + { + "name": "Members", + "description": ( + "**Team Member Management**\n\n" + "Manage team members, roles, and permissions within projects and workspaces. " + "Control access levels and track member participation.\n\n" + "*Key Features:*\n" + "- Invite and manage team members\n" + "- Assign roles and permissions\n" + "- Control project and workspace access\n" + "- Track member activity and participation\n\n" + "*Use Cases:* Team setup, access control, role management, collaboration." + ), + }, + # Project Organization + { + "name": "Modules", + "description": ( + "**Feature Modules**\n\n" + "Group related work items into modules for better organization and tracking. " + "Plan features, track progress, and manage deliverables at a higher level.\n\n" + "*Key Features:*\n" + "- Create and organize feature modules\n" + "- Group work items by module\n" + "- Track module progress and completion\n" + "- Manage module leads and assignments\n\n" + "*Use Cases:* Feature planning, release organization, progress tracking, team coordination." + ), + }, + # Core Project Management + { + "name": "Projects", + "description": ( + "**Project Management**\n\n" + "Create and manage projects to organize your development work. Configure project settings, " + "manage team access, and control project visibility.\n\n" + "*Key Features:*\n" + "- Create, update, and delete projects\n" + "- Configure project settings and preferences\n" + "- Manage team access and permissions\n" + "- Control project visibility and sharing\n\n" + "*Use Cases:* Project setup, team collaboration, access control, project configuration." + ), + }, + # Project Organization + { + "name": "States", + "description": ( + "**Workflow States**\n\n" + "Define custom workflow states for work items to match your team's process. " + "Configure state transitions and track work item progress through different stages.\n\n" + "*Key Features:*\n" + "- Create custom workflow states\n" + "- Configure state transitions and rules\n" + "- Track work item progress through states\n" + "- Set state-based permissions and automation\n\n" + "*Use Cases:* Custom workflows, status tracking, process automation, progress monitoring." + ), + }, + # Team & User Management + { + "name": "Users", + "description": ( + "**Current User Information**\n\n" + "Get information about the currently authenticated user including profile details " + "and account settings.\n\n" + "*Key Features:*\n" + "- Retrieve current user profile\n" + "- Access user account information\n" + "- View user preferences and settings\n" + "- Get authentication context\n\n" + "*Use Cases:* Profile display, user context, account information, authentication status." + ), + }, + # Work Item Management + { + "name": "Work Item Activity", + "description": ( + "**Activity History & Search**\n\n" + "View activity history and search for work items across the workspace. " + "Get detailed activity logs and find work items using text search.\n\n" + "*Key Features:*\n" + "- View work item activity history\n" + "- Search work items across workspace\n" + "- Track changes and modifications\n" + "- Filter search results by project\n\n" + "*Use Cases:* Activity tracking, work item discovery, change history, workspace search." + ), + }, + { + "name": "Work Item Attachments", + "description": ( + "**Work Item File Attachments**\n\n" + "Generate presigned URLs for uploading files directly to specific work items. " + "Upload and manage attachments associated with work items.\n\n" + "*Key Features:*\n" + "- Generate presigned URLs for work item attachments\n" + "- Upload files directly to work items\n" + "- Retrieve and manage attachment metadata\n" + "- Delete attachments from work items\n\n" + "*Use Cases:* Screenshots, error logs, design files, supporting documents." + ), + }, + { + "name": "Work Item Comments", + "description": ( + "**Comments & Discussions**\n\n" + "Add comments and discussions to work items for team collaboration. " + "Support threaded conversations, mentions, and rich text formatting.\n\n" + "*Key Features:*\n" + "- Add comments to work items\n" + "- Thread conversations and replies\n" + "- Mention users and trigger notifications\n" + "- Rich text and markdown support\n\n" + "*Use Cases:* Team discussions, progress updates, code reviews, decision tracking." + ), + }, + { + "name": "Work Item Links", + "description": ( + "**External Links & References**\n\n" + "Link work items to external resources like documentation, repositories, or design files. " + "Maintain connections between work items and external systems.\n\n" + "*Key Features:*\n" + "- Add external URL links to work items\n" + "- Validate and preview linked resources\n" + "- Organize links by type and category\n" + "- Track link usage and access\n\n" + "*Use Cases:* Documentation links, repository connections, design references, external tools." + ), + }, + { + "name": "Work Items", + "description": ( + "**Work Items & Tasks**\n\n" + "Create and manage work items like tasks, bugs, features, and user stories. " + "The core entities for tracking work in your projects.\n\n" + "*Key Features:*\n" + "- Create, update, and manage work items\n" + "- Assign to team members and set priorities\n" + "- Track progress through workflow states\n" + "- Set due dates, estimates, and relationships\n\n" + "*Use Cases:* Bug tracking, task management, feature development, sprint planning." + ), + }, + ], + # ======================================================================== + # Security & Authentication + # ======================================================================== + "AUTHENTICATION_WHITELIST": [ + "plane.api.middleware.api_authentication.APIKeyAuthentication", + ], + # ======================================================================== + # Schema Generation Options + # ======================================================================== + "COMPONENT_NO_READ_ONLY_REQUIRED": True, + "COMPONENT_SPLIT_REQUEST": True, + "ENUM_NAME_OVERRIDES": { + "ModuleStatusEnum": "plane.db.models.module.ModuleStatus", + "IntakeWorkItemStatusEnum": "plane.db.models.intake.IntakeIssueStatus", + }, +} diff --git a/apps/api/plane/space/serializer/__init__.py b/apps/api/plane/space/serializer/__init__.py index ad4e9897d98..a3fe1029f37 100644 --- a/apps/api/plane/space/serializer/__init__.py +++ b/apps/api/plane/space/serializer/__init__.py @@ -1,5 +1,5 @@ from .user import UserLiteSerializer -from .issue import LabelLiteSerializer, StateLiteSerializer, IssuePublicSerializer +from .issue import LabelLiteSerializer, IssuePublicSerializer -from .state import StateSerializer, StateLiteSerializer +from .state import StateSerializer diff --git a/apps/api/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py index b70c9352a30..15f3a8a2870 100644 --- a/apps/api/plane/tests/conftest.py +++ b/apps/api/plane/tests/conftest.py @@ -1,15 +1,13 @@ import pytest -from django.conf import settings from rest_framework.test import APIClient from pytest_django.fixtures import django_db_setup -from unittest.mock import patch, MagicMock from plane.db.models import User, Workspace, WorkspaceMember from plane.db.models.api import APIToken @pytest.fixture(scope="session") -def django_db_setup(django_db_setup): +def django_db_setup(django_db_setup): # noqa: F811 """Set up the Django database for the test session""" pass diff --git a/apps/api/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py index 50022b49063..b4853e53126 100644 --- a/apps/api/plane/tests/conftest_external.py +++ b/apps/api/plane/tests/conftest_external.py @@ -1,6 +1,5 @@ import pytest from unittest.mock import MagicMock, patch -from django.conf import settings @pytest.fixture diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py index a52882b9d22..b44f5f3fcd6 100644 --- a/apps/api/plane/tests/contract/app/test_authentication.py +++ b/apps/api/plane/tests/contract/app/test_authentication.py @@ -6,7 +6,7 @@ from rest_framework import status from django.test import Client from django.core.exceptions import ValidationError -from unittest.mock import patch, MagicMock +from unittest.mock import patch from plane.db.models import User from plane.settings.redis import redis_instance diff --git a/apps/api/plane/tests/unit/models/test_workspace_model.py b/apps/api/plane/tests/unit/models/test_workspace_model.py index aa3c1564541..26a79751268 100644 --- a/apps/api/plane/tests/unit/models/test_workspace_model.py +++ b/apps/api/plane/tests/unit/models/test_workspace_model.py @@ -1,7 +1,7 @@ import pytest from uuid import uuid4 -from plane.db.models import Workspace, WorkspaceMember, User +from plane.db.models import Workspace, WorkspaceMember @pytest.mark.unit diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py index b692306a764..c06e67158d5 100644 --- a/apps/api/plane/urls.py +++ b/apps/api/plane/urls.py @@ -2,6 +2,11 @@ from django.conf import settings from django.urls import include, path, re_path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) handler404 = "plane.app.views.error_404.custom_404_view" @@ -14,6 +19,20 @@ path("", include("plane.web.urls")), ] +if settings.ENABLE_DRF_SPECTACULAR: + urlpatterns += [ + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/schema/swagger-ui/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/schema/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + ] if settings.DEBUG: try: diff --git a/apps/api/plane/utils/openapi/README.md b/apps/api/plane/utils/openapi/README.md new file mode 100644 index 00000000000..9ac82cdd378 --- /dev/null +++ b/apps/api/plane/utils/openapi/README.md @@ -0,0 +1,102 @@ +# OpenAPI Utilities Module + +This module provides a well-organized structure for OpenAPI/drf-spectacular utilities, replacing the monolithic `openapi_spec_helpers.py` file with a more maintainable modular approach. + +## Structure + +``` +plane/utils/openapi/ +├── __init__.py # Main module that re-exports everything +├── auth.py # Authentication extensions +├── parameters.py # Common OpenAPI parameters +├── responses.py # Common OpenAPI responses +├── examples.py # Common OpenAPI examples +├── decorators.py # Helper decorators for different endpoint types +└── hooks.py # Schema processing hooks (pre/post processing) +``` + +## Usage + +### Import Everything (Recommended for backwards compatibility) +```python +from plane.utils.openapi import ( + asset_docs, + ASSET_ID_PARAMETER, + UNAUTHORIZED_RESPONSE, + # ... other imports +) +``` + +### Import from Specific Modules (Recommended for new code) +```python +from plane.utils.openapi.decorators import asset_docs +from plane.utils.openapi.parameters import ASSET_ID_PARAMETER +from plane.utils.openapi.responses import UNAUTHORIZED_RESPONSE +``` + +## Module Contents + +### auth.py +- `APIKeyAuthenticationExtension` - X-API-Key authentication +- `APITokenAuthenticationExtension` - Bearer token authentication + +### parameters.py +- Path parameters: `WORKSPACE_SLUG_PARAMETER`, `PROJECT_ID_PARAMETER`, `ISSUE_ID_PARAMETER`, `ASSET_ID_PARAMETER` +- Query parameters: `CURSOR_PARAMETER`, `PER_PAGE_PARAMETER` + +### responses.py +- Auth responses: `UNAUTHORIZED_RESPONSE`, `FORBIDDEN_RESPONSE` +- Resource responses: `NOT_FOUND_RESPONSE`, `VALIDATION_ERROR_RESPONSE` +- Asset responses: `PRESIGNED_URL_SUCCESS_RESPONSE`, `ASSET_UPDATED_RESPONSE`, etc. +- Generic asset responses: `GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE`, `ASSET_DOWNLOAD_SUCCESS_RESPONSE`, etc. + +### examples.py +- `FILE_UPLOAD_EXAMPLE`, `WORKSPACE_EXAMPLE`, `PROJECT_EXAMPLE`, `ISSUE_EXAMPLE` + +### decorators.py +- `workspace_docs()` - For workspace endpoints +- `project_docs()` - For project endpoints +- `issue_docs()` - For issue/work item endpoints +- `asset_docs()` - For asset endpoints + +### hooks.py +- `preprocess_filter_api_v1_paths()` - Filters API v1 paths +- `postprocess_assign_tags()` - Assigns tags based on URL patterns +- `generate_operation_summary()` - Generates operation summaries + +## Migration Status + +✅ **FULLY COMPLETE** - All components from the legacy `openapi_spec_helpers.py` have been successfully migrated to this modular structure and the old file has been completely removed. All imports have been updated to use the new modular structure. + +### What was migrated: +- ✅ All authentication extensions +- ✅ All common parameters and responses +- ✅ All helper decorators +- ✅ All schema processing hooks +- ✅ All examples and reusable components +- ✅ All asset view decorators converted to use new helpers +- ✅ All view imports updated to new module paths +- ✅ Legacy file completely removed + +### Files updated: +- `plane/api/views/asset.py` - All methods use new `@asset_docs` helpers +- `plane/api/views/project.py` - Import updated +- `plane/api/views/user.py` - Import updated +- `plane/api/views/state.py` - Import updated +- `plane/api/views/intake.py` - Import updated +- `plane/api/views/member.py` - Import updated +- `plane/api/views/module.py` - Import updated +- `plane/api/views/cycle.py` - Import updated +- `plane/api/views/issue.py` - Import updated +- `plane/settings/common.py` - Hook paths updated +- `plane/api/apps.py` - Auth extension import updated + +## Benefits + +1. **Better Organization**: Related functionality is grouped together +2. **Easier Maintenance**: Changes to specific areas only affect relevant files +3. **Improved Discoverability**: Clear module names make it easy to find what you need +4. **Backwards Compatibility**: All existing imports continue to work +5. **Reduced Coupling**: Import only what you need from specific modules +6. **Consistent Documentation**: All endpoints now use standardized helpers +7. **Massive Code Reduction**: ~80% reduction in decorator bloat using reusable components \ No newline at end of file diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py new file mode 100644 index 00000000000..bf682125881 --- /dev/null +++ b/apps/api/plane/utils/openapi/__init__.py @@ -0,0 +1,315 @@ +""" +OpenAPI utilities for drf-spectacular integration. + +This module provides reusable components for API documentation: +- Authentication extensions +- Common parameters and responses +- Helper decorators +- Schema preprocessing hooks +- Examples +""" + +# Authentication extensions +from .auth import APIKeyAuthenticationExtension + +# Parameters +from .parameters import ( + WORKSPACE_SLUG_PARAMETER, + PROJECT_ID_PARAMETER, + PROJECT_PK_PARAMETER, + PROJECT_IDENTIFIER_PARAMETER, + ISSUE_IDENTIFIER_PARAMETER, + ASSET_ID_PARAMETER, + CYCLE_ID_PARAMETER, + MODULE_ID_PARAMETER, + MODULE_PK_PARAMETER, + ISSUE_ID_PARAMETER, + STATE_ID_PARAMETER, + LABEL_ID_PARAMETER, + COMMENT_ID_PARAMETER, + LINK_ID_PARAMETER, + ATTACHMENT_ID_PARAMETER, + ACTIVITY_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + EXTERNAL_ID_PARAMETER, + EXTERNAL_SOURCE_PARAMETER, + ORDER_BY_PARAMETER, + SEARCH_PARAMETER, + SEARCH_PARAMETER_REQUIRED, + LIMIT_PARAMETER, + WORKSPACE_SEARCH_PARAMETER, + PROJECT_ID_QUERY_PARAMETER, + CYCLE_VIEW_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, +) + +# Responses +from .responses import ( + UNAUTHORIZED_RESPONSE, + FORBIDDEN_RESPONSE, + NOT_FOUND_RESPONSE, + VALIDATION_ERROR_RESPONSE, + DELETED_RESPONSE, + ARCHIVED_RESPONSE, + UNARCHIVED_RESPONSE, + INVALID_REQUEST_RESPONSE, + CONFLICT_RESPONSE, + ADMIN_ONLY_RESPONSE, + CANNOT_DELETE_RESPONSE, + CANNOT_ARCHIVE_RESPONSE, + REQUIRED_FIELDS_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + WORKSPACE_NOT_FOUND_RESPONSE, + PROJECT_NAME_TAKEN_RESPONSE, + ISSUE_NOT_FOUND_RESPONSE, + WORK_ITEM_NOT_FOUND_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, + LABEL_NOT_FOUND_RESPONSE, + LABEL_NAME_EXISTS_RESPONSE, + MODULE_NOT_FOUND_RESPONSE, + MODULE_ISSUE_NOT_FOUND_RESPONSE, + CYCLE_CANNOT_ARCHIVE_RESPONSE, + STATE_NAME_EXISTS_RESPONSE, + STATE_CANNOT_DELETE_RESPONSE, + COMMENT_NOT_FOUND_RESPONSE, + LINK_NOT_FOUND_RESPONSE, + ATTACHMENT_NOT_FOUND_RESPONSE, + BAD_SEARCH_REQUEST_RESPONSE, + PRESIGNED_URL_SUCCESS_RESPONSE, + GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE, + GENERIC_ASSET_VALIDATION_ERROR_RESPONSE, + ASSET_CONFLICT_RESPONSE, + ASSET_DOWNLOAD_SUCCESS_RESPONSE, + ASSET_DOWNLOAD_ERROR_RESPONSE, + ASSET_UPDATED_RESPONSE, + ASSET_DELETED_RESPONSE, + ASSET_NOT_FOUND_RESPONSE, + create_paginated_response, +) + +# Examples +from .examples import ( + FILE_UPLOAD_EXAMPLE, + WORKSPACE_EXAMPLE, + PROJECT_EXAMPLE, + ISSUE_EXAMPLE, + USER_EXAMPLE, + get_sample_for_schema, + # Request Examples + ISSUE_CREATE_EXAMPLE, + ISSUE_UPDATE_EXAMPLE, + ISSUE_UPSERT_EXAMPLE, + LABEL_CREATE_EXAMPLE, + LABEL_UPDATE_EXAMPLE, + ISSUE_LINK_CREATE_EXAMPLE, + ISSUE_LINK_UPDATE_EXAMPLE, + ISSUE_COMMENT_CREATE_EXAMPLE, + ISSUE_COMMENT_UPDATE_EXAMPLE, + ISSUE_ATTACHMENT_UPLOAD_EXAMPLE, + ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE, + CYCLE_CREATE_EXAMPLE, + CYCLE_UPDATE_EXAMPLE, + CYCLE_ISSUE_REQUEST_EXAMPLE, + TRANSFER_CYCLE_ISSUE_EXAMPLE, + MODULE_CREATE_EXAMPLE, + MODULE_UPDATE_EXAMPLE, + MODULE_ISSUE_REQUEST_EXAMPLE, + PROJECT_CREATE_EXAMPLE, + PROJECT_UPDATE_EXAMPLE, + STATE_CREATE_EXAMPLE, + STATE_UPDATE_EXAMPLE, + INTAKE_ISSUE_CREATE_EXAMPLE, + INTAKE_ISSUE_UPDATE_EXAMPLE, + # Response Examples + CYCLE_EXAMPLE, + TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE, + TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE, + TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE, + MODULE_EXAMPLE, + STATE_EXAMPLE, + LABEL_EXAMPLE, + ISSUE_LINK_EXAMPLE, + ISSUE_COMMENT_EXAMPLE, + ISSUE_ATTACHMENT_EXAMPLE, + ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE, + INTAKE_ISSUE_EXAMPLE, + MODULE_ISSUE_EXAMPLE, + ISSUE_SEARCH_EXAMPLE, + WORKSPACE_MEMBER_EXAMPLE, + PROJECT_MEMBER_EXAMPLE, + CYCLE_ISSUE_EXAMPLE, +) + +# Helper decorators +from .decorators import ( + workspace_docs, + project_docs, + issue_docs, + intake_docs, + asset_docs, + user_docs, + cycle_docs, + work_item_docs, + label_docs, + issue_link_docs, + issue_comment_docs, + issue_activity_docs, + issue_attachment_docs, + module_docs, + module_issue_docs, + state_docs, +) + +# Schema processing hooks +from .hooks import ( + preprocess_filter_api_v1_paths, + generate_operation_summary, +) + +__all__ = [ + # Authentication + "APIKeyAuthenticationExtension", + # Parameters + "WORKSPACE_SLUG_PARAMETER", + "PROJECT_ID_PARAMETER", + "PROJECT_PK_PARAMETER", + "PROJECT_IDENTIFIER_PARAMETER", + "ISSUE_IDENTIFIER_PARAMETER", + "ASSET_ID_PARAMETER", + "CYCLE_ID_PARAMETER", + "MODULE_ID_PARAMETER", + "MODULE_PK_PARAMETER", + "ISSUE_ID_PARAMETER", + "STATE_ID_PARAMETER", + "LABEL_ID_PARAMETER", + "COMMENT_ID_PARAMETER", + "LINK_ID_PARAMETER", + "ATTACHMENT_ID_PARAMETER", + "ACTIVITY_ID_PARAMETER", + "CURSOR_PARAMETER", + "PER_PAGE_PARAMETER", + "EXTERNAL_ID_PARAMETER", + "EXTERNAL_SOURCE_PARAMETER", + "ORDER_BY_PARAMETER", + "SEARCH_PARAMETER", + "SEARCH_PARAMETER_REQUIRED", + "LIMIT_PARAMETER", + "WORKSPACE_SEARCH_PARAMETER", + "PROJECT_ID_QUERY_PARAMETER", + "CYCLE_VIEW_PARAMETER", + "FIELDS_PARAMETER", + "EXPAND_PARAMETER", + # Responses + "UNAUTHORIZED_RESPONSE", + "FORBIDDEN_RESPONSE", + "NOT_FOUND_RESPONSE", + "VALIDATION_ERROR_RESPONSE", + "DELETED_RESPONSE", + "ARCHIVED_RESPONSE", + "UNARCHIVED_RESPONSE", + "INVALID_REQUEST_RESPONSE", + "CONFLICT_RESPONSE", + "ADMIN_ONLY_RESPONSE", + "CANNOT_DELETE_RESPONSE", + "CANNOT_ARCHIVE_RESPONSE", + "REQUIRED_FIELDS_RESPONSE", + "PROJECT_NOT_FOUND_RESPONSE", + "WORKSPACE_NOT_FOUND_RESPONSE", + "PROJECT_NAME_TAKEN_RESPONSE", + "ISSUE_NOT_FOUND_RESPONSE", + "WORK_ITEM_NOT_FOUND_RESPONSE", + "EXTERNAL_ID_EXISTS_RESPONSE", + "LABEL_NOT_FOUND_RESPONSE", + "LABEL_NAME_EXISTS_RESPONSE", + "MODULE_NOT_FOUND_RESPONSE", + "MODULE_ISSUE_NOT_FOUND_RESPONSE", + "CYCLE_CANNOT_ARCHIVE_RESPONSE", + "STATE_NAME_EXISTS_RESPONSE", + "STATE_CANNOT_DELETE_RESPONSE", + "COMMENT_NOT_FOUND_RESPONSE", + "LINK_NOT_FOUND_RESPONSE", + "ATTACHMENT_NOT_FOUND_RESPONSE", + "BAD_SEARCH_REQUEST_RESPONSE", + "create_paginated_response", + "PRESIGNED_URL_SUCCESS_RESPONSE", + "GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE", + "GENERIC_ASSET_VALIDATION_ERROR_RESPONSE", + "ASSET_CONFLICT_RESPONSE", + "ASSET_DOWNLOAD_SUCCESS_RESPONSE", + "ASSET_DOWNLOAD_ERROR_RESPONSE", + "ASSET_UPDATED_RESPONSE", + "ASSET_DELETED_RESPONSE", + "ASSET_NOT_FOUND_RESPONSE", + # Examples + "FILE_UPLOAD_EXAMPLE", + "WORKSPACE_EXAMPLE", + "PROJECT_EXAMPLE", + "ISSUE_EXAMPLE", + "USER_EXAMPLE", + "get_sample_for_schema", + # Request Examples + "ISSUE_CREATE_EXAMPLE", + "ISSUE_UPDATE_EXAMPLE", + "ISSUE_UPSERT_EXAMPLE", + "LABEL_CREATE_EXAMPLE", + "LABEL_UPDATE_EXAMPLE", + "ISSUE_LINK_CREATE_EXAMPLE", + "ISSUE_LINK_UPDATE_EXAMPLE", + "ISSUE_COMMENT_CREATE_EXAMPLE", + "ISSUE_COMMENT_UPDATE_EXAMPLE", + "ISSUE_ATTACHMENT_UPLOAD_EXAMPLE", + "ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE", + "CYCLE_CREATE_EXAMPLE", + "CYCLE_UPDATE_EXAMPLE", + "CYCLE_ISSUE_REQUEST_EXAMPLE", + "TRANSFER_CYCLE_ISSUE_EXAMPLE", + "MODULE_CREATE_EXAMPLE", + "MODULE_UPDATE_EXAMPLE", + "MODULE_ISSUE_REQUEST_EXAMPLE", + "PROJECT_CREATE_EXAMPLE", + "PROJECT_UPDATE_EXAMPLE", + "STATE_CREATE_EXAMPLE", + "STATE_UPDATE_EXAMPLE", + "INTAKE_ISSUE_CREATE_EXAMPLE", + "INTAKE_ISSUE_UPDATE_EXAMPLE", + # Response Examples + "CYCLE_EXAMPLE", + "TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE", + "TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE", + "TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE", + "MODULE_EXAMPLE", + "STATE_EXAMPLE", + "LABEL_EXAMPLE", + "ISSUE_LINK_EXAMPLE", + "ISSUE_COMMENT_EXAMPLE", + "ISSUE_ATTACHMENT_EXAMPLE", + "ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE", + "INTAKE_ISSUE_EXAMPLE", + "MODULE_ISSUE_EXAMPLE", + "ISSUE_SEARCH_EXAMPLE", + "WORKSPACE_MEMBER_EXAMPLE", + "PROJECT_MEMBER_EXAMPLE", + "CYCLE_ISSUE_EXAMPLE", + # Decorators + "workspace_docs", + "project_docs", + "issue_docs", + "intake_docs", + "asset_docs", + "user_docs", + "cycle_docs", + "work_item_docs", + "label_docs", + "issue_link_docs", + "issue_comment_docs", + "issue_activity_docs", + "issue_attachment_docs", + "module_docs", + "module_issue_docs", + "state_docs", + # Hooks + "preprocess_filter_api_v1_paths", + "generate_operation_summary", +] diff --git a/apps/api/plane/utils/openapi/auth.py b/apps/api/plane/utils/openapi/auth.py new file mode 100644 index 00000000000..e6012cc4e9e --- /dev/null +++ b/apps/api/plane/utils/openapi/auth.py @@ -0,0 +1,29 @@ +""" +OpenAPI authentication extensions for drf-spectacular. + +This module provides authentication extensions that automatically register +custom authentication classes with the OpenAPI schema generator. +""" + +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class APIKeyAuthenticationExtension(OpenApiAuthenticationExtension): + """ + OpenAPI authentication extension for plane.api.middleware.api_authentication.APIKeyAuthentication + """ + + target_class = "plane.api.middleware.api_authentication.APIKeyAuthentication" + name = "ApiKeyAuthentication" + priority = 1 + + def get_security_definition(self, auto_schema): + """ + Return the security definition for API key authentication. + """ + return { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key authentication. Provide your API key in the X-API-Key header.", + } diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py new file mode 100644 index 00000000000..e4a86839f64 --- /dev/null +++ b/apps/api/plane/utils/openapi/decorators.py @@ -0,0 +1,264 @@ +""" +Helper decorators for drf-spectacular OpenAPI documentation. + +This module provides domain-specific decorators that apply common +parameters, responses, and tags to API endpoints based on their context. +""" + +from drf_spectacular.utils import extend_schema +from .parameters import WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER +from .responses import UNAUTHORIZED_RESPONSE, FORBIDDEN_RESPONSE, NOT_FOUND_RESPONSE + + +def _merge_schema_options(defaults, kwargs): + """Helper function to merge responses and parameters from kwargs into defaults""" + # Merge responses + if "responses" in kwargs: + defaults["responses"].update(kwargs["responses"]) + kwargs = {k: v for k, v in kwargs.items() if k != "responses"} + + # Merge parameters + if "parameters" in kwargs: + defaults["parameters"].extend(kwargs["parameters"]) + kwargs = {k: v for k, v in kwargs.items() if k != "parameters"} + + defaults.update(kwargs) + return defaults + + +def user_docs(**kwargs): + """Decorator for user-related endpoints""" + defaults = { + "tags": ["Users"], + "parameters": [], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def workspace_docs(**kwargs): + """Decorator for workspace-related endpoints""" + defaults = { + "tags": ["Workspaces"], + "parameters": [WORKSPACE_SLUG_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def project_docs(**kwargs): + """Decorator for project-related endpoints""" + defaults = { + "tags": ["Projects"], + "parameters": [WORKSPACE_SLUG_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def cycle_docs(**kwargs): + """Decorator for cycle-related endpoints""" + defaults = { + "tags": ["Cycles"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_docs(**kwargs): + """Decorator for issue-related endpoints""" + defaults = { + "tags": ["Work Items"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def intake_docs(**kwargs): + """Decorator for intake-related endpoints""" + defaults = { + "tags": ["Intake"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def asset_docs(**kwargs): + """Decorator for asset-related endpoints with common defaults""" + defaults = { + "tags": ["Assets"], + "parameters": [], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +# Issue-related decorators for specific tags +def work_item_docs(**kwargs): + """Decorator for work item endpoints (main issue operations)""" + defaults = { + "tags": ["Work Items"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def label_docs(**kwargs): + """Decorator for label management endpoints""" + defaults = { + "tags": ["Labels"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_link_docs(**kwargs): + """Decorator for issue link endpoints""" + defaults = { + "tags": ["Work Item Links"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_comment_docs(**kwargs): + """Decorator for issue comment endpoints""" + defaults = { + "tags": ["Work Item Comments"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_activity_docs(**kwargs): + """Decorator for issue activity/search endpoints""" + defaults = { + "tags": ["Work Item Activity"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def issue_attachment_docs(**kwargs): + """Decorator for issue attachment endpoints""" + defaults = { + "tags": ["Work Item Attachments"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def module_docs(**kwargs): + """Decorator for module management endpoints""" + defaults = { + "tags": ["Modules"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def module_issue_docs(**kwargs): + """Decorator for module issue management endpoints""" + defaults = { + "tags": ["Modules"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + +def state_docs(**kwargs): + """Decorator for state management endpoints""" + defaults = { + "tags": ["States"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py new file mode 100644 index 00000000000..136669159b6 --- /dev/null +++ b/apps/api/plane/utils/openapi/examples.py @@ -0,0 +1,816 @@ +""" +Common OpenAPI examples for drf-spectacular. + +This module provides reusable example data for API responses and requests +to make the generated documentation more helpful and realistic. +""" + +from drf_spectacular.utils import OpenApiExample + + +# File Upload Examples +FILE_UPLOAD_EXAMPLE = OpenApiExample( + name="File Upload Success", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "asset": "uploads/workspace_1/file_example.pdf", + "attributes": { + "name": "example-document.pdf", + "size": 1024000, + "mimetype": "application/pdf", + }, + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# Workspace Examples +WORKSPACE_EXAMPLE = OpenApiExample( + name="Workspace", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "My Workspace", + "slug": "my-workspace", + "organization_size": "1-10", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# Project Examples +PROJECT_EXAMPLE = OpenApiExample( + name="Project", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Mobile App Development", + "description": "Development of the mobile application", + "identifier": "MAD", + "network": 2, + "project_lead": "550e8400-e29b-41d4-a716-446655440001", + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# Issue Examples +ISSUE_EXAMPLE = OpenApiExample( + name="Issue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Implement user authentication", + "description": "Add OAuth 2.0 authentication flow", + "sequence_id": 1, + "priority": "high", + "assignees": ["550e8400-e29b-41d4-a716-446655440001"], + "labels": ["550e8400-e29b-41d4-a716-446655440002"], + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + }, +) + + +# User Examples +USER_EXAMPLE = OpenApiExample( + name="User", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "John", + "last_name": "Doe", + "email": "john.doe@example.com", + "avatar": "https://example.com/avatar.jpg", + "avatar_url": "https://example.com/avatar.jpg", + "display_name": "John Doe", + }, +) + + +# ============================================================================ +# REQUEST EXAMPLES - Centralized examples for API requests +# ============================================================================ + +# Work Item / Issue Examples +ISSUE_CREATE_EXAMPLE = OpenApiExample( + "IssueCreateSerializer", + value={ + "name": "New Issue", + "description": "New issue description", + "priority": "medium", + "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a work item", +) + +ISSUE_UPDATE_EXAMPLE = OpenApiExample( + "IssueUpdateSerializer", + value={ + "name": "Updated Issue", + "description": "Updated issue description", + "priority": "medium", + "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + }, + description="Example request for updating a work item", +) + +ISSUE_UPSERT_EXAMPLE = OpenApiExample( + "IssueUpsertSerializer", + value={ + "name": "Updated Issue via External ID", + "description": "Updated issue description", + "priority": "high", + "state": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "assignees": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "labels": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for upserting a work item via external ID", +) + +# Label Examples +LABEL_CREATE_EXAMPLE = OpenApiExample( + "LabelCreateUpdateSerializer", + value={ + "name": "New Label", + "color": "#ff0000", + "description": "New label description", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a label", +) + +LABEL_UPDATE_EXAMPLE = OpenApiExample( + "LabelCreateUpdateSerializer", + value={ + "name": "Updated Label", + "color": "#00ff00", + "description": "Updated label description", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a label", +) + +# Issue Link Examples +ISSUE_LINK_CREATE_EXAMPLE = OpenApiExample( + "IssueLinkCreateSerializer", + value={ + "url": "https://example.com", + "title": "Example Link", + }, + description="Example request for creating an issue link", +) + +ISSUE_LINK_UPDATE_EXAMPLE = OpenApiExample( + "IssueLinkUpdateSerializer", + value={ + "url": "https://example.com", + "title": "Updated Link", + }, + description="Example request for updating an issue link", +) + +# Issue Comment Examples +ISSUE_COMMENT_CREATE_EXAMPLE = OpenApiExample( + "IssueCommentCreateSerializer", + value={ + "comment_html": "

New comment content

", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating an issue comment", +) + +ISSUE_COMMENT_UPDATE_EXAMPLE = OpenApiExample( + "IssueCommentCreateSerializer", + value={ + "comment_html": "

Updated comment content

", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating an issue comment", +) + +# Issue Attachment Examples +ISSUE_ATTACHMENT_UPLOAD_EXAMPLE = OpenApiExample( + "IssueAttachmentUploadSerializer", + value={ + "name": "document.pdf", + "type": "application/pdf", + "size": 1024000, + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating an issue attachment", +) + +ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE = OpenApiExample( + "ConfirmUpload", + value={"is_uploaded": True}, + description="Confirm that the attachment has been successfully uploaded", +) + +# Cycle Examples +CYCLE_CREATE_EXAMPLE = OpenApiExample( + "CycleCreateSerializer", + value={ + "name": "Cycle 1", + "description": "Cycle 1 description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a cycle", +) + +CYCLE_UPDATE_EXAMPLE = OpenApiExample( + "CycleUpdateSerializer", + value={ + "name": "Updated Cycle", + "description": "Updated cycle description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a cycle", +) + +CYCLE_ISSUE_REQUEST_EXAMPLE = OpenApiExample( + "CycleIssueRequestSerializer", + value={ + "issues": [ + "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + ], + }, + description="Example request for adding cycle issues", +) + +TRANSFER_CYCLE_ISSUE_EXAMPLE = OpenApiExample( + "TransferCycleIssueRequestSerializer", + value={ + "new_cycle_id": "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + }, + description="Example request for transferring cycle issues", +) + +# Module Examples +MODULE_CREATE_EXAMPLE = OpenApiExample( + "ModuleCreateSerializer", + value={ + "name": "New Module", + "description": "New module description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a module", +) + +MODULE_UPDATE_EXAMPLE = OpenApiExample( + "ModuleUpdateSerializer", + value={ + "name": "Updated Module", + "description": "Updated module description", + "start_date": "2021-01-01", + "end_date": "2021-01-31", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a module", +) + +MODULE_ISSUE_REQUEST_EXAMPLE = OpenApiExample( + "ModuleIssueRequestSerializer", + value={ + "issues": [ + "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + ], + }, + description="Example request for adding module issues", +) + +# Project Examples +PROJECT_CREATE_EXAMPLE = OpenApiExample( + "ProjectCreateSerializer", + value={ + "name": "New Project", + "description": "New project description", + "identifier": "new-project", + "project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + }, + description="Example request for creating a project", +) + +PROJECT_UPDATE_EXAMPLE = OpenApiExample( + "ProjectUpdateSerializer", + value={ + "name": "Updated Project", + "description": "Updated project description", + "identifier": "updated-project", + "project_lead": "0ec6cfa4-e906-4aad-9390-2df0303a41ce", + }, + description="Example request for updating a project", +) + +# State Examples +STATE_CREATE_EXAMPLE = OpenApiExample( + "StateCreateSerializer", + value={ + "name": "New State", + "color": "#ff0000", + "group": "backlog", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a state", +) + +STATE_UPDATE_EXAMPLE = OpenApiExample( + "StateUpdateSerializer", + value={ + "name": "Updated State", + "color": "#00ff00", + "group": "backlog", + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a state", +) + +# Intake Examples +INTAKE_ISSUE_CREATE_EXAMPLE = OpenApiExample( + "IntakeIssueCreateSerializer", + value={ + "issue": { + "name": "New Issue", + "description": "New issue description", + "priority": "medium", + } + }, + description="Example request for creating an intake issue", +) + +INTAKE_ISSUE_UPDATE_EXAMPLE = OpenApiExample( + "IntakeIssueUpdateSerializer", + value={ + "status": 1, + "issue": { + "name": "Updated Issue", + "description": "Updated issue description", + "priority": "high", + }, + }, + description="Example request for updating an intake issue", +) + + +# ============================================================================ +# RESPONSE EXAMPLES - Centralized examples for API responses +# ============================================================================ + +# Cycle Response Examples +CYCLE_EXAMPLE = OpenApiExample( + name="Cycle", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sprint 1 - Q1 2024", + "description": "First sprint of the quarter focusing on core features", + "start_date": "2024-01-01", + "end_date": "2024-01-14", + "status": "current", + "total_issues": 15, + "completed_issues": 8, + "cancelled_issues": 1, + "started_issues": 4, + "unstarted_issues": 2, + "backlog_issues": 0, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Transfer Cycle Issue Response Examples +TRANSFER_CYCLE_ISSUE_SUCCESS_EXAMPLE = OpenApiExample( + name="Transfer Cycle Issue Success", + value={ + "message": "Success", + }, + description="Successful transfer of cycle issues to new cycle", +) + +TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE = OpenApiExample( + name="Transfer Cycle Issue Error", + value={ + "error": "New Cycle Id is required", + }, + description="Error when required cycle ID is missing", +) + +TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE = OpenApiExample( + name="Transfer to Completed Cycle Error", + value={ + "error": "The cycle where the issues are transferred is already completed", + }, + description="Error when trying to transfer to a completed cycle", +) + +# Module Response Examples +MODULE_EXAMPLE = OpenApiExample( + name="Module", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Authentication Module", + "description": "User authentication and authorization features", + "start_date": "2024-01-01", + "target_date": "2024-02-15", + "status": "in-progress", + "total_issues": 12, + "completed_issues": 5, + "cancelled_issues": 0, + "started_issues": 4, + "unstarted_issues": 3, + "backlog_issues": 0, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# State Response Examples +STATE_EXAMPLE = OpenApiExample( + name="State", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "In Progress", + "color": "#f39c12", + "group": "started", + "sequence": 2, + "default": False, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Label Response Examples +LABEL_EXAMPLE = OpenApiExample( + name="Label", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "bug", + "color": "#ff4444", + "description": "Issues that represent bugs in the system", + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Link Response Examples +ISSUE_LINK_EXAMPLE = OpenApiExample( + name="IssueLink", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://github.com/example/repo/pull/123", + "title": "Fix authentication bug", + "metadata": { + "title": "Fix authentication bug", + "description": "Pull request to fix authentication timeout issue", + "image": "https://github.com/example/repo/avatar.png", + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Comment Response Examples +ISSUE_COMMENT_EXAMPLE = OpenApiExample( + name="IssueComment", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "comment_html": "

This issue has been resolved by implementing OAuth 2.0 flow.

", + "comment_json": { + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This issue has been resolved by implementing OAuth 2.0 flow.", + } + ], + } + ], + }, + "actor": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "first_name": "John", + "last_name": "Doe", + "display_name": "John Doe", + "avatar": "https://example.com/avatar.jpg", + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Attachment Response Examples +ISSUE_ATTACHMENT_EXAMPLE = OpenApiExample( + name="IssueAttachment", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "screenshot.png", + "size": 1024000, + "asset_url": "https://s3.amazonaws.com/bucket/screenshot.png?signed-url", + "attributes": { + "name": "screenshot.png", + "type": "image/png", + "size": 1024000, + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Attachment Error Response Examples +ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE = OpenApiExample( + name="Issue Attachment Not Uploaded", + value={ + "error": "The asset is not uploaded.", + "status": False, + }, + description="Error when trying to download an attachment that hasn't been uploaded yet", +) + +# Intake Issue Response Examples +INTAKE_ISSUE_EXAMPLE = OpenApiExample( + name="IntakeIssue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": 0, # Pending + "source": "in_app", + "issue": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "name": "Feature request: Dark mode", + "description": "Add dark mode support to the application", + "priority": "medium", + "sequence_id": 124, + }, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Module Issue Response Examples +MODULE_ISSUE_EXAMPLE = OpenApiExample( + name="ModuleIssue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "module": "550e8400-e29b-41d4-a716-446655440001", + "issue": "550e8400-e29b-41d4-a716-446655440002", + "sub_issues_count": 2, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + +# Issue Search Response Examples +ISSUE_SEARCH_EXAMPLE = OpenApiExample( + name="IssueSearchResults", + value={ + "issues": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Fix authentication bug in user login", + "sequence_id": 123, + "project__identifier": "MAB", + "project_id": "550e8400-e29b-41d4-a716-446655440001", + "workspace__slug": "my-workspace", + }, + { + "id": "550e8400-e29b-41d4-a716-446655440002", + "name": "Add authentication middleware", + "sequence_id": 124, + "project__identifier": "MAB", + "project_id": "550e8400-e29b-41d4-a716-446655440001", + "workspace__slug": "my-workspace", + }, + ] + }, +) + +# Workspace Member Response Examples +WORKSPACE_MEMBER_EXAMPLE = OpenApiExample( + name="WorkspaceMembers", + value=[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "John", + "last_name": "Doe", + "display_name": "John Doe", + "email": "john.doe@example.com", + "avatar": "https://example.com/avatar.jpg", + "role": 20, + }, + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "first_name": "Jane", + "last_name": "Smith", + "display_name": "Jane Smith", + "email": "jane.smith@example.com", + "avatar": "https://example.com/avatar2.jpg", + "role": 15, + }, + ], +) + +# Project Member Response Examples +PROJECT_MEMBER_EXAMPLE = OpenApiExample( + name="ProjectMembers", + value=[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "first_name": "John", + "last_name": "Doe", + "display_name": "John Doe", + "email": "john.doe@example.com", + "avatar": "https://example.com/avatar.jpg", + }, + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "first_name": "Jane", + "last_name": "Smith", + "display_name": "Jane Smith", + "email": "jane.smith@example.com", + "avatar": "https://example.com/avatar2.jpg", + }, + ], +) + +# Cycle Issue Response Examples +CYCLE_ISSUE_EXAMPLE = OpenApiExample( + name="CycleIssue", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "cycle": "550e8400-e29b-41d4-a716-446655440001", + "issue": "550e8400-e29b-41d4-a716-446655440002", + "sub_issues_count": 3, + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + + +# Sample data for different entity types +SAMPLE_ISSUE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Fix authentication bug in user login", + "description": "Users are unable to log in due to authentication service timeout", + "priority": "high", + "sequence_id": 123, + "state": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "name": "In Progress", + "group": "started", + }, + "assignees": [], + "labels": [], + "created_at": "2024-01-15T10:30:00Z", +} + +SAMPLE_LABEL = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "bug", + "color": "#ff4444", + "description": "Issues that represent bugs in the system", +} + +SAMPLE_CYCLE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sprint 1 - Q1 2024", + "description": "First sprint of the quarter focusing on core features", + "start_date": "2024-01-01", + "end_date": "2024-01-14", + "status": "current", +} + +SAMPLE_MODULE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Authentication Module", + "description": "User authentication and authorization features", + "start_date": "2024-01-01", + "target_date": "2024-02-15", + "status": "in_progress", +} + +SAMPLE_PROJECT = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Mobile App Backend", + "description": "Backend services for the mobile application", + "identifier": "MAB", + "network": 2, +} + +SAMPLE_STATE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "In Progress", + "color": "#ffa500", + "group": "started", + "sequence": 2, +} + +SAMPLE_COMMENT = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "comment_html": "

This issue needs more investigation. I'll look into the database connection timeout.

", + "created_at": "2024-01-15T14:20:00Z", + "actor": {"id": "550e8400-e29b-41d4-a716-446655440002", "display_name": "John Doe"}, +} + +SAMPLE_LINK = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://github.com/example/repo/pull/123", + "title": "Fix authentication timeout issue", + "metadata": {}, +} + +SAMPLE_ACTIVITY = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "field": "priority", + "old_value": "medium", + "new_value": "high", + "created_at": "2024-01-15T11:45:00Z", + "actor": { + "id": "550e8400-e29b-41d4-a716-446655440002", + "display_name": "Jane Smith", + }, +} + +SAMPLE_INTAKE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": 0, + "issue": { + "id": "550e8400-e29b-41d4-a716-446655440003", + "name": "Feature request: Dark mode support", + }, + "created_at": "2024-01-15T09:15:00Z", +} + +SAMPLE_GENERIC = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sample Item", + "created_at": "2024-01-15T12:00:00Z", +} + +SAMPLE_CYCLE_ISSUE = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "cycle": "550e8400-e29b-41d4-a716-446655440001", + "issue": "550e8400-e29b-41d4-a716-446655440002", + "sub_issues_count": 3, + "created_at": "2024-01-01T10:30:00Z", +} + +# Mapping of schema types to sample data +SCHEMA_EXAMPLES = { + "Issue": SAMPLE_ISSUE, + "WorkItem": SAMPLE_ISSUE, + "Label": SAMPLE_LABEL, + "Cycle": SAMPLE_CYCLE, + "Module": SAMPLE_MODULE, + "Project": SAMPLE_PROJECT, + "State": SAMPLE_STATE, + "Comment": SAMPLE_COMMENT, + "Link": SAMPLE_LINK, + "Activity": SAMPLE_ACTIVITY, + "Intake": SAMPLE_INTAKE, + "CycleIssue": SAMPLE_CYCLE_ISSUE, +} + + +def get_sample_for_schema(schema_name): + """ + Get appropriate sample data for a schema type. + + Args: + schema_name (str): Name of the schema (e.g., "PaginatedIssueResponse") + + Returns: + dict: Sample data for the schema type + """ + # Extract base schema name from paginated responses + if schema_name.startswith("Paginated"): + base_name = schema_name.replace("Paginated", "").replace("Response", "") + return SCHEMA_EXAMPLES.get(base_name, SAMPLE_GENERIC) + + return SCHEMA_EXAMPLES.get(schema_name, SAMPLE_GENERIC) diff --git a/apps/api/plane/utils/openapi/hooks.py b/apps/api/plane/utils/openapi/hooks.py new file mode 100644 index 00000000000..3cd7eaf7afd --- /dev/null +++ b/apps/api/plane/utils/openapi/hooks.py @@ -0,0 +1,56 @@ +""" +Schema processing hooks for drf-spectacular OpenAPI generation. + +This module provides preprocessing and postprocessing functions that modify +the generated OpenAPI schema to apply custom filtering, tagging, and other +transformations. +""" + + +def preprocess_filter_api_v1_paths(endpoints): + """ + Filter OpenAPI endpoints to only include /api/v1/ paths and exclude PUT methods. + """ + filtered = [] + for path, path_regex, method, callback in endpoints: + # Only include paths that start with /api/v1/ and exclude PUT methods + if ( + path.startswith("/api/v1/") + and method.upper() != "PUT" + and "server" not in path.lower() + ): + filtered.append((path, path_regex, method, callback)) + return filtered + + +def generate_operation_summary(method, path, tag): + """ + Generate a human-readable summary for an operation. + """ + # Extract the main resource from the path + path_parts = [part for part in path.split("/") if part and not part.startswith("{")] + + if len(path_parts) > 0: + resource = path_parts[-1].replace("-", " ").title() + else: + resource = tag + + # Generate summary based on method + method_summaries = { + "GET": f"Retrieve {resource}", + "POST": f"Create {resource}", + "PATCH": f"Update {resource}", + "DELETE": f"Delete {resource}", + } + + # Handle specific cases + if "archive" in path.lower(): + if method == "POST": + return f'Archive {tag.rstrip("s")}' + elif method == "DELETE": + return f'Unarchive {tag.rstrip("s")}' + + if "transfer" in path.lower(): + return f'Transfer {tag.rstrip("s")}' + + return method_summaries.get(method, f"{method} {resource}") diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py new file mode 100644 index 00000000000..0d7f3a3d18f --- /dev/null +++ b/apps/api/plane/utils/openapi/parameters.py @@ -0,0 +1,493 @@ +""" +Common OpenAPI parameters for drf-spectacular. + +This module provides reusable parameter definitions that can be shared +across multiple API endpoints to ensure consistency. +""" + +from drf_spectacular.utils import OpenApiParameter, OpenApiExample +from drf_spectacular.types import OpenApiTypes + + +# Path Parameters +WORKSPACE_SLUG_PARAMETER = OpenApiParameter( + name="slug", + description="Workspace slug", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example workspace", + value="my-workspace", + description="A typical workspace slug", + ) + ], +) + +PROJECT_ID_PARAMETER = OpenApiParameter( + name="project_id", + description="Project ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example project ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical project UUID", + ) + ], +) + +PROJECT_PK_PARAMETER = OpenApiParameter( + name="pk", + description="Project ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example project ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical project UUID", + ) + ], +) + +PROJECT_IDENTIFIER_PARAMETER = OpenApiParameter( + name="project_identifier", + description="Project identifier (unique string within workspace)", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example project identifier", + value="PROJ", + description="A typical project identifier", + ) + ], +) + +ISSUE_IDENTIFIER_PARAMETER = OpenApiParameter( + name="issue_identifier", + description="Issue sequence ID (numeric identifier within project)", + required=True, + type=OpenApiTypes.INT, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example issue identifier", + value=123, + description="A typical issue sequence ID", + ) + ], +) + +ASSET_ID_PARAMETER = OpenApiParameter( + name="asset_id", + description="Asset ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example asset ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical asset UUID", + ) + ], +) + +CYCLE_ID_PARAMETER = OpenApiParameter( + name="cycle_id", + description="Cycle ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example cycle ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical cycle UUID", + ) + ], +) + +MODULE_ID_PARAMETER = OpenApiParameter( + name="module_id", + description="Module ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example module ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical module UUID", + ) + ], +) + +MODULE_PK_PARAMETER = OpenApiParameter( + name="pk", + description="Module ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example module ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical module UUID", + ) + ], +) + +ISSUE_ID_PARAMETER = OpenApiParameter( + name="issue_id", + description="Issue ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example issue ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical issue UUID", + ) + ], +) + +STATE_ID_PARAMETER = OpenApiParameter( + name="state_id", + description="State ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example state ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical state UUID", + ) + ], +) + +# Additional Path Parameters +LABEL_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Label ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example label ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical label UUID", + ) + ], +) + +COMMENT_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Comment ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example comment ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical comment UUID", + ) + ], +) + +LINK_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Link ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example link ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical link UUID", + ) + ], +) + +ATTACHMENT_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Attachment ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example attachment ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical attachment UUID", + ) + ], +) + +ACTIVITY_ID_PARAMETER = OpenApiParameter( + name="pk", + description="Activity ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example activity ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical activity UUID", + ) + ], +) + +# Query Parameters +CURSOR_PARAMETER = OpenApiParameter( + name="cursor", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Pagination cursor for getting next set of results", + required=False, + examples=[ + OpenApiExample( + name="Next page cursor", + value="20:1:0", + description="Cursor format: 'page_size:page_number:offset'", + ) + ], +) + +PER_PAGE_PARAMETER = OpenApiParameter( + name="per_page", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Number of results per page (default: 20, max: 100)", + required=False, + examples=[ + OpenApiExample(name="Default", value=20), + OpenApiExample(name="Maximum", value=100), + ], +) + +# External Integration Parameters +EXTERNAL_ID_PARAMETER = OpenApiParameter( + name="external_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="External system identifier for filtering or lookup", + required=False, + examples=[ + OpenApiExample( + name="GitHub Issue", + value="1234567890", + description="GitHub issue number", + ) + ], +) + +EXTERNAL_SOURCE_PARAMETER = OpenApiParameter( + name="external_source", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="External system source name for filtering or lookup", + required=False, + examples=[ + OpenApiExample( + name="GitHub", + value="github", + description="GitHub integration source", + ), + OpenApiExample( + name="Jira", + value="jira", + description="Jira integration source", + ), + ], +) + +# Ordering Parameters +ORDER_BY_PARAMETER = OpenApiParameter( + name="order_by", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Field to order results by. Prefix with '-' for descending order", + required=False, + examples=[ + OpenApiExample( + name="Created date descending", + value="-created_at", + description="Most recent items first", + ), + OpenApiExample( + name="Priority ascending", + value="priority", + description="Order by priority (urgent, high, medium, low, none)", + ), + OpenApiExample( + name="State group", + value="state__group", + description="Order by state group (backlog, unstarted, started, completed, cancelled)", + ), + OpenApiExample( + name="Assignee name", + value="assignees__first_name", + description="Order by assignee first name", + ), + ], +) + +# Search Parameters +SEARCH_PARAMETER = OpenApiParameter( + name="search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Search query to filter results by name, description, or identifier", + required=False, + examples=[ + OpenApiExample( + name="Name search", + value="bug fix", + description="Search for items containing 'bug fix'", + ), + OpenApiExample( + name="Sequence ID", + value="123", + description="Search by sequence ID number", + ), + ], +) + +SEARCH_PARAMETER_REQUIRED = OpenApiParameter( + name="search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Search query to filter results by name, description, or identifier", + required=True, + examples=[ + OpenApiExample( + name="Name search", + value="bug fix", + description="Search for items containing 'bug fix'", + ), + OpenApiExample( + name="Sequence ID", + value="123", + description="Search by sequence ID number", + ), + ], +) + +LIMIT_PARAMETER = OpenApiParameter( + name="limit", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Maximum number of results to return", + required=False, + examples=[ + OpenApiExample(name="Default", value=10), + OpenApiExample(name="More results", value=50), + ], +) + +WORKSPACE_SEARCH_PARAMETER = OpenApiParameter( + name="workspace_search", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Whether to search across entire workspace or within specific project", + required=False, + examples=[ + OpenApiExample( + name="Project only", + value="false", + description="Search within specific project only", + ), + OpenApiExample( + name="Workspace wide", + value="true", + description="Search across entire workspace", + ), + ], +) + +PROJECT_ID_QUERY_PARAMETER = OpenApiParameter( + name="project_id", + description="Project ID for filtering results within a specific project", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + examples=[ + OpenApiExample( + name="Example project ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="Filter results for this project", + ) + ], +) + +# Cycle View Parameter +CYCLE_VIEW_PARAMETER = OpenApiParameter( + name="cycle_view", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter cycles by status", + required=False, + examples=[ + OpenApiExample(name="All cycles", value="all"), + OpenApiExample(name="Current cycles", value="current"), + OpenApiExample(name="Upcoming cycles", value="upcoming"), + OpenApiExample(name="Completed cycles", value="completed"), + OpenApiExample(name="Draft cycles", value="draft"), + OpenApiExample(name="Incomplete cycles", value="incomplete"), + ], +) + +# Field Selection Parameters +FIELDS_PARAMETER = OpenApiParameter( + name="fields", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Comma-separated list of fields to include in response", + required=False, + examples=[ + OpenApiExample( + name="Basic fields", + value="id,name,description", + description="Include only basic fields", + ), + OpenApiExample( + name="With relations", + value="id,name,assignees,state", + description="Include fields with relationships", + ), + ], +) + +EXPAND_PARAMETER = OpenApiParameter( + name="expand", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Comma-separated list of related fields to expand in response", + required=False, + examples=[ + OpenApiExample( + name="Expand assignees", + value="assignees", + description="Include full assignee details", + ), + OpenApiExample( + name="Multiple expansions", + value="assignees,labels,state", + description="Include details for multiple relations", + ), + ], +) diff --git a/apps/api/plane/utils/openapi/responses.py b/apps/api/plane/utils/openapi/responses.py new file mode 100644 index 00000000000..a70a749f3fd --- /dev/null +++ b/apps/api/plane/utils/openapi/responses.py @@ -0,0 +1,492 @@ +""" +Common OpenAPI responses for drf-spectacular. + +This module provides reusable response definitions for common HTTP status codes +and scenarios that occur across multiple API endpoints. +""" + +from drf_spectacular.utils import OpenApiResponse, OpenApiExample, inline_serializer +from rest_framework import serializers +from .examples import get_sample_for_schema + + +# Authentication & Authorization Responses +UNAUTHORIZED_RESPONSE = OpenApiResponse( + description="Authentication credentials were not provided or are invalid.", + examples=[ + OpenApiExample( + name="Unauthorized", + value={ + "error": "Authentication credentials were not provided", + "error_code": "AUTHENTICATION_REQUIRED", + }, + ) + ], +) + +FORBIDDEN_RESPONSE = OpenApiResponse( + description="Permission denied. User lacks required permissions.", + examples=[ + OpenApiExample( + name="Forbidden", + value={ + "error": "You do not have permission to perform this action", + "error_code": "PERMISSION_DENIED", + }, + ) + ], +) + + +# Resource Responses +NOT_FOUND_RESPONSE = OpenApiResponse( + description="The requested resource was not found.", + examples=[ + OpenApiExample( + name="Not Found", + value={"error": "Not found", "error_code": "RESOURCE_NOT_FOUND"}, + ) + ], +) + +VALIDATION_ERROR_RESPONSE = OpenApiResponse( + description="Validation error occurred with the provided data.", + examples=[ + OpenApiExample( + name="Validation Error", + value={ + "error": "Validation failed", + "details": {"field_name": ["This field is required."]}, + }, + ) + ], +) + +# Generic Success Responses +DELETED_RESPONSE = OpenApiResponse( + description="Resource deleted successfully", + examples=[ + OpenApiExample( + name="Deleted Successfully", + value={"message": "Resource deleted successfully"}, + ) + ], +) + +ARCHIVED_RESPONSE = OpenApiResponse( + description="Resource archived successfully", + examples=[ + OpenApiExample( + name="Archived Successfully", + value={"message": "Resource archived successfully"}, + ) + ], +) + +UNARCHIVED_RESPONSE = OpenApiResponse( + description="Resource unarchived successfully", + examples=[ + OpenApiExample( + name="Unarchived Successfully", + value={"message": "Resource unarchived successfully"}, + ) + ], +) + +# Specific Error Responses +INVALID_REQUEST_RESPONSE = OpenApiResponse( + description="Invalid request data provided", + examples=[ + OpenApiExample( + name="Invalid Request", + value={ + "error": "Invalid request data", + "details": "Specific validation errors", + }, + ) + ], +) + +CONFLICT_RESPONSE = OpenApiResponse( + description="Resource conflict - duplicate or constraint violation", + examples=[ + OpenApiExample( + name="Resource Conflict", + value={ + "error": "Resource with the same identifier already exists", + "id": "550e8400-e29b-41d4-a716-446655440000", + }, + ) + ], +) + +ADMIN_ONLY_RESPONSE = OpenApiResponse( + description="Only admin or creator can perform this action", + examples=[ + OpenApiExample( + name="Admin Only", + value={"error": "Only admin or creator can perform this action"}, + ) + ], +) + +CANNOT_DELETE_RESPONSE = OpenApiResponse( + description="Resource cannot be deleted due to constraints", + examples=[ + OpenApiExample( + name="Cannot Delete", + value={"error": "Resource cannot be deleted", "reason": "Has dependencies"}, + ) + ], +) + +CANNOT_ARCHIVE_RESPONSE = OpenApiResponse( + description="Resource cannot be archived in current state", + examples=[ + OpenApiExample( + name="Cannot Archive", + value={ + "error": "Resource cannot be archived", + "reason": "Not in valid state", + }, + ) + ], +) + +REQUIRED_FIELDS_RESPONSE = OpenApiResponse( + description="Required fields are missing", + examples=[ + OpenApiExample( + name="Required Fields Missing", + value={"error": "Required fields are missing", "fields": ["name", "type"]}, + ) + ], +) + +# Project-specific Responses +PROJECT_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Project not found", + examples=[ + OpenApiExample( + name="Project Not Found", + value={"error": "Project not found"}, + ) + ], +) + +WORKSPACE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Workspace not found", + examples=[ + OpenApiExample( + name="Workspace Not Found", + value={"error": "Workspace not found"}, + ) + ], +) + +PROJECT_NAME_TAKEN_RESPONSE = OpenApiResponse( + description="Project name already taken", + examples=[ + OpenApiExample( + name="Project Name Taken", + value={"error": "Project name already taken"}, + ) + ], +) + +# Issue-specific Responses +ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Issue not found", + examples=[ + OpenApiExample( + name="Issue Not Found", + value={"error": "Issue not found"}, + ) + ], +) + +WORK_ITEM_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Work item not found", + examples=[ + OpenApiExample( + name="Work Item Not Found", + value={"error": "Work item not found"}, + ) + ], +) + +EXTERNAL_ID_EXISTS_RESPONSE = OpenApiResponse( + description="Resource with same external ID already exists", + examples=[ + OpenApiExample( + name="External ID Exists", + value={ + "error": "Resource with the same external id and external source already exists", + "id": "550e8400-e29b-41d4-a716-446655440000", + }, + ) + ], +) + +# Label-specific Responses +LABEL_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Label not found", + examples=[ + OpenApiExample( + name="Label Not Found", + value={"error": "Label not found"}, + ) + ], +) + +LABEL_NAME_EXISTS_RESPONSE = OpenApiResponse( + description="Label with the same name already exists", + examples=[ + OpenApiExample( + name="Label Name Exists", + value={"error": "Label with the same name already exists in the project"}, + ) + ], +) + +# Module-specific Responses +MODULE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Module not found", + examples=[ + OpenApiExample( + name="Module Not Found", + value={"error": "Module not found"}, + ) + ], +) + +MODULE_ISSUE_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Module issue not found", + examples=[ + OpenApiExample( + name="Module Issue Not Found", + value={"error": "Module issue not found"}, + ) + ], +) + +# Cycle-specific Responses +CYCLE_CANNOT_ARCHIVE_RESPONSE = OpenApiResponse( + description="Cycle cannot be archived", + examples=[ + OpenApiExample( + name="Cycle Cannot Archive", + value={"error": "Only completed cycles can be archived"}, + ) + ], +) + +# State-specific Responses +STATE_NAME_EXISTS_RESPONSE = OpenApiResponse( + description="State with the same name already exists", + examples=[ + OpenApiExample( + name="State Name Exists", + value={"error": "State with the same name already exists"}, + ) + ], +) + +STATE_CANNOT_DELETE_RESPONSE = OpenApiResponse( + description="State cannot be deleted", + examples=[ + OpenApiExample( + name="State Cannot Delete", + value={ + "error": "State cannot be deleted", + "reason": "Default state or has issues", + }, + ) + ], +) + +# Comment-specific Responses +COMMENT_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Comment not found", + examples=[ + OpenApiExample( + name="Comment Not Found", + value={"error": "Comment not found"}, + ) + ], +) + +# Link-specific Responses +LINK_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Link not found", + examples=[ + OpenApiExample( + name="Link Not Found", + value={"error": "Link not found"}, + ) + ], +) + +# Attachment-specific Responses +ATTACHMENT_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Attachment not found", + examples=[ + OpenApiExample( + name="Attachment Not Found", + value={"error": "Attachment not found"}, + ) + ], +) + +# Search-specific Responses +BAD_SEARCH_REQUEST_RESPONSE = OpenApiResponse( + description="Bad request - invalid search parameters", + examples=[ + OpenApiExample( + name="Bad Search Request", + value={"error": "Invalid search parameters"}, + ) + ], +) + + +# Pagination Response Templates +def create_paginated_response( + item_schema, + schema_name, + description="Paginated results", + example_name="Paginated Response", +): + """Create a paginated response with the specified item schema""" + + return OpenApiResponse( + description=description, + response=inline_serializer( + name=schema_name, + fields={ + "grouped_by": serializers.CharField(allow_null=True), + "sub_grouped_by": serializers.CharField(allow_null=True), + "total_count": serializers.IntegerField(), + "next_cursor": serializers.CharField(), + "prev_cursor": serializers.CharField(), + "next_page_results": serializers.BooleanField(), + "prev_page_results": serializers.BooleanField(), + "count": serializers.IntegerField(), + "total_pages": serializers.IntegerField(), + "total_results": serializers.IntegerField(), + "extra_stats": serializers.CharField(allow_null=True), + "results": serializers.ListField(child=item_schema()), + }, + ), + examples=[ + OpenApiExample( + name=example_name, + value={ + "grouped_by": "state", + "sub_grouped_by": "priority", + "total_count": 150, + "next_cursor": "20:1:0", + "prev_cursor": "20:0:0", + "next_page_results": True, + "prev_page_results": False, + "count": 20, + "total_pages": 8, + "total_results": 150, + "extra_stats": None, + "results": [get_sample_for_schema(schema_name)], + }, + summary=example_name, + ) + ], + ) + + +# Asset-specific Responses +PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse( + description="Presigned URL generated successfully" +) + +GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse( + description="Presigned URL generated successfully", + examples=[ + OpenApiExample( + name="Generic Asset Upload Response", + value={ + "upload_data": { + "url": "https://s3.amazonaws.com/bucket-name", + "fields": { + "key": "workspace-id/uuid-filename.pdf", + "AWSAccessKeyId": "AKIA...", + "policy": "eyJ...", + "signature": "abc123...", + }, + }, + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://cdn.example.com/workspace-id/uuid-filename.pdf", + }, + ) + ], +) + +GENERIC_ASSET_VALIDATION_ERROR_RESPONSE = OpenApiResponse( + description="Validation error", + examples=[ + OpenApiExample( + name="Missing required fields", + value={"error": "Name and size are required fields.", "status": False}, + ), + OpenApiExample( + name="Invalid file type", + value={"error": "Invalid file type.", "status": False}, + ), + ], +) + +ASSET_CONFLICT_RESPONSE = OpenApiResponse( + description="Asset with same external ID already exists", + examples=[ + OpenApiExample( + name="Duplicate external asset", + value={ + "message": "Asset with same external id and source already exists", + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://cdn.example.com/existing-file.pdf", + }, + ) + ], +) + +ASSET_DOWNLOAD_SUCCESS_RESPONSE = OpenApiResponse( + description="Presigned download URL generated successfully", + examples=[ + OpenApiExample( + name="Asset Download Response", + value={ + "asset_id": "550e8400-e29b-41d4-a716-446655440000", + "asset_url": "https://s3.amazonaws.com/bucket/file.pdf?signed-url", + "asset_name": "document.pdf", + "asset_type": "application/pdf", + }, + ) + ], +) + +ASSET_DOWNLOAD_ERROR_RESPONSE = OpenApiResponse( + description="Bad request", + examples=[ + OpenApiExample( + name="Asset not uploaded", value={"error": "Asset not yet uploaded"} + ), + ], +) + +ASSET_UPDATED_RESPONSE = OpenApiResponse(description="Asset updated successfully") + +ASSET_DELETED_RESPONSE = OpenApiResponse(description="Asset deleted successfully") + +ASSET_NOT_FOUND_RESPONSE = OpenApiResponse( + description="Asset not found", + examples=[ + OpenApiExample(name="Asset not found", value={"error": "Asset not found"}) + ], +) diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt index 3a12b9bf6b2..78e9efed343 100644 --- a/apps/api/requirements/base.txt +++ b/apps/api/requirements/base.txt @@ -65,3 +65,5 @@ opentelemetry-api==1.28.1 opentelemetry-sdk==1.28.1 opentelemetry-instrumentation-django==0.49b1 opentelemetry-exporter-otlp==1.28.1 +# OpenAPI Specification +drf-spectacular==0.28.0 \ No newline at end of file