Files
dify/api/controllers/console/app/workflow_comment.py
T

440 lines
17 KiB
Python

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.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/<uuid:app_id>/workflow/comments")
class WorkflowCommentListApi(Resource):
"""API for listing and creating workflow comments."""
@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})
@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/<uuid:app_id>/workflow/comments/<string:comment_id>")
class WorkflowCommentDetailApi(Resource):
"""API for managing individual workflow comments."""
@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)
@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)
@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/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
class WorkflowCommentResolveApi(Resource):
"""API for resolving and reopening workflow comments."""
@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/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
class WorkflowCommentReplyApi(Resource):
"""API for managing comment replies."""
@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/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>")
class WorkflowCommentReplyDetailApi(Resource):
"""API for managing individual comment replies."""
@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)
@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/<uuid:app_id>/workflow/comments/mention-users")
class WorkflowCommentMentionUsersApi(Resource):
"""API for getting mentionable users for workflow comments."""
@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