Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import functools
import logging
from typing import Any

from aiohttp import web
from common_library.json_serialization import json_dumps
from models_library.api_schemas_webserver.conversations import (
ConversationMessagePatch,
ConversationMessageRestGet,
Expand All @@ -16,6 +19,7 @@
PageQueryParameters,
)
from models_library.rest_pagination_utils import paginate_data
from models_library.utils.fastapi_encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict
from servicelib.aiohttp import status
from servicelib.aiohttp.requests_validation import (
Expand All @@ -25,11 +29,13 @@
)
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
from simcore_service_webserver.users import users_service

from ..._meta import API_VTAG as VTAG
from ...email import email_service
from ...login.decorators import login_required
from ...models import AuthenticatedRequestContext
from ...users import users_service
from ...products import products_web
from ...utils_aiohttp import envelope_json_response
from .. import _conversation_message_service, _conversation_service
from ._common import ConversationPathParams, raise_unsupported_type
Expand All @@ -56,6 +62,10 @@ class _ConversationMessageCreateBodyParams(BaseModel):
model_config = ConfigDict(extra="forbid")


def _json_encoder_and_dumps(obj: Any, **kwargs):
return json_dumps(jsonable_encoder(obj), **kwargs)


@routes.post(
f"/{VTAG}/conversations/{{conversation_id}}/messages",
name="create_conversation_message",
Expand All @@ -70,27 +80,75 @@ async def create_conversation_message(request: web.Request):
_ConversationMessageCreateBodyParams, request
)

user_primary_gid = await users_service.get_user_primary_group_id(
request.app, user_id=req_ctx.user_id
)
conversation = await _conversation_service.get_conversation_for_user(
app=request.app,
conversation_id=path_params.conversation_id,
user_group_id=user_primary_gid,
_conversation = await _conversation_service.get_conversation(
request.app, conversation_id=path_params.conversation_id
)
# Ensure only support conversations are allowed
if conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(conversation.type)
if _conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(_conversation.type)

message = await _conversation_message_service.create_message(
# This function takes care of granting support user access to the message
await _conversation_service.get_support_conversation_for_user(
app=request.app,
user_id=req_ctx.user_id,
project_id=None, # Support conversations don't use project_id
product_name=req_ctx.product_name,
conversation_id=path_params.conversation_id,
content=body_params.content,
type_=body_params.type,
)

message, is_first_message = (
await _conversation_message_service.create_support_message_with_first_check(
app=request.app,
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,
)
)

# NOTE: This is done here in the Controller layer, as the interface around email currently needs request
if is_first_message:
try:
user = await users_service.get_user(request.app, req_ctx.user_id)
product = products_web.get_current_product(request)
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
if _url.port:
_conversation_url = f"{_url.scheme}://{_url.host}:{_url.port}/#/conversations/{path_params.conversation_id}"
else:
_conversation_url = f"{_url.scheme}://{_url.host}/#/conversations/{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,
"product": product.model_dump(
include={
"display_name",
}
),
"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,
)

data = ConversationMessageRestGet.from_domain_model(message)
return envelope_json_response(data, web.HTTPCreated)

Expand All @@ -109,16 +167,19 @@ async def list_conversation_messages(request: web.Request):
_ListConversationMessageQueryParams, request
)

