diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index c003be1303..f011f576fd 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -1,22 +1,16 @@ import logging +from datetime import datetime -from flask_restx import Resource, marshal_with -from pydantic import BaseModel, Field, TypeAdapter +from flask_restx import Resource +from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_validator -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from fields.base import ResponseModel from fields.member_fields import AccountWithRole -from fields.workflow_comment_fields import ( - workflow_comment_basic_fields, - workflow_comment_create_fields, - workflow_comment_detail_fields, - workflow_comment_reply_create_fields, - workflow_comment_reply_update_fields, - workflow_comment_resolve_fields, - workflow_comment_update_fields, -) +from libs.helper import build_avatar_url, dump_response, to_timestamp from libs.login import current_user, login_required from models import App from services.account_service import TenantService @@ -51,6 +45,138 @@ 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, AccountWithRole, @@ -59,17 +185,19 @@ register_schema_models( WorkflowCommentUpdatePayload, WorkflowCommentReplyPayload, ) - -workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields) -workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields) -workflow_comment_create_model = console_ns.model("WorkflowCommentCreate", workflow_comment_create_fields) -workflow_comment_update_model = console_ns.model("WorkflowCommentUpdate", workflow_comment_update_fields) -workflow_comment_resolve_model = console_ns.model("WorkflowCommentResolve", workflow_comment_resolve_fields) -workflow_comment_reply_create_model = console_ns.model( - "WorkflowCommentReplyCreate", workflow_comment_reply_create_fields -) -workflow_comment_reply_update_model = console_ns.model( - "WorkflowCommentReplyUpdate", workflow_comment_reply_update_fields +register_response_schema_models( + console_ns, + WorkflowCommentAccount, + WorkflowCommentReply, + WorkflowCommentMention, + WorkflowCommentBasic, + WorkflowCommentBasicList, + WorkflowCommentDetail, + WorkflowCommentCreate, + WorkflowCommentUpdate, + WorkflowCommentResolve, + WorkflowCommentReplyCreate, + WorkflowCommentReplyUpdate, ) @@ -80,28 +208,26 @@ class WorkflowCommentListApi(Resource): @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", workflow_comment_basic_model) + @console_ns.response(200, "Comments retrieved successfully", console_ns.models[WorkflowCommentBasicList.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_basic_model, envelope="data") def get(self, app_model: App): """Get all comments for a workflow.""" comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id) - return comments + return WorkflowCommentBasicList.model_validate({"data": comments}).model_dump(mode="json") @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", workflow_comment_create_model) + @console_ns.response(201, "Comment created successfully", console_ns.models[WorkflowCommentCreate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_create_model) @edit_permission_required def post(self, app_model: App): """Create a new workflow comment.""" @@ -117,7 +243,7 @@ class WorkflowCommentListApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return result, 201 + return dump_response(WorkflowCommentCreate, result), 201 @console_ns.route("/apps//workflow/comments/") @@ -127,30 +253,28 @@ class WorkflowCommentDetailApi(Resource): @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", workflow_comment_detail_model) + @console_ns.response(200, "Comment retrieved successfully", console_ns.models[WorkflowCommentDetail.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_detail_model) def get(self, app_model: App, comment_id: str): """Get a specific workflow comment.""" comment = WorkflowCommentService.get_comment( tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id ) - return comment + 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", workflow_comment_update_model) + @console_ns.response(200, "Comment updated successfully", console_ns.models[WorkflowCommentUpdate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_update_model) @edit_permission_required def put(self, app_model: App, comment_id: str): """Update a workflow comment.""" @@ -167,7 +291,7 @@ class WorkflowCommentDetailApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return result + return dump_response(WorkflowCommentUpdate, result) @console_ns.doc("delete_workflow_comment") @console_ns.doc(description="Delete a workflow comment") @@ -197,12 +321,11 @@ class WorkflowCommentResolveApi(Resource): @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", workflow_comment_resolve_model) + @console_ns.response(200, "Comment resolved successfully", console_ns.models[WorkflowCommentResolve.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_resolve_model) @edit_permission_required def post(self, app_model: App, comment_id: str): """Resolve a workflow comment.""" @@ -213,7 +336,7 @@ class WorkflowCommentResolveApi(Resource): user_id=current_user.id, ) - return comment + return dump_response(WorkflowCommentResolve, comment) @console_ns.route("/apps//workflow/comments//replies") @@ -224,12 +347,11 @@ class WorkflowCommentReplyApi(Resource): @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", workflow_comment_reply_create_model) + @console_ns.response(201, "Reply created successfully", console_ns.models[WorkflowCommentReplyCreate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_reply_create_model) @edit_permission_required def post(self, app_model: App, comment_id: str): """Add a reply to a workflow comment.""" @@ -247,7 +369,7 @@ class WorkflowCommentReplyApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return result, 201 + return dump_response(WorkflowCommentReplyCreate, result), 201 @console_ns.route("/apps//workflow/comments//replies/") @@ -258,12 +380,11 @@ class WorkflowCommentReplyDetailApi(Resource): @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", workflow_comment_reply_update_model) + @console_ns.response(200, "Reply updated successfully", console_ns.models[WorkflowCommentReplyUpdate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_reply_update_model) @edit_permission_required def put(self, app_model: App, comment_id: str, reply_id: str): """Update a comment reply.""" @@ -284,7 +405,7 @@ class WorkflowCommentReplyDetailApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return reply + return dump_response(WorkflowCommentReplyUpdate, reply) @console_ns.doc("delete_workflow_comment_reply") @console_ns.doc(description="Delete a comment reply") diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 691d4de611..7ae5e3b652 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -6,8 +6,7 @@ from flask_restx import fields from pydantic import computed_field, field_validator from fields.base import ResponseModel -from graphon.file import helpers as file_helpers -from libs.helper import to_timestamp +from libs.helper import build_avatar_url, to_timestamp simple_account_fields = { "id": fields.String, @@ -16,14 +15,6 @@ simple_account_fields = { } -def _build_avatar_url(avatar: str | None) -> str | None: - if avatar is None: - return None - if avatar.startswith(("http://", "https://")): - return avatar - return file_helpers.get_signed_file_url(avatar) - - class SimpleAccount(ResponseModel): id: str name: str @@ -36,7 +27,7 @@ class _AccountAvatar(ResponseModel): @computed_field(return_type=str | None) # type: ignore[prop-decorator] @property def avatar_url(self) -> str | None: - return _build_avatar_url(self.avatar) + return build_avatar_url(self.avatar) class Account(_AccountAvatar): diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py deleted file mode 100644 index c708dd3460..0000000000 --- a/api/fields/workflow_comment_fields.py +++ /dev/null @@ -1,96 +0,0 @@ -from flask_restx import fields - -from libs.helper import AvatarUrlField, TimestampField - -# basic account fields for comments -account_fields = { - "id": fields.String, - "name": fields.String, - "email": fields.String, - "avatar_url": AvatarUrlField, -} - -# Comment mention fields -workflow_comment_mention_fields = { - "mentioned_user_id": fields.String, - "mentioned_user_account": fields.Nested(account_fields, allow_null=True), - "reply_id": fields.String, -} - -# Comment reply fields -workflow_comment_reply_fields = { - "id": fields.String, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(account_fields, allow_null=True), - "created_at": TimestampField, -} - -# Basic comment fields (for list views) -workflow_comment_basic_fields = { - "id": fields.String, - "position_x": fields.Float, - "position_y": fields.Float, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(account_fields, allow_null=True), - "created_at": TimestampField, - "updated_at": TimestampField, - "resolved": fields.Boolean, - "resolved_at": TimestampField, - "resolved_by": fields.String, - "resolved_by_account": fields.Nested(account_fields, allow_null=True), - "reply_count": fields.Integer, - "mention_count": fields.Integer, - "participants": fields.List(fields.Nested(account_fields)), -} - -# Detailed comment fields (for single comment view) -workflow_comment_detail_fields = { - "id": fields.String, - "position_x": fields.Float, - "position_y": fields.Float, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(account_fields, allow_null=True), - "created_at": TimestampField, - "updated_at": TimestampField, - "resolved": fields.Boolean, - "resolved_at": TimestampField, - "resolved_by": fields.String, - "resolved_by_account": fields.Nested(account_fields, allow_null=True), - "replies": fields.List(fields.Nested(workflow_comment_reply_fields)), - "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), -} - -# Comment creation response fields (simplified) -workflow_comment_create_fields = { - "id": fields.String, - "created_at": TimestampField, -} - -# Comment update response fields (simplified) -workflow_comment_update_fields = { - "id": fields.String, - "updated_at": TimestampField, -} - -# Comment resolve response fields -workflow_comment_resolve_fields = { - "id": fields.String, - "resolved": fields.Boolean, - "resolved_at": TimestampField, - "resolved_by": fields.String, -} - -# Reply creation response fields (simplified) -workflow_comment_reply_create_fields = { - "id": fields.String, - "created_at": TimestampField, -} - -# Reply update response fields -workflow_comment_reply_update_fields = { - "id": fields.String, - "updated_at": TimestampField, -} diff --git a/api/libs/helper.py b/api/libs/helper.py index 4cc4bba4c3..b66324a5d7 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -136,6 +136,14 @@ def build_icon_url(icon_type: Any, icon: str | None) -> str | None: return file_helpers.get_signed_file_url(icon) +def build_avatar_url(avatar: str | None) -> str | None: + if avatar is None: + return None + if avatar.startswith(("http://", "https://")): + return avatar + return file_helpers.get_signed_file_url(avatar) + + class AvatarUrlField(fields.Raw): def output(self, key, obj, **kwargs): if obj is None: @@ -144,9 +152,7 @@ class AvatarUrlField(fields.Raw): from models import Account if isinstance(obj, Account) and obj.avatar is not None: - if obj.avatar.startswith(("http://", "https://")): - return obj.avatar - return file_helpers.get_signed_file_url(obj.avatar) + return build_avatar_url(obj.avatar) return None @@ -181,6 +187,11 @@ def to_timestamp(value: datetime | int | None) -> int | None: return value +def dump_response(model: type[BaseModel], data: Any) -> dict[str, Any]: + """Serialize a Pydantic response model to JSON-compatible dict output.""" + return model.model_validate(data, from_attributes=True).model_dump(mode="json") + + def current_timestamp() -> int: """Return the current Unix timestamp in seconds.""" return int(time.time()) diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index ffde4a0971..8a7495cb8d 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -2404,7 +2404,7 @@ Get all comments for a workflow | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Comments retrieved successfully | [WorkflowCommentBasic](#workflowcommentbasic) | +| 200 | Comments retrieved successfully | [WorkflowCommentBasicList](#workflowcommentbasiclist) | #### POST ##### Summary @@ -13927,32 +13927,47 @@ User action configuration. | trigger_metadata | | | No | | workflow_run | | | No | +#### WorkflowCommentAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar_url | | | Yes | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + #### WorkflowCommentBasic | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | -| created_at | object | | No | -| created_by | string | | No | -| created_by_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No | -| id | string | | No | -| mention_count | integer | | No | -| participants | [ [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) ] | | No | -| position_x | number | | No | -| position_y | number | | No | -| reply_count | integer | | No | -| resolved | boolean | | No | -| resolved_at | object | | No | -| resolved_by | string | | No | -| resolved_by_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No | -| updated_at | object | | No | +| content | string | | Yes | +| created_at | | | No | +| created_by | string | | Yes | +| created_by_account | | | No | +| id | string | | Yes | +| mention_count | integer | | Yes | +| participants | [ [WorkflowCommentAccount](#workflowcommentaccount) ] | | Yes | +| position_x | number | | Yes | +| position_y | number | | Yes | +| reply_count | integer | | Yes | +| resolved | boolean | | Yes | +| resolved_at | | | No | +| resolved_by | | | No | +| resolved_by_account | | | No | +| updated_at | | | No | + +#### WorkflowCommentBasicList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowCommentBasic](#workflowcommentbasic) ] | | Yes | #### WorkflowCommentCreate | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | -| id | string | | No | +| created_at | | | No | +| id | string | | Yes | #### WorkflowCommentCreatePayload @@ -13967,20 +13982,28 @@ User action configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | -| created_at | object | | No | -| created_by | string | | No | -| created_by_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No | -| id | string | | No | -| mentions | [ [_AnonymousInlineModel_f7ff64cce858](#_anonymousinlinemodel_f7ff64cce858) ] | | No | -| position_x | number | | No | -| position_y | number | | No | -| replies | [ [_AnonymousInlineModel_55c39c6a4b9e](#_anonymousinlinemodel_55c39c6a4b9e) ] | | No | -| resolved | boolean | | No | -| resolved_at | object | | No | -| resolved_by | string | | No | -| resolved_by_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No | -| updated_at | object | | No | +| content | string | | Yes | +| created_at | | | No | +| created_by | string | | Yes | +| created_by_account | | | No | +| id | string | | Yes | +| mentions | [ [WorkflowCommentMention](#workflowcommentmention) ] | | Yes | +| position_x | number | | Yes | +| position_y | number | | Yes | +| replies | [ [WorkflowCommentReply](#workflowcommentreply) ] | | Yes | +| resolved | boolean | | Yes | +| resolved_at | | | No | +| resolved_by | | | No | +| resolved_by_account | | | No | +| updated_at | | | No | + +#### WorkflowCommentMention + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mentioned_user_account | | | No | +| mentioned_user_id | string | | Yes | +| reply_id | | | No | #### WorkflowCommentMentionUsersPayload @@ -13988,12 +14011,22 @@ User action configuration. | ---- | ---- | ----------- | -------- | | users | [ [AccountWithRole](#accountwithrole) ] | | Yes | +#### WorkflowCommentReply + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | +| created_at | | | No | +| created_by | string | | Yes | +| created_by_account | | | No | +| id | string | | Yes | + #### WorkflowCommentReplyCreate | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| created_at | object | | No | -| id | string | | No | +| created_at | | | No | +| id | string | | Yes | #### WorkflowCommentReplyPayload @@ -14006,24 +14039,24 @@ User action configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | No | -| updated_at | object | | No | +| id | string | | Yes | +| updated_at | | | No | #### WorkflowCommentResolve | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | No | -| resolved | boolean | | No | -| resolved_at | object | | No | -| resolved_by | string | | No | +| id | string | | Yes | +| resolved | boolean | | Yes | +| resolved_at | | | No | +| resolved_by | | | No | #### WorkflowCommentUpdate | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | No | -| updated_at | object | | No | +| id | string | | Yes | +| updated_at | | | No | #### WorkflowCommentUpdatePayload @@ -14427,25 +14460,6 @@ Workflow tool configuration | limit | integer | | No | | page | integer | | No | -#### _AnonymousInlineModel_55c39c6a4b9e - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| content | string | | No | -| created_at | object | | No | -| created_by | string | | No | -| created_by_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No | -| id | string | | No | - -#### _AnonymousInlineModel_6fec07cd0d85 - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| avatar_url | object | | No | -| email | string | | No | -| id | string | | No | -| name | string | | No | - #### _AnonymousInlineModel_b1954337d565 | Name | Type | Description | Required | @@ -14455,14 +14469,6 @@ Workflow tool configuration | model_provider_name | string | | No | | summary_prompt | string | | No | -#### _AnonymousInlineModel_f7ff64cce858 - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| mentioned_user_account | [_AnonymousInlineModel_6fec07cd0d85](#_anonymousinlinemodel_6fec07cd0d85) | | No | -| mentioned_user_id | string | | No | -| reply_id | string | | No | - ## FastOpenAPI Preview (OpenAPI 3.0) ### Dify API (FastOpenAPI PoC) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py index 85afcf0e60..baa21999f9 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py @@ -17,6 +17,15 @@ from controllers.console.app import wraps as app_wraps from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole +JAN_1_2024_NOON = datetime(2024, 1, 1, 12, 0, 0) +JAN_1_2024_NOON_TS = int(JAN_1_2024_NOON.timestamp()) +JAN_1_2024_1201 = datetime(2024, 1, 1, 12, 1, 0) +JAN_1_2024_1201_TS = int(JAN_1_2024_1201.timestamp()) +JAN_1_2024_1202 = datetime(2024, 1, 1, 12, 2, 0) +JAN_1_2024_1202_TS = int(JAN_1_2024_1202.timestamp()) +JAN_1_2024_1203 = datetime(2024, 1, 1, 12, 3, 0) +JAN_1_2024_1203_TS = int(JAN_1_2024_1203.timestamp()) + def _make_account(role: TenantAccountRole) -> Account: account = Account(name="tester", email="tester@example.com") @@ -78,6 +87,30 @@ class WriteCase: payload: dict[str, object] | None = None +@dataclass(frozen=True) +class MutationResponseCase: + resource_cls: type + method_name: str + path: str + kwargs: dict[str, str] + service_method_name: str + service_return: object + expected_response: dict[str, object] + payload: dict[str, object] | None = None + expected_status: int | None = None + + +def _unwrap_response(result: object) -> tuple[dict[str, object], int | None]: + if isinstance(result, tuple): + response, status = result + assert isinstance(response, dict) + assert isinstance(status, int) + return response, status + + assert isinstance(result, dict) + return result, None + + @pytest.mark.parametrize( "case", [ @@ -151,17 +184,20 @@ def test_create_comment_allows_editor(app: Flask, monkeypatch: pytest.MonkeyPatc create_comment_mock = MagicMock(return_value={"id": "comment-1"}) monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "create_comment", create_comment_mock) - payload = {"content": "hello", "position_x": 1.0, "position_y": 2.0, "mentioned_user_ids": []} + payload: dict[str, object] = { + "content": "hello", + "position_x": 1.0, + "position_y": 2.0, + "mentioned_user_ids": [], + } with app.test_request_context("/console/api/apps/app-123/workflow/comments", method="POST", json=payload): with _patch_payload(payload): result = workflow_comment_module.WorkflowCommentListApi().post(app_id="app-123") - if isinstance(result, tuple): - response = result[0] - else: - response = result + response, status = _unwrap_response(result) assert response["id"] == "comment-1" + assert status == 201 create_comment_mock.assert_called_once_with( tenant_id="tenant-123", app_id="app-123", @@ -181,14 +217,17 @@ def test_update_comment_omits_mentions_when_payload_does_not_include_them( app_model = _make_app() _patch_console_guards(monkeypatch, account, app_model) - update_comment_mock = MagicMock(return_value={"id": "comment-1", "updated_at": datetime(2024, 1, 1, 12, 0, 0)}) + update_comment_mock = MagicMock(return_value={"id": "comment-1", "updated_at": JAN_1_2024_NOON}) monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "update_comment", update_comment_mock) - payload = {"content": "hello", "position_x": 10.0, "position_y": 20.0} + payload: dict[str, object] = {"content": "hello", "position_x": 10.0, "position_y": 20.0} with app.test_request_context("/console/api/apps/app-123/workflow/comments/comment-1", method="PUT", json=payload): with _patch_payload(payload): - workflow_comment_module.WorkflowCommentDetailApi().put(app_id="app-123", comment_id="comment-1") + result = workflow_comment_module.WorkflowCommentDetailApi().put(app_id="app-123", comment_id="comment-1") + response, status = _unwrap_response(result) + assert response == {"id": "comment-1", "updated_at": JAN_1_2024_NOON_TS} + assert status is None update_comment_mock.assert_called_once_with( tenant_id="tenant-123", app_id="app-123", @@ -199,3 +238,254 @@ def test_update_comment_omits_mentions_when_payload_does_not_include_them( position_y=20.0, mentioned_user_ids=None, ) + + +def test_list_comments_serializes_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + app.config.setdefault("RESTX_MASK_HEADER", "X-Fields") + account = _make_account(TenantAccountRole.NORMAL) + app_model = _make_app() + _patch_console_guards(monkeypatch, account, app_model) + + comment_author = SimpleNamespace( + id="account-123", + name="tester", + email="tester@example.com", + avatar="https://example.com/avatar.png", + ) + comment = SimpleNamespace( + id="comment-1", + position_x=1.5, + position_y=2.5, + content="hello", + created_by="account-123", + created_by_account=comment_author, + created_at=1_700_000_000, + updated_at=1_700_000_001, + resolved=False, + resolved_at=None, + resolved_by=None, + resolved_by_account=None, + reply_count=0, + mention_count=0, + participants=[comment_author], + ) + get_comments_mock = MagicMock(return_value=[comment]) + monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "get_comments", get_comments_mock) + + with app.test_request_context("/console/api/apps/app-123/workflow/comments", method="GET"): + response = workflow_comment_module.WorkflowCommentListApi().get(app_id="app-123") + + assert response == { + "data": [ + { + "id": "comment-1", + "position_x": 1.5, + "position_y": 2.5, + "content": "hello", + "created_by": "account-123", + "created_by_account": { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + }, + "created_at": 1_700_000_000, + "updated_at": 1_700_000_001, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "resolved_by_account": None, + "reply_count": 0, + "mention_count": 0, + "participants": [ + { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + } + ], + } + ] + } + get_comments_mock.assert_called_once_with(tenant_id="tenant-123", app_id="app-123") + + +def test_get_comment_serializes_detail_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + app.config.setdefault("RESTX_MASK_HEADER", "X-Fields") + account = _make_account(TenantAccountRole.NORMAL) + app_model = _make_app() + _patch_console_guards(monkeypatch, account, app_model) + + comment_author = SimpleNamespace( + id="account-123", + name="tester", + email="tester@example.com", + avatar="https://example.com/avatar.png", + ) + mentioned_user = SimpleNamespace( + id="account-456", + name="mentioned", + email="mentioned@example.com", + avatar=None, + ) + comment = SimpleNamespace( + id="comment-1", + position_x=1.5, + position_y=2.5, + content="hello", + created_by="account-123", + created_by_account=comment_author, + created_at=JAN_1_2024_NOON, + updated_at=JAN_1_2024_1201, + resolved=True, + resolved_at=JAN_1_2024_1202, + resolved_by="account-123", + resolved_by_account=comment_author, + replies=[ + SimpleNamespace( + id="reply-1", + content="reply", + created_by="account-456", + created_by_account=mentioned_user, + created_at=JAN_1_2024_1203, + ) + ], + mentions=[ + SimpleNamespace( + mentioned_user_id="account-456", + mentioned_user_account=mentioned_user, + reply_id="reply-1", + ) + ], + ) + get_comment_mock = MagicMock(return_value=comment) + monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "get_comment", get_comment_mock) + + with app.test_request_context("/console/api/apps/app-123/workflow/comments/comment-1", method="GET"): + response = workflow_comment_module.WorkflowCommentDetailApi().get(app_id="app-123", comment_id="comment-1") + + assert response == { + "id": "comment-1", + "position_x": 1.5, + "position_y": 2.5, + "content": "hello", + "created_by": "account-123", + "created_by_account": { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + }, + "created_at": JAN_1_2024_NOON_TS, + "updated_at": JAN_1_2024_1201_TS, + "resolved": True, + "resolved_at": JAN_1_2024_1202_TS, + "resolved_by": "account-123", + "resolved_by_account": { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + }, + "replies": [ + { + "id": "reply-1", + "content": "reply", + "created_by": "account-456", + "created_by_account": { + "id": "account-456", + "name": "mentioned", + "email": "mentioned@example.com", + "avatar_url": None, + }, + "created_at": JAN_1_2024_1203_TS, + } + ], + "mentions": [ + { + "mentioned_user_id": "account-456", + "mentioned_user_account": { + "id": "account-456", + "name": "mentioned", + "email": "mentioned@example.com", + "avatar_url": None, + }, + "reply_id": "reply-1", + } + ], + } + get_comment_mock.assert_called_once_with(tenant_id="tenant-123", app_id="app-123", comment_id="comment-1") + + +@pytest.mark.parametrize( + "case", + [ + MutationResponseCase( + resource_cls=workflow_comment_module.WorkflowCommentResolveApi, + method_name="post", + path="/console/api/apps/app-123/workflow/comments/comment-1/resolve", + kwargs={"app_id": "app-123", "comment_id": "comment-1"}, + service_method_name="resolve_comment", + service_return={ + "id": "comment-1", + "resolved": True, + "resolved_at": JAN_1_2024_NOON, + "resolved_by": "account-123", + }, + expected_response={ + "id": "comment-1", + "resolved": True, + "resolved_at": JAN_1_2024_NOON_TS, + "resolved_by": "account-123", + }, + ), + MutationResponseCase( + resource_cls=workflow_comment_module.WorkflowCommentReplyApi, + method_name="post", + path="/console/api/apps/app-123/workflow/comments/comment-1/replies", + kwargs={"app_id": "app-123", "comment_id": "comment-1"}, + payload={"content": "reply", "mentioned_user_ids": []}, + service_method_name="create_reply", + service_return={"id": "reply-1", "created_at": JAN_1_2024_NOON}, + expected_response={"id": "reply-1", "created_at": JAN_1_2024_NOON_TS}, + expected_status=201, + ), + MutationResponseCase( + resource_cls=workflow_comment_module.WorkflowCommentReplyDetailApi, + method_name="put", + path="/console/api/apps/app-123/workflow/comments/comment-1/replies/reply-1", + kwargs={"app_id": "app-123", "comment_id": "comment-1", "reply_id": "reply-1"}, + payload={"content": "reply", "mentioned_user_ids": []}, + service_method_name="update_reply", + service_return={"id": "reply-1", "updated_at": JAN_1_2024_NOON}, + expected_response={"id": "reply-1", "updated_at": JAN_1_2024_NOON_TS}, + ), + ], +) +def test_mutation_endpoints_serialize_response_models( + app: Flask, monkeypatch: pytest.MonkeyPatch, case: MutationResponseCase +) -> None: + app.config.setdefault("RESTX_MASK_HEADER", "X-Fields") + account = _make_account(TenantAccountRole.EDITOR) + app_model = _make_app() + _patch_console_guards(monkeypatch, account, app_model) + _patch_write_services(monkeypatch) + monkeypatch.setattr( + workflow_comment_module.WorkflowCommentService, + case.service_method_name, + MagicMock(return_value=case.service_return), + ) + + with app.test_request_context(case.path, method=case.method_name.upper(), json=case.payload): + with _patch_payload(case.payload): + result = getattr(case.resource_cls(), case.method_name)(**case.kwargs) + + response, status = _unwrap_response(result) + assert response == case.expected_response + assert status == case.expected_status + + +def test_workflow_comment_response_schemas_are_registered() -> None: + assert workflow_comment_module.WorkflowCommentBasicList.__name__ in workflow_comment_module.console_ns.models + assert workflow_comment_module.WorkflowCommentDetail.__name__ in workflow_comment_module.console_ns.models