from collections.abc import Generator from contextlib import contextmanager from datetime import datetime from uuid import UUID from flask_restx import Resource from pydantic import BaseModel, Field, computed_field, field_validator from controllers.common.errors import ForbiddenError, InvalidArgumentError, NotFoundError from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import AppNotFoundError from controllers.console.flask_admission import console_account_admission from controllers.console.wraps import model_validate from enums.account import TenantAccountRole from extensions.ext_application_services import application_services from fields.base import ResponseModel from fields.member_fields import AccountWithRole from libs.external_api import error_responses from libs.helper import build_avatar_url, dump_response, to_timestamp from machinery.context import RequestContext from services.app.workflow_comment_service import ( InvalidMentionedUserIdError, InvalidWorkflowCommentContentError, WorkflowCommentAppNotFoundError, WorkflowCommentDraft, WorkflowCommentEdit, WorkflowCommentNotFoundError, WorkflowCommentPermissionError, WorkflowCommentReplyDraft, WorkflowCommentReplyNotFoundError, ) # Same roles as the legacy `edit_permission_required` gate; like it, admission skips the role check under RBAC. _EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR}) class WorkflowCommentCreatePayload(BaseModel): content: str = Field(..., description="Comment content") position_x: float = Field(..., description="Comment X position") position_y: float = Field(..., description="Comment Y position") mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs") class WorkflowCommentUpdatePayload(BaseModel): content: str = Field(..., description="Comment content") position_x: float | None = Field(default=None, description="Comment X position") position_y: float | None = Field(default=None, description="Comment Y position") mentioned_user_ids: list[str] | None = Field( default=None, description="Mentioned user IDs. Omit to keep existing mentions.", ) class WorkflowCommentReplyPayload(BaseModel): content: str = Field(..., description="Reply content") mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs") class WorkflowCommentMentionUsersPayload(BaseModel): users: list[AccountWithRole] class WorkflowCommentAccount(ResponseModel): id: str name: str email: str avatar: str | None = Field(default=None, exclude=True) @computed_field(return_type=str | None) # type: ignore[prop-decorator] @property def avatar_url(self) -> str | None: return build_avatar_url(self.avatar) class WorkflowCommentReply(ResponseModel): id: str content: str created_by: str created_by_account: WorkflowCommentAccount | None = None created_at: int | None = None @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentMention(ResponseModel): mentioned_user_id: str mentioned_user_account: WorkflowCommentAccount | None = None reply_id: str | None = None class WorkflowCommentBasic(ResponseModel): id: str position_x: float position_y: float content: str created_by: str created_by_account: WorkflowCommentAccount | None = None created_at: int | None = None updated_at: int | None = None resolved: bool resolved_at: int | None = None resolved_by: str | None = None resolved_by_account: WorkflowCommentAccount | None = None reply_count: int mention_count: int participants: list[WorkflowCommentAccount] @field_validator("created_at", "updated_at", "resolved_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentBasicList(ResponseModel): data: list[WorkflowCommentBasic] class WorkflowCommentDetail(ResponseModel): id: str position_x: float position_y: float content: str created_by: str created_by_account: WorkflowCommentAccount | None = None created_at: int | None = None updated_at: int | None = None resolved: bool resolved_at: int | None = None resolved_by: str | None = None resolved_by_account: WorkflowCommentAccount | None = None replies: list[WorkflowCommentReply] mentions: list[WorkflowCommentMention] @field_validator("created_at", "updated_at", "resolved_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentCreate(ResponseModel): id: str created_at: int | None = None @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentUpdate(ResponseModel): id: str updated_at: int | None = None @field_validator("updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentResolve(ResponseModel): id: str resolved: bool resolved_at: int | None = None resolved_by: str | None = None @field_validator("resolved_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentReplyCreate(ResponseModel): id: str created_at: int | None = None @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) class WorkflowCommentReplyUpdate(ResponseModel): id: str updated_at: int | None = None @field_validator("updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: return to_timestamp(value) register_schema_models( console_ns, WorkflowCommentCreatePayload, WorkflowCommentUpdatePayload, WorkflowCommentReplyPayload, ) register_response_schema_models( console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload, WorkflowCommentAccount, WorkflowCommentReply, WorkflowCommentMention, WorkflowCommentBasic, WorkflowCommentBasicList, WorkflowCommentDetail, WorkflowCommentCreate, WorkflowCommentUpdate, WorkflowCommentResolve, WorkflowCommentReplyCreate, WorkflowCommentReplyUpdate, ) @contextmanager def _translate_errors() -> Generator[None]: """Map framework-neutral comment failures back to the existing transport errors.""" try: yield except WorkflowCommentAppNotFoundError as error: raise AppNotFoundError() from error except (WorkflowCommentNotFoundError, WorkflowCommentReplyNotFoundError) as error: raise NotFoundError(str(error)) from error except WorkflowCommentPermissionError as error: raise ForbiddenError(str(error)) from error except (InvalidWorkflowCommentContentError, InvalidMentionedUserIdError) as error: raise InvalidArgumentError(str(error)) from error @console_ns.route("/apps//workflow/comments") class WorkflowCommentListApi(Resource): """API for listing and creating workflow comments.""" @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("list_workflow_comments") @console_ns.doc(description="Get all comments for a workflow") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response(200, "Comments retrieved successfully", console_ns.models[WorkflowCommentBasicList.__name__]) @console_account_admission() def get(self, request_context: RequestContext, app_id: UUID): """Get all comments for a workflow.""" with _translate_errors(): comments = application_services().workflow_comments.list_comments(request_context, str(app_id)) return dump_response(WorkflowCommentBasicList, {"data": comments}) @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("create_workflow_comment") @console_ns.doc(description="Create a new workflow comment") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__]) @console_ns.response(201, "Comment created successfully", console_ns.models[WorkflowCommentCreate.__name__]) @console_account_admission(allowed_roles=_EDIT_ROLES) @model_validate(WorkflowCommentCreatePayload) def post(self, req_data: WorkflowCommentCreatePayload, request_context: RequestContext, app_id: UUID): """Create a new workflow comment.""" with _translate_errors(): result = application_services().workflow_comments.create_comment( request_context, str(app_id), WorkflowCommentDraft( content=req_data.content, position_x=req_data.position_x, position_y=req_data.position_y, mentioned_user_ids=tuple(req_data.mentioned_user_ids), ), ) return dump_response(WorkflowCommentCreate, result), 201 @console_ns.route("/apps//workflow/comments/") class WorkflowCommentDetailApi(Resource): """API for managing individual workflow comments.""" @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("get_workflow_comment") @console_ns.doc(description="Get a specific workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.response(200, "Comment retrieved successfully", console_ns.models[WorkflowCommentDetail.__name__]) @console_account_admission() def get(self, request_context: RequestContext, app_id: UUID, comment_id: str): """Get a specific workflow comment.""" with _translate_errors(): comment = application_services().workflow_comments.get_comment(request_context, str(app_id), comment_id) return dump_response(WorkflowCommentDetail, comment) @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("update_workflow_comment") @console_ns.doc(description="Update a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__]) @console_ns.response(200, "Comment updated successfully", console_ns.models[WorkflowCommentUpdate.__name__]) @console_account_admission(allowed_roles=_EDIT_ROLES) @model_validate(WorkflowCommentUpdatePayload) def put( self, req_data: WorkflowCommentUpdatePayload, request_context: RequestContext, app_id: UUID, comment_id: str, ): """Update a workflow comment.""" mentioned_user_ids = req_data.mentioned_user_ids with _translate_errors(): result = application_services().workflow_comments.update_comment( request_context, str(app_id), comment_id, WorkflowCommentEdit( content=req_data.content, position_x=req_data.position_x, position_y=req_data.position_y, mentioned_user_ids=tuple(mentioned_user_ids) if mentioned_user_ids is not None else None, ), ) return dump_response(WorkflowCommentUpdate, result) @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("delete_workflow_comment") @console_ns.doc(description="Delete a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.response(204, "Comment deleted successfully") @console_account_admission(allowed_roles=_EDIT_ROLES) def delete(self, request_context: RequestContext, app_id: UUID, comment_id: str): """Delete a workflow comment.""" with _translate_errors(): application_services().workflow_comments.delete_comment(request_context, str(app_id), comment_id) return "", 204 @console_ns.route("/apps//workflow/comments//resolve") class WorkflowCommentResolveApi(Resource): """API for resolving and reopening workflow comments.""" @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("resolve_workflow_comment") @console_ns.doc(description="Resolve a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.response(200, "Comment resolved successfully", console_ns.models[WorkflowCommentResolve.__name__]) @console_account_admission(allowed_roles=_EDIT_ROLES) def post(self, request_context: RequestContext, app_id: UUID, comment_id: str): """Resolve a workflow comment.""" with _translate_errors(): result = application_services().workflow_comments.resolve_comment(request_context, str(app_id), comment_id) return dump_response(WorkflowCommentResolve, result) @console_ns.route("/apps//workflow/comments//replies") class WorkflowCommentReplyApi(Resource): """API for managing comment replies.""" @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("create_workflow_comment_reply") @console_ns.doc(description="Add a reply to a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__]) @console_ns.response(201, "Reply created successfully", console_ns.models[WorkflowCommentReplyCreate.__name__]) @console_account_admission(allowed_roles=_EDIT_ROLES) @model_validate(WorkflowCommentReplyPayload) def post( self, req_data: WorkflowCommentReplyPayload, request_context: RequestContext, app_id: UUID, comment_id: str, ): """Add a reply to a workflow comment.""" with _translate_errors(): result = application_services().workflow_comments.create_reply( request_context, str(app_id), comment_id, WorkflowCommentReplyDraft( content=req_data.content, mentioned_user_ids=tuple(req_data.mentioned_user_ids), ), ) return dump_response(WorkflowCommentReplyCreate, result), 201 @console_ns.route("/apps//workflow/comments//replies/") class WorkflowCommentReplyDetailApi(Resource): """API for managing individual comment replies.""" @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("update_workflow_comment_reply") @console_ns.doc(description="Update a comment reply") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"}) @console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__]) @console_ns.response(200, "Reply updated successfully", console_ns.models[WorkflowCommentReplyUpdate.__name__]) @console_account_admission(allowed_roles=_EDIT_ROLES) @model_validate(WorkflowCommentReplyPayload) def put( self, req_data: WorkflowCommentReplyPayload, request_context: RequestContext, app_id: UUID, comment_id: str, reply_id: str, ): """Update a comment reply.""" with _translate_errors(): result = application_services().workflow_comments.update_reply( request_context, str(app_id), comment_id, reply_id, WorkflowCommentReplyDraft( content=req_data.content, mentioned_user_ids=tuple(req_data.mentioned_user_ids), ), ) return dump_response(WorkflowCommentReplyUpdate, result) @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("delete_workflow_comment_reply") @console_ns.doc(description="Delete a comment reply") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"}) @console_ns.response(204, "Reply deleted successfully") @console_account_admission(allowed_roles=_EDIT_ROLES) def delete(self, request_context: RequestContext, app_id: UUID, comment_id: str, reply_id: str): """Delete a comment reply.""" with _translate_errors(): application_services().workflow_comments.delete_reply(request_context, str(app_id), comment_id, reply_id) return "", 204 @console_ns.route("/apps//workflow/comments/mention-users") class WorkflowCommentMentionUsersApi(Resource): """API for getting mentionable users for workflow comments.""" @error_responses(AppNotFoundError, ForbiddenError) @console_ns.doc("workflow_comment_mention_users") @console_ns.doc(description="Get all users in current tenant for mentions") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response( 200, "Mentionable users retrieved successfully", console_ns.models[WorkflowCommentMentionUsersPayload.__name__] ) @console_account_admission() def get(self, request_context: RequestContext, app_id: UUID): """Get all users in current tenant for mentions.""" services = application_services() with _translate_errors(): services.workflow_comments.ensure_app(request_context, str(app_id)) members = services.workspaces.member_queries.list_members(request_context.active_workspace_id) return dump_response(WorkflowCommentMentionUsersPayload, {"users": members}), 200