Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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 @@ -27,8 +31,10 @@
from servicelib.rest_constants import RESPONSE_MODEL_POLICY

from ..._meta import API_VTAG as VTAG
from ...email import email_service
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
Expand Down Expand Up @@ -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,70 @@ 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
)
user = await users_service.get_user(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,
user_group_id=user["primary_gid"],
)
# Ensure only support conversations are allowed
if conversation.type != ConversationType.SUPPORT:
raise_unsupported_type(conversation.type)

message = await _conversation_message_service.create_message(
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,
message, is_first_message = (
await _conversation_message_service.create_support_message_and_check_if_it_is_first_message(
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:
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 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_and_check_if_it_is_first_message(
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.

Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import logging
from pathlib import Path

from aiohttp import web

from .._resources import webserver_resources
from ..email.utils import AttachmentTuple, send_email_from_template
from ..products import products_web
from ..email.email_service import (
AttachmentTuple,
get_template_path,
send_email_from_template,
)

log = logging.getLogger(__name__)

Expand All @@ -15,17 +16,15 @@ def themed(dirname: str, template: str) -> Path:
return path


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


# 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",
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from pydantic import EmailStr, PositiveInt, TypeAdapter, ValidationError
from servicelib.utils_secrets import generate_passcode

from ..email.utils import send_email_from_template
from ..email.email_service import send_email_from_template
from ..products import products_web
from ..products.models import Product
from ..users import _accounts_service
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Request for Support on {{ host }}
<!--
NOTE: this header is only for testing purposes and is removed automatically before sending
TEST host: {{ host }}
TEST first_name: {{ first_name }}
TEST last_name: {{ last_name }}
TEST user_email: {{ user_email }}
TEST product: {{ product }}
TEST conversation_url: {{ conversation_url }}
TEST message_content: {{ message_content }}
TEST extra_context: {{ extra_context }}
-->
<p>
Dear Support Team,
</p>

<p>
We have received a support request from {{ first_name }} {{ last_name }} ({{ user_email }}) regarding an account in <b>{{ product.display_name }}</b> on <b>{{ host }}</b>.
</p>

<p>
All communication should take place in the Platform Support Center at the following link:
<a href="{{ conversation_url }}">{{ conversation_url }}</a>
</p>

<p>
First message content: {{ message_content }}
</p>

<p>
Extra Context:
</p>

<pre>
{{ dumps(extra_context) }}
</pre>
1 change: 1 addition & 0 deletions services/web/server/tests/unit/with_dbs/03/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ def test_render_templates(template_path: Path, faker: Faker):
"dumps": functools.partial(_json_encoder_and_dumps, indent=1),
"request_form": fake_request_form,
"ipinfo": _get_ipinfo(request),
"extra_context": {"extra": "information"},
},
)

Expand Down
Loading
Loading