diff --git a/packages/models-library/src/models_library/api_schemas_webserver/conversations.py b/packages/models-library/src/models_library/api_schemas_webserver/conversations.py index c0fade966011..4f42586a0377 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/conversations.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/conversations.py @@ -26,9 +26,13 @@ class ConversationRestGet(OutputSchema): project_uuid: ProjectID | None user_group_id: GroupID type: ConversationType + fogbugz_case_id: str | None created: datetime modified: datetime extra_context: dict[str, str] + is_read_by_user: bool + is_read_by_support: bool + last_message_created_at: datetime @classmethod def from_domain_model(cls, domain: ConversationGetDB) -> Self: @@ -39,15 +43,21 @@ def from_domain_model(cls, domain: ConversationGetDB) -> Self: project_uuid=domain.project_uuid, user_group_id=domain.user_group_id, type=domain.type, + fogbugz_case_id=domain.fogbugz_case_id, created=domain.created, modified=domain.modified, extra_context=domain.extra_context, + is_read_by_user=domain.is_read_by_user, + is_read_by_support=domain.is_read_by_support, + last_message_created_at=domain.last_message_created_at, ) class ConversationPatch(InputSchema): name: str | None = None extra_context: dict[str, Any] | None = None + is_read_by_user: bool | None = None + is_read_by_support: bool | None = None ### CONVERSATION MESSAGES --------------------------------------------------------------- diff --git a/packages/models-library/src/models_library/conversations.py b/packages/models-library/src/models_library/conversations.py index 8db6a85987c6..a0eb177b7f7a 100644 --- a/packages/models-library/src/models_library/conversations.py +++ b/packages/models-library/src/models_library/conversations.py @@ -38,6 +38,9 @@ class ConversationMessageType(StrAutoEnum): # +IsSupportUser: TypeAlias = bool + + class ConversationGetDB(BaseModel): conversation_id: ConversationID product_name: ProductName @@ -46,10 +49,14 @@ class ConversationGetDB(BaseModel): user_group_id: GroupID type: ConversationType extra_context: dict[str, Any] + fogbugz_case_id: str | None + is_read_by_user: bool + is_read_by_support: bool # states created: datetime modified: datetime + last_message_created_at: datetime model_config = ConfigDict(from_attributes=True) @@ -71,6 +78,10 @@ class ConversationMessageGetDB(BaseModel): class ConversationPatchDB(BaseModel): name: ConversationName | None = None extra_context: dict[str, Any] | None = None + fogbugz_case_id: str | None = None + is_read_by_user: bool | None = None + is_read_by_support: bool | None = None + last_message_created_at: datetime | None = None class ConversationMessagePatchDB(BaseModel): diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/a6289977e057_add_last_message_created_at_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a6289977e057_add_last_message_created_at_column.py new file mode 100644 index 000000000000..97a059cdfd3d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a6289977e057_add_last_message_created_at_column.py @@ -0,0 +1,39 @@ +"""add last_message_created_at column + +Revision ID: a6289977e057 +Revises: dfdd4f8d4870 +Create Date: 2025-10-13 08:39:24.912539+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a6289977e057" +down_revision = "dfdd4f8d4870" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "conversations", + sa.Column( + "last_message_created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + # ### end Alembic commands ### + + # Data migration: populate last_message_created_at with modified column values + op.execute("UPDATE conversations SET last_message_created_at = modified") + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("conversations", "last_message_created_at") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/dfdd4f8d4870_add_read_by_user_support_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/dfdd4f8d4870_add_read_by_user_support_columns.py new file mode 100644 index 000000000000..9f4a338a6164 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/dfdd4f8d4870_add_read_by_user_support_columns.py @@ -0,0 +1,50 @@ +"""add read by user/support columns + +Revision ID: dfdd4f8d4870 +Revises: 9dddb16914a4 +Create Date: 2025-10-10 12:07:12.014847+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "dfdd4f8d4870" +down_revision = "9dddb16914a4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "conversations", sa.Column("fogbugz_case_id", sa.String(), nullable=True) + ) + op.add_column( + "conversations", + sa.Column( + "is_read_by_user", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + op.add_column( + "conversations", + sa.Column( + "is_read_by_support", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("conversations", "is_read_by_support") + op.drop_column("conversations", "is_read_by_user") + op.drop_column("conversations", "fogbugz_case_id") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/conversations.py b/packages/postgres-database/src/simcore_postgres_database/models/conversations.py index a301a7ea70a8..f60271a59860 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/conversations.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/conversations.py @@ -78,6 +78,33 @@ class ConversationType(enum.Enum): server_default=sa.text("'{}'::jsonb"), doc="Free JSON to store extra context", ), + sa.Column( + "fogbugz_case_id", + sa.String, + nullable=True, + doc="Fogbugz case ID associated with the conversation", + ), + sa.Column( + "is_read_by_user", + sa.Boolean, + nullable=False, + server_default=sa.text("true"), + doc="Indicates if the message has been read by the user (true) or not (false)", + ), + sa.Column( + "is_read_by_support", + sa.Boolean, + nullable=False, + server_default=sa.text("true"), + doc="Indicates if the message has been read by the support user (true) or not (false)", + ), + sa.Column( + "last_message_created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.sql.func.now(), + doc="Timestamp of the last message created in this conversation", + ), column_created_datetime(timezone=True), column_modified_datetime(timezone=True), ) diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 6460d11a9211..81fb46b3b4b1 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -10327,6 +10327,16 @@ components: type: object - type: 'null' title: Extracontext + isReadByUser: + anyOf: + - type: boolean + - type: 'null' + title: Isreadbyuser + isReadBySupport: + anyOf: + - type: boolean + - type: 'null' + title: Isreadbysupport type: object title: ConversationPatch ConversationRestGet: @@ -10355,6 +10365,11 @@ components: minimum: 0 type: $ref: '#/components/schemas/ConversationType' + fogbugzCaseId: + anyOf: + - type: string + - type: 'null' + title: Fogbugzcaseid created: type: string format: date-time @@ -10368,6 +10383,16 @@ components: type: string type: object title: Extracontext + isReadByUser: + type: boolean + title: Isreadbyuser + isReadBySupport: + type: boolean + title: Isreadbysupport + lastMessageCreatedAt: + type: string + format: date-time + title: Lastmessagecreatedat type: object required: - conversationId @@ -10376,9 +10401,13 @@ components: - projectUuid - userGroupId - type + - fogbugzCaseId - created - modified - extraContext + - isReadByUser + - isReadBySupport + - lastMessageCreatedAt title: ConversationRestGet ConversationType: type: string diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index 43f0d655e1d2..ba8e287880ea 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -175,8 +175,8 @@ def create_application(tracing_config: TracingConfig) -> web.Application: setup_projects(app) # conversations - setup_conversations(app) setup_fogbugz(app) # Needed for support conversations + setup_conversations(app) # licenses setup_licenses(app) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py index 939cda01deec..ced1bfb1434e 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py @@ -1,4 +1,3 @@ -import functools import logging from typing import Any @@ -31,13 +30,8 @@ from servicelib.rest_constants import RESPONSE_MODEL_POLICY from ..._meta import API_VTAG as VTAG -from ...application_keys import APP_SETTINGS_APPKEY -from ...email import email_service -from ...fogbugz.settings import FogbugzSettings from ...login.decorators import login_required from ...models import AuthenticatedRequestContext -from ...products import products_web -from ...users import users_service from ...utils_aiohttp import envelope_json_response from .. import _conversation_message_service, _conversation_service from ._common import ConversationPathParams, raise_unsupported_type @@ -89,103 +83,25 @@ async def create_conversation_message(request: web.Request): raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message - await _conversation_service.get_support_conversation_for_user( + _, is_support_user = await _conversation_service.get_support_conversation_for_user( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, conversation_id=path_params.conversation_id, ) - message, is_first_message = ( - await _conversation_message_service.create_support_message_with_first_check( - app=request.app, - product_name=req_ctx.product_name, - user_id=req_ctx.user_id, - project_id=None, # Support conversations don't use project_id - conversation_id=path_params.conversation_id, - content=body_params.content, - type_=body_params.type, - ) + message = await _conversation_message_service.create_support_message( + app=request.app, + product_name=req_ctx.product_name, + user_id=req_ctx.user_id, + is_support_user=is_support_user, + conversation=_conversation, + request_url=request.url, + request_host=request.host, + content=body_params.content, + type_=body_params.type, ) - # NOTE: This is done here in the Controller layer, as the interface around email currently needs request - product = products_web.get_current_product(request) - fogbugz_settings_or_none: FogbugzSettings | None = request.app[ - APP_SETTINGS_APPKEY - ].WEBSERVER_FOGBUGZ - if ( - product.support_standard_group_id - and fogbugz_settings_or_none is not None - and is_first_message - ): - _logger.debug( - "Support settings available and FogBugz client configured, creating FogBugz case." - ) - assert product.support_assigned_fogbugz_project_id # nosec - - try: - _url = request.url - _conversation_url = f"{_url.scheme}://{_url.host}/#/conversation/{path_params.conversation_id}" - - await _conversation_service.create_fogbugz_case_for_support_conversation( - request.app, - conversation=_conversation, - user_id=req_ctx.user_id, - message_content=message.content, - conversation_url=_conversation_url, - host=request.host, - product_support_assigned_fogbugz_project_id=product.support_assigned_fogbugz_project_id, - fogbugz_url=str(fogbugz_settings_or_none.FOGBUGZ_URL), - ) - except Exception: # pylint: disable=broad-except - _logger.exception( - "Failed to create support request FogBugz case for conversation %s.", - _conversation.conversation_id, - ) - - elif ( - product.support_standard_group_id - and fogbugz_settings_or_none is None - and is_first_message - ): - _logger.debug( - "Support settings available, but no FogBugz client configured, sending email instead to create FogBugz case." - ) - try: - user = await users_service.get_user(request.app, req_ctx.user_id) - template_name = "request_support.jinja2" - destination_email = product.support_email - email_template_path = await products_web.get_product_template_path( - request, template_name - ) - _url = request.url - _conversation_url = f"{_url.scheme}://{_url.host}/#/conversation/{path_params.conversation_id}" - _extra_context = _conversation.extra_context - await email_service.send_email_from_template( - request, - from_=product.support_email, - to=destination_email, - template=email_template_path, - context={ - "host": request.host, - "first_name": user["first_name"], - "last_name": user["last_name"], - "user_email": user["email"], - "conversation_url": _conversation_url, - "message_content": message.content, - "extra_context": _extra_context, - "dumps": functools.partial(_json_encoder_and_dumps, indent=1), - }, - ) - except Exception: # pylint: disable=broad-except - _logger.exception( - "Failed to send '%s' email to %s (this means the FogBugz case for the request was not created).", - template_name, - destination_email, - ) - else: - _logger.debug("No support settings available, skipping FogBugz case creation.") - data = ConversationMessageRestGet.from_domain_model(message) return envelope_json_response(data, web.HTTPCreated) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py index bed8d79a8a6c..256a7ca94b90 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py @@ -149,7 +149,7 @@ async def get_conversation(request: web.Request): if conversation.type != ConversationType.SUPPORT: raise_unsupported_type(conversation.type) - conversation = await _conversation_service.get_support_conversation_for_user( + conversation, _ = await _conversation_service.get_support_conversation_for_user( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py index e920306c0428..99e463781a73 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py @@ -3,13 +3,16 @@ import logging from aiohttp import web +from common_library.logging.logging_errors import create_troubleshooting_log_kwargs from models_library.basic_types import IDStr from models_library.conversations import ( + ConversationGetDB, ConversationID, ConversationMessageGetDB, ConversationMessageID, ConversationMessagePatchDB, ConversationMessageType, + ConversationPatchDB, ) from models_library.products import ProductName from models_library.projects import ProjectID @@ -17,12 +20,18 @@ from models_library.rest_pagination import PageTotalCount from models_library.users import UserID from servicelib.redis import exclusive +from simcore_service_webserver.application_keys import APP_SETTINGS_APPKEY from simcore_service_webserver.groups import api as group_service +from yarl import URL from ..products import products_service from ..redis import get_redis_lock_manager_client_sdk from ..users import users_service -from . import _conversation_message_repository, _conversation_service +from . import ( + _conversation_message_repository, + _conversation_repository, + _conversation_service, +) from ._socketio import ( notify_conversation_message_created, notify_conversation_message_deleted, @@ -98,12 +107,12 @@ async def create_message( return created_message -async def create_support_message_with_first_check( +async def _create_support_message_with_first_check( app: web.Application, *, product_name: ProductName, user_id: UserID, - project_id: ProjectID | None, + is_support_user: bool, conversation_id: ConversationID, # Creation attributes content: str, @@ -142,7 +151,7 @@ async def _create_support_message_and_check_if_it_is_first_message() -> ( app, product_name=product_name, user_id=user_id, - project_id=project_id, + project_id=None, # Support conversations don't use project_id conversation_id=conversation_id, content=content, type_=type_, @@ -164,7 +173,131 @@ async def _create_support_message_and_check_if_it_is_first_message() -> ( return created_message, is_first_message - return await _create_support_message_and_check_if_it_is_first_message() + message, is_first_message = ( + await _create_support_message_and_check_if_it_is_first_message() + ) + + # NOTE: Update conversation last modified (for frontend listing) and read states + if is_support_user: + _is_read_by_user = False + _is_read_by_support = True + else: + _is_read_by_user = True + _is_read_by_support = False + await _conversation_repository.update( + app, + conversation_id=conversation_id, + updates=ConversationPatchDB( + is_read_by_user=_is_read_by_user, + is_read_by_support=_is_read_by_support, + last_message_created_at=message.created, + ), + ) + return message, is_first_message + + +async def create_support_message( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + is_support_user: bool, + conversation: ConversationGetDB, + request_url: URL, + request_host: str, + # Creation attributes + content: str, + type_: ConversationMessageType, +) -> ConversationMessageGetDB: + message, is_first_message = await _create_support_message_with_first_check( + app=app, + product_name=product_name, + user_id=user_id, + is_support_user=is_support_user, + conversation_id=conversation.conversation_id, + content=content, + type_=type_, + ) + + product = products_service.get_product(app, product_name=product_name) + fogbugz_settings_or_none = app[APP_SETTINGS_APPKEY].WEBSERVER_FOGBUGZ + _conversation_url = f"{request_url.scheme}://{request_url.host}/#/conversation/{conversation.conversation_id}" + + if ( + product.support_standard_group_id is None + or product.support_assigned_fogbugz_project_id is None + or product.support_assigned_fogbugz_person_id is None + or fogbugz_settings_or_none is None + ): + _logger.warning( + "Support settings NOT available, so no need to create FogBugz case. Conversation ID: %s", + conversation.conversation_id, + ) + return message + + if is_first_message or conversation.fogbugz_case_id is None: + _logger.debug( + "Support settings available, this is first message, creating FogBugz case for Conversation ID: %s", + conversation.conversation_id, + ) + assert product.support_assigned_fogbugz_project_id # nosec + + try: + await _conversation_service.create_fogbugz_case_for_support_conversation( + app, + conversation=conversation, + user_id=user_id, + message_content=message.content, + conversation_url=_conversation_url, + host=request_host, + product_support_assigned_fogbugz_project_id=product.support_assigned_fogbugz_project_id, + fogbugz_url=str(fogbugz_settings_or_none.FOGBUGZ_URL), + ) + except Exception as err: # pylint: disable=broad-except + _logger.exception( + **create_troubleshooting_log_kwargs( + f"Failed to create support request FogBugz case for conversation {conversation.conversation_id}.", + error=err, + error_context={ + "conversation": conversation, + "user_id": user_id, + "fogbugz_url": str(fogbugz_settings_or_none.FOGBUGZ_URL), + }, + tip="Check conversation in the database and inform support team (create Fogbugz case manually if needed).", + ) + ) + else: + assert not is_first_message # nosec + _logger.debug( + "Support settings available, but this is NOT the first message, so we need to reopen a FogBugz case. Conversation ID: %s", + conversation.conversation_id, + ) + assert product.support_assigned_fogbugz_project_id # nosec + assert product.support_assigned_fogbugz_person_id # nosec + assert conversation.fogbugz_case_id # nosec + + try: + await _conversation_service.reopen_fogbugz_case_for_support_conversation( + app, + case_id=conversation.fogbugz_case_id, + conversation_url=_conversation_url, + product_support_assigned_fogbugz_person_id=f"{product.support_assigned_fogbugz_person_id}", + ) + except Exception as err: # pylint: disable=broad-except + _logger.exception( + **create_troubleshooting_log_kwargs( + f"Failed to reopen support request FogBugz case for conversation {conversation.conversation_id}", + error=err, + error_context={ + "conversation": conversation, + "user_id": user_id, + "fogbugz_url": str(fogbugz_settings_or_none.FOGBUGZ_URL), + }, + tip="Check conversation in the database and corresponding Fogbugz case", + ) + ) + + return message async def get_message( diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_repository.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_repository.py index 7f0d8267d9ff..49c3ca6676f3 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_repository.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_repository.py @@ -58,6 +58,9 @@ async def create( modified=func.now(), product_name=product_name, extra_context=extra_context, + is_read_by_user=False, # New conversation is unread + is_read_by_support=False, # New conversation is unread + last_message_created_at=func.now(), # No messages yet ) .returning(*_SELECTION_ARGS) ) @@ -174,6 +177,7 @@ async def list_all_support_conversations_for_support_user( app: web.Application, connection: AsyncConnection | None = None, *, + product_name: ProductName, # pagination offset: NonNegativeInt, limit: NonNegativeInt, @@ -184,7 +188,10 @@ async def list_all_support_conversations_for_support_user( base_query = ( select(*_SELECTION_ARGS) .select_from(conversations) - .where(conversations.c.type == ConversationType.SUPPORT) + .where( + (conversations.c.type == ConversationType.SUPPORT) + & (conversations.c.product_name == product_name) + ) ) # Select total count from base_query diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py index bd090239a1a4..facb37be99d3 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py @@ -12,6 +12,7 @@ ConversationID, ConversationPatchDB, ConversationType, + IsSupportUser, ) from models_library.products import ProductName from models_library.projects import ProjectID @@ -184,7 +185,7 @@ async def get_support_conversation_for_user( user_id: UserID, product_name: ProductName, conversation_id: ConversationID, -): +) -> tuple[ConversationGetDB, IsSupportUser]: # Check if user is part of support group (in that case he has access to all support conversations) product = products_service.get_product(app, product_name=product_name) _support_standard_group_id = product.support_standard_group_id @@ -194,16 +195,22 @@ async def get_support_conversation_for_user( ) if _support_standard_group_id in _user_group_ids: # I am a support user - return await get_conversation( - app, conversation_id=conversation_id, type_=ConversationType.SUPPORT + return ( + await get_conversation( + app, conversation_id=conversation_id, type_=ConversationType.SUPPORT + ), + True, ) _user_group_id = await users_service.get_user_primary_group_id(app, user_id=user_id) - return await get_conversation_for_user( - app, - conversation_id=conversation_id, - user_group_id=_user_group_id, - type_=ConversationType.SUPPORT, + return ( + await get_conversation_for_user( + app, + conversation_id=conversation_id, + user_group_id=_user_group_id, + type_=ConversationType.SUPPORT, + ), + False, ) @@ -228,10 +235,12 @@ async def list_support_conversations_for_user( # I am a support user return await _conversation_repository.list_all_support_conversations_for_support_user( app, + product_name=product_name, offset=offset, limit=limit, order_by=OrderBy( - field=IDStr("conversation_id"), direction=OrderDirection.DESC + field=IDStr("last_message_created_at"), + direction=OrderDirection.DESC, ), ) @@ -241,7 +250,9 @@ async def list_support_conversations_for_user( user_group_id=_user_group_id, offset=offset, limit=limit, - order_by=OrderBy(field=IDStr("conversation_id"), direction=OrderDirection.DESC), + order_by=OrderBy( + field=IDStr("last_message_created_at"), direction=OrderDirection.DESC + ), ) @@ -292,5 +303,28 @@ async def create_fogbugz_case_for_support_conversation( f"f/cases/{case_id}", ) }, + fogbugz_case_id=case_id, ), ) + + +async def reopen_fogbugz_case_for_support_conversation( + app: web.Application, + *, + case_id: str, + conversation_url: str, + product_support_assigned_fogbugz_person_id: str, +) -> None: + """Reopen a FogBugz case for a support conversation""" + description = f""" + Dear Support Team, + + We have received a follow up request in this conversation {conversation_url}. + """ + + fogbugz_client = get_fogbugz_rest_client(app) + await fogbugz_client.reopen_case( + case_id=case_id, + assigned_fogbugz_person_id=product_support_assigned_fogbugz_person_id, + reopen_msg=description, + ) diff --git a/services/web/server/src/simcore_service_webserver/conversations/plugin.py b/services/web/server/src/simcore_service_webserver/conversations/plugin.py index 59b5a3f06b47..5d9fec4915c6 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/plugin.py +++ b/services/web/server/src/simcore_service_webserver/conversations/plugin.py @@ -6,6 +6,7 @@ from ..application_keys import APP_SETTINGS_APPKEY from ..application_setup import ModuleCategory, app_setup_func +from ..fogbugz.plugin import setup_fogbugz from ._controller import _conversations_messages_rest, _conversations_rest _logger = logging.getLogger(__name__) @@ -21,5 +22,7 @@ def setup_conversations(app: web.Application): assert app[APP_SETTINGS_APPKEY].WEBSERVER_CONVERSATIONS # nosec + setup_fogbugz(app) + app.router.add_routes(_conversations_rest.routes) app.router.add_routes(_conversations_messages_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/fogbugz/_client.py b/services/web/server/src/simcore_service_webserver/fogbugz/_client.py index f3844e4dd19d..8df60434351a 100644 --- a/services/web/server/src/simcore_service_webserver/fogbugz/_client.py +++ b/services/web/server/src/simcore_service_webserver/fogbugz/_client.py @@ -11,6 +11,14 @@ import httpx from aiohttp import web from pydantic import AnyUrl, BaseModel, Field, SecretStr +from servicelib.aiohttp import status +from tenacity import ( + retry, + retry_if_exception_type, + retry_if_result, + stop_after_attempt, + wait_exponential, +) from ..products import products_service from ..products.models import Product @@ -22,12 +30,25 @@ _UNKNOWN_ERROR_MESSAGE = "Unknown error occurred" +class FogbugzClientBaseError(Exception): + """Base exception class for Fogbugz client errors""" + + class FogbugzCaseCreate(BaseModel): fogbugz_project_id: int = Field(description="Project ID in Fogbugz") title: str = Field(description="Case title") description: str = Field(description="Case description/first comment") +def _should_retry(response: httpx.Response | None) -> bool: + if response is None: + return True + return ( + response.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR + or response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + ) + + class FogbugzRestClient: """REST client for Fogbugz API""" @@ -38,15 +59,40 @@ def __init__(self, api_token: SecretStr, base_url: AnyUrl) -> None: async def _make_api_request(self, json_payload: dict[str, Any]) -> dict[str, Any]: """Make a request to Fogbugz API with common formatting""" - # Fogbugz requires multipart/form-data with stringified JSON - files = {"request": (None, json.dumps(json_payload), _JSON_CONTENT_TYPE)} - - url = urljoin(f"{self._base_url}", "f/api/0/jsonapi") - response = await self._client.post(url, files=files) - response.raise_for_status() - response_data: dict[str, Any] = response.json() - return response_data + @retry( + retry=( + retry_if_result(_should_retry) + | retry_if_exception_type( + ( + httpx.ConnectError, + httpx.TimeoutException, + httpx.NetworkError, + httpx.ProtocolError, + ) + ) + ), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, + ) + async def _request() -> httpx.Response: + # Fogbugz requires multipart/form-data with stringified JSON + files = {"request": (None, json.dumps(json_payload), _JSON_CONTENT_TYPE)} + url = urljoin(f"{self._base_url}", "f/api/0/jsonapi") + + return await self._client.post(url, files=files) + + try: + response = await _request() + response.raise_for_status() + response_data: dict[str, Any] = response.json() + return response_data + except Exception: + _logger.error( # noqa: TRY400 + "Failed to make API request to Fogbugz with payload: %s", json_payload + ) + raise async def create_case(self, data: FogbugzCaseCreate) -> str: """Create a new case in Fogbugz""" @@ -64,7 +110,7 @@ async def create_case(self, data: FogbugzCaseCreate) -> str: case_id = response_data.get("data", {}).get("case", {}).get("ixBug", None) if case_id is None: msg = "Failed to create case in Fogbugz" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) return str(case_id) @@ -82,7 +128,7 @@ async def resolve_case(self, case_id: str) -> None: if response_data.get("error"): error_msg = response_data.get("error", _UNKNOWN_ERROR_MESSAGE) msg = f"Failed to resolve case in Fogbugz: {error_msg}" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) async def get_case_status(self, case_id: str) -> str: """Get the status of a case in Fogbugz""" @@ -99,13 +145,13 @@ async def get_case_status(self, case_id: str) -> str: if response_data.get("error"): error_msg = response_data.get("error", _UNKNOWN_ERROR_MESSAGE) msg = f"Failed to get case status from Fogbugz: {error_msg}" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) # Extract the status from the search results cases = response_data.get("data", {}).get("cases", []) if not cases: msg = f"Case {case_id} not found in Fogbugz" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) # Find the case with matching ixBug target_case = None @@ -116,35 +162,41 @@ async def get_case_status(self, case_id: str) -> str: if target_case is None: msg = f"Case {case_id} not found in search results" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) # Get the status from the found case - status: str = target_case.get("sStatus", "") - if not status: + _status: str = target_case.get("sStatus", "") + if not _status: msg = f"Status not found for case {case_id}" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) - return status + return _status - async def reopen_case(self, case_id: str, assigned_fogbugz_person_id: str) -> None: + async def reopen_case( + self, case_id: str, assigned_fogbugz_person_id: str, reopen_msg: str = "" + ) -> None: """Reopen a case in Fogbugz (uses reactivate for resolved cases, reopen for closed cases)""" # First get the current status to determine which command to use current_status = await self.get_case_status(case_id) # Determine the command based on current status + if current_status.lower().startswith("active"): + return # Case is already active, no action needed + if current_status.lower().startswith("resolved"): cmd = "reactivate" elif current_status.lower().startswith("closed"): cmd = "reopen" else: msg = f"Cannot reopen case {case_id} with status '{current_status}'. Only resolved or closed cases can be reopened." - raise ValueError(msg) + raise FogbugzClientBaseError(msg) json_payload = { "cmd": cmd, "token": self._api_token.get_secret_value(), "ixBug": case_id, "ixPersonAssignedTo": assigned_fogbugz_person_id, + "sEvent": reopen_msg, } response_data = await self._make_api_request(json_payload) @@ -153,7 +205,15 @@ async def reopen_case(self, case_id: str, assigned_fogbugz_person_id: str) -> No if response_data.get("error"): error_msg = response_data.get("error", _UNKNOWN_ERROR_MESSAGE) msg = f"Failed to reopen case in Fogbugz: {error_msg}" - raise ValueError(msg) + raise FogbugzClientBaseError(msg) + + async def __aenter__(self): + """Async context manager entry""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit - cleanup client""" + await self._client.aclose() _APPKEY: Final = web.AppKey(FogbugzRestClient.__name__, FogbugzRestClient) @@ -172,13 +232,13 @@ async def setup_fogbugz_rest_client(app: web.Application) -> None: f"Product '{product.name}' has support_standard_group_id set " "but `support_assigned_fogbugz_person_id` is not configured." ) - raise ValueError(msg) + raise FogbugzClientBaseError(msg) if product.support_assigned_fogbugz_project_id is None: msg = ( f"Product '{product.name}' has support_standard_group_id set " "but `support_assigned_fogbugz_project_id` is not configured." ) - raise ValueError(msg) + raise FogbugzClientBaseError(msg) else: _logger.info( "Product '%s' has support conversation disabled (therefore Fogbugz integration is not necessary for this product)", diff --git a/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py b/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py index 9fb63eb81655..b6a03ae7800b 100644 --- a/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py @@ -21,12 +21,17 @@ ConversationMessageGetDB, ConversationMessageType, ) -from pytest_mock import MockerFixture +from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict +from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.conversations import _conversation_message_service from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.fogbugz._client import _APPKEY, FogbugzRestClient +from simcore_service_webserver.groups import _groups_repository +from simcore_service_webserver.products import products_service @pytest.fixture @@ -450,18 +455,61 @@ async def test_conversation_messages_nonexistent_resources( await assert_status(resp, status.HTTP_404_NOT_FOUND) +@pytest.fixture +def app_environment( + app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch +) -> EnvVarsDict: + return app_environment | setenvs_from_dict( + monkeypatch, + {"FOGBUGZ_API_TOKEN": "token-12345", "FOGBUGZ_URL": "http://test.com"}, + ) + + +@pytest.fixture(autouse=True) +def mocked_fogbugz_client(client: TestClient, mocker: MockerFixture) -> MockType: + """Auto-mock the Fogbugz client in every test""" + mock_client = mocker.AsyncMock(spec=FogbugzRestClient) + mock_client.create_case.return_value = "test-case-12345" + mock_client.reopen_case.return_value = None + mock_client.get_case_status.return_value = "Active" + mock_client.resolve_case.return_value = None + + # Replace the client in the app storage + client.app[_APPKEY] = mock_client + + return mock_client + + +@pytest.fixture +def mocked_list_users_in_group(mocker: MockerFixture) -> MockType: + """Mock the list_users_in_group function to return empty list""" + mock = mocker.patch.object(_groups_repository, "list_users_in_group") + mock.return_value = [] + return mock + + +@pytest.fixture +def mocked_get_current_product(mocker: MockerFixture) -> MockType: + """Mock the get_product function to return a product with support settings""" + mock = mocker.patch.object(products_service, "get_product") + mocked_product = mocker.Mock() + mocked_product.support_standard_group_id = 123 + mocked_product.support_assigned_fogbugz_project_id = 456 + mocked_product.support_assigned_fogbugz_person_id = 789 + mock.return_value = mocked_product + return mock + + @pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_conversation_messages_with_database( + app_environment: EnvVarsDict, client: TestClient, + mocked_fogbugz_client: MockType, + mocked_list_users_in_group: MockType, + mocked_get_current_product: MockType, logged_user: UserInfoDict, - mocker: MockerFixture, ): """Test conversation messages with direct database interaction""" - # Mock the email service to verify it's called for first message - mock_send_email = mocker.patch( - "simcore_service_webserver.email.email_service.send_email_from_template" - ) - mocker.patch("simcore_service_webserver.products.products_web.get_current_product") assert client.app # Create a conversation directly via API (no mocks) @@ -490,8 +538,9 @@ async def test_conversation_messages_with_database( assert message_data["type"] == "MESSAGE" assert message_data["conversationId"] == conversation_id - # Verify email was sent for first message - assert mock_send_email.call_count == 1 + # Verify fogbugz case was created for first message + assert mocked_fogbugz_client.create_case.call_count == 1 + assert not mocked_fogbugz_client.reopen_case.called # Create a second message second_message_body = {"content": "Second message", "type": "MESSAGE"} @@ -504,5 +553,8 @@ async def test_conversation_messages_with_database( assert second_message_data["type"] == "MESSAGE" assert second_message_data["conversationId"] == conversation_id - # Verify email was NOT sent again for second message (still only 1 call) - assert mock_send_email.call_count == 1 + # Verify fogbugz case was NOT created again for second message (still only 1 call) + assert mocked_fogbugz_client.create_case.call_count == 1 + assert mocked_fogbugz_client.reopen_case.called + assert second_message_data["type"] == "MESSAGE" + assert second_message_data["conversationId"] == conversation_id diff --git a/services/web/server/tests/unit/with_dbs/04/test_fogbugz_client.py b/services/web/server/tests/unit/with_dbs/04/test_fogbugz_client.py index 6f105e79f16d..f6e3f6736603 100644 --- a/services/web/server/tests/unit/with_dbs/04/test_fogbugz_client.py +++ b/services/web/server/tests/unit/with_dbs/04/test_fogbugz_client.py @@ -120,6 +120,10 @@ async def test_fogubugz_client( status = await fogbugz_client.get_case_status(case_id) assert status == "Resolved (Completed)" - await fogbugz_client.reopen_case(case_id, assigned_fogbugz_person_id="281") + await fogbugz_client.reopen_case( + case_id, + assigned_fogbugz_person_id="281", + reopen_msg="Reopening the case with customer request", + ) status = await fogbugz_client.get_case_status(case_id) assert status == "Active"