user_primary_gid = await users_service.get_user_primary_group_id(
request.app, user_id=req_ctx.user_id
_conversation = await _conversation_service.get_conversation(
request.app, conversation_id=path_params.conversation_id
)
conversation = await _conversation_service.get_conversation_for_user(
if _conversation.type != ConversationType.SUPPORT:
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(
app=request.app,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
conversation_id=path_params.conversation_id,
user_group_id=user_primary_gid,
)
if conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(conversation.type)

total, messages = (
await _conversation_message_service.list_messages_for_conversation(
Expand Down Expand Up @@ -160,16 +221,19 @@ async def get_conversation_message(request: web.Request):
_ConversationMessagePathParams, request
)

user_primary_gid = await users_service.get_user_primary_group_id(
request.app, user_id=req_ctx.user_id
_conversation = await _conversation_service.get_conversation(
request.app, conversation_id=path_params.conversation_id
)
conversation = await _conversation_service.get_conversation_for_user(
if _conversation.type != ConversationType.SUPPORT:
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(
app=request.app,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
conversation_id=path_params.conversation_id,
user_group_id=user_primary_gid,
)
if conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(conversation.type)

message = await _conversation_message_service.get_message(
app=request.app,
Expand All @@ -195,16 +259,19 @@ async def update_conversation_message(request: web.Request):
)
body_params = await parse_request_body_as(ConversationMessagePatch, request)

user_primary_gid = await users_service.get_user_primary_group_id(
request.app, user_id=req_ctx.user_id
_conversation = await _conversation_service.get_conversation(
request.app, conversation_id=path_params.conversation_id
)
conversation = await _conversation_service.get_conversation_for_user(
if _conversation.type != ConversationType.SUPPORT:
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(
app=request.app,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
conversation_id=path_params.conversation_id,
user_group_id=user_primary_gid,
)
if conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(conversation.type)

message = await _conversation_message_service.update_message(
app=request.app,
Expand All @@ -231,16 +298,19 @@ async def delete_conversation_message(request: web.Request):
_ConversationMessagePathParams, request
)

user_primary_gid = await users_service.get_user_primary_group_id(
request.app, user_id=req_ctx.user_id
_conversation = await _conversation_service.get_conversation(
request.app, conversation_id=path_params.conversation_id
)
conversation = await _conversation_service.get_conversation_for_user(
if _conversation.type != ConversationType.SUPPORT:
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(
app=request.app,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
conversation_id=path_params.conversation_id,
user_group_id=user_primary_gid,
)
if conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(conversation.type)

await _conversation_message_service.delete_message(
app=request.app,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
from models_library.rest_ordering import OrderBy, OrderDirection
from models_library.rest_pagination import PageTotalCount
from models_library.users import UserID
from servicelib.redis import exclusive

# Import or define SocketMessageDict
from ..redis import get_redis_lock_manager_client_sdk
from ..users import users_service
from . import _conversation_message_repository
from ._conversation_service import _get_recipients
Expand All @@ -28,6 +29,9 @@

_logger = logging.getLogger(__name__)

# Redis lock key for conversation message operations
CONVERSATION_MESSAGE_REDIS_LOCK_KEY = "conversation_message_update:{}"


async def create_message(
app: web.Application,
Expand Down Expand Up @@ -60,6 +64,73 @@ async def create_message(
return created_message


async def create_support_message_with_first_check(
app: web.Application,
*,
user_id: UserID,
project_id: ProjectID | None,
conversation_id: ConversationID,
# Creation attributes
content: str,
type_: ConversationMessageType,
) -> tuple[ConversationMessageGetDB, bool]:
"""Create a message and check if it's the first one with Redis lock protection.

This function is protected by Redis exclusive lock because:
- the message creation and first message check must be kept in sync

Args:
app: The web application instance
user_id: ID of the user creating the message
project_id: ID of the project (optional)
conversation_id: ID of the conversation
content: Content of the message
type_: Type of the message

Returns:
Tuple containing the created message and whether it's the first message
"""

@exclusive(
get_redis_lock_manager_client_sdk(app),
lock_key=CONVERSATION_MESSAGE_REDIS_LOCK_KEY.format(conversation_id),
blocking=True,
blocking_timeout=None, # NOTE: this is a blocking call, a timeout has undefined effects
)
async def _create_support_message_and_check_if_it_is_first_message() -> (
tuple[ConversationMessageGetDB, bool]
):
"""This function is protected because
- the message creation and first message check must be kept in sync
"""
created_message = await create_message(
app,
user_id=user_id,
project_id=project_id,
conversation_id=conversation_id,
content=content,
type_=type_,
)
_, messages = await _conversation_message_repository.list_(
app,
conversation_id=conversation_id,
offset=0,
limit=1,
order_by=OrderBy(
field=IDStr("created"), direction=OrderDirection.ASC
), # NOTE: ASC - first/oldest message first
)

is_first_message = False
if messages:
first_message = messages[0]
is_first_message = first_message.message_id == created_message.message_id

return created_message, is_first_message

return await _create_support_message_and_check_if_it_is_first_message()


async def get_message(
app: web.Application,
*,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from aiohttp_jinja2 import render_string
from settings_library.email import EmailProtocol, SMTPSettings

from ..products import products_web
from .settings import get_plugin_settings

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -225,6 +226,10 @@ def _render_template(
return subject, html_body


async def get_template_path(request: web.Request, filename: str) -> Path:
return await products_web.get_product_template_path(request, filename)


async def send_email_from_template(
request: web.Request,
*,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging

from ._core import AttachmentTuple, get_template_path, send_email_from_template

log = logging.getLogger(__name__)


# prevents auto-removal by pycln
# mypy: disable-error-code=truthy-function
assert AttachmentTuple # nosec
assert send_email_from_template # nosec
assert get_template_path # nosec


__all__: tuple[str, ...] = (
"AttachmentTuple",
"send_email_from_template",
"get_template_path",
)

This file was deleted.

Loading
Loading