Skip to content

Commit 5718eb4

Browse files
✨ On first support message send email to fogbugz (#8238)
1 parent dc6f3c8 commit 5718eb4

File tree

10 files changed

+308
-52
lines changed

10 files changed

+308
-52
lines changed

services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py

Lines changed: 109 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import functools
12
import logging
3+
from typing import Any
24

35
from aiohttp import web
6+
from common_library.json_serialization import json_dumps
47
from models_library.api_schemas_webserver.conversations import (
58
ConversationMessagePatch,
69
ConversationMessageRestGet,
@@ -16,6 +19,7 @@
1619
PageQueryParameters,
1720
)
1821
from models_library.rest_pagination_utils import paginate_data
22+
from models_library.utils.fastapi_encoders import jsonable_encoder
1923
from pydantic import BaseModel, ConfigDict
2024
from servicelib.aiohttp import status
2125
from servicelib.aiohttp.requests_validation import (
@@ -25,11 +29,13 @@
2529
)
2630
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
2731
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
32+
from simcore_service_webserver.users import users_service
2833

2934
from ..._meta import API_VTAG as VTAG
35+
from ...email import email_service
3036
from ...login.decorators import login_required
3137
from ...models import AuthenticatedRequestContext
32-
from ...users import users_service
38+
from ...products import products_web
3339
from ...utils_aiohttp import envelope_json_response
3440
from .. import _conversation_message_service, _conversation_service
3541
from ._common import ConversationPathParams, raise_unsupported_type
@@ -56,6 +62,10 @@ class _ConversationMessageCreateBodyParams(BaseModel):
5662
model_config = ConfigDict(extra="forbid")
5763

5864

65+
def _json_encoder_and_dumps(obj: Any, **kwargs):
66+
return json_dumps(jsonable_encoder(obj), **kwargs)
67+
68+
5969
@routes.post(
6070
f"/{VTAG}/conversations/{{conversation_id}}/messages",
6171
name="create_conversation_message",
@@ -70,27 +80,75 @@ async def create_conversation_message(request: web.Request):
7080
_ConversationMessageCreateBodyParams, request
7181
)
7282

73-
user_primary_gid = await users_service.get_user_primary_group_id(
74-
request.app, user_id=req_ctx.user_id
75-
)
76-
conversation = await _conversation_service.get_conversation_for_user(
77-
app=request.app,
78-
conversation_id=path_params.conversation_id,
79-
user_group_id=user_primary_gid,
83+
_conversation = await _conversation_service.get_conversation(
84+
request.app, conversation_id=path_params.conversation_id
8085
)
81-
# Ensure only support conversations are allowed
82-
if conversation.type != ConversationType.SUPPORT:
83-
raise_unsupported_type(conversation.type)
86+
if _conversation.type != ConversationType.SUPPORT:
87+
raise_unsupported_type(_conversation.type)
8488

85-
message = await _conversation_message_service.create_message(
89+
# This function takes care of granting support user access to the message
90+
await _conversation_service.get_support_conversation_for_user(
8691
app=request.app,
8792
user_id=req_ctx.user_id,
88-
project_id=None, # Support conversations don't use project_id
93+
product_name=req_ctx.product_name,
8994
conversation_id=path_params.conversation_id,
90-
content=body_params.content,
91-
type_=body_params.type,
9295
)
9396

97+
message, is_first_message = (
98+
await _conversation_message_service.create_support_message_with_first_check(
99+
app=request.app,
100+
user_id=req_ctx.user_id,
101+
project_id=None, # Support conversations don't use project_id
102+
conversation_id=path_params.conversation_id,
103+
content=body_params.content,
104+
type_=body_params.type,
105+
)
106+
)
107+
108+
# NOTE: This is done here in the Controller layer, as the interface around email currently needs request
109+
if is_first_message:
110+
try:
111+
user = await users_service.get_user(request.app, req_ctx.user_id)
112+
product = products_web.get_current_product(request)
113+
template_name = "request_support.jinja2"
114+
destination_email = product.support_email
115+
email_template_path = await products_web.get_product_template_path(
116+
request, template_name
117+
)
118+
_url = request.url
119+
if _url.port:
120+
_conversation_url = f"{_url.scheme}://{_url.host}:{_url.port}/#/conversations/{path_params.conversation_id}"
121+
else:
122+
_conversation_url = f"{_url.scheme}://{_url.host}/#/conversations/{path_params.conversation_id}"
123+
_extra_context = _conversation.extra_context
124+
await email_service.send_email_from_template(
125+
request,
126+
from_=product.support_email,
127+
to=destination_email,
128+
template=email_template_path,
129+
context={
130+
"host": request.host,
131+
"product": product.model_dump(
132+
include={
133+
"display_name",
134+
}
135+
),
136+
"first_name": user["first_name"],
137+
"last_name": user["last_name"],
138+
"user_email": user["email"],
139+
"conversation_url": _conversation_url,
140+
"message_content": message.content,
141+
"extra_context": _extra_context,
142+
"dumps": functools.partial(_json_encoder_and_dumps, indent=1),
143+
},
144+
)
145+
except Exception: # pylint: disable=broad-except
146+
_logger.exception(
147+
"Failed to send '%s' email to %s (this means the FogBugz case for the request was not created).",
148+
template_name,
149+
destination_email,
150+
)
151+
94152
data = ConversationMessageRestGet.from_domain_model(message)
95153
return envelope_json_response(data, web.HTTPCreated)
96154

@@ -109,16 +167,19 @@ async def list_conversation_messages(request: web.Request):
109167
_ListConversationMessageQueryParams, request
110168
)
111169

112-
user_primary_gid = await users_service.get_user_primary_group_id(
113-
request.app, user_id=req_ctx.user_id
170+
_conversation = await _conversation_service.get_conversation(
171+
request.app, conversation_id=path_params.conversation_id
114172
)
115-
conversation = await _conversation_service.get_conversation_for_user(
173+
if _conversation.type != ConversationType.SUPPORT:
174+
raise_unsupported_type(_conversation.type)
175+
176+
# This function takes care of granting support user access to the message
177+
await _conversation_service.get_support_conversation_for_user(
116178
app=request.app,
179+
user_id=req_ctx.user_id,
180+
product_name=req_ctx.product_name,
117181
conversation_id=path_params.conversation_id,
118-
user_group_id=user_primary_gid,
119182
)
120-
if conversation.type != ConversationType.SUPPORT:
121-
raise_unsupported_type(conversation.type)
122183

123184
total, messages = (
124185
await _conversation_message_service.list_messages_for_conversation(
@@ -160,16 +221,19 @@ async def get_conversation_message(request: web.Request):
160221
_ConversationMessagePathParams, request
161222
)
162223

163-
user_primary_gid = await users_service.get_user_primary_group_id(
164-
request.app, user_id=req_ctx.user_id
224+
_conversation = await _conversation_service.get_conversation(
225+
request.app, conversation_id=path_params.conversation_id
165226
)
166-
conversation = await _conversation_service.get_conversation_for_user(
227+
if _conversation.type != ConversationType.SUPPORT:
228+
raise_unsupported_type(_conversation.type)
229+
230+
# This function takes care of granting support user access to the message
231+
await _conversation_service.get_support_conversation_for_user(
167232
app=request.app,
233+
user_id=req_ctx.user_id,
234+
product_name=req_ctx.product_name,
168235
conversation_id=path_params.conversation_id,
169-
user_group_id=user_primary_gid,
170236
)
171-
if conversation.type != ConversationType.SUPPORT:
172-
raise_unsupported_type(conversation.type)
173237

174238
message = await _conversation_message_service.get_message(
175239
app=request.app,
@@ -195,16 +259,19 @@ async def update_conversation_message(request: web.Request):
195259
)
196260
body_params = await parse_request_body_as(ConversationMessagePatch, request)
197261

198-
user_primary_gid = await users_service.get_user_primary_group_id(
199-
request.app, user_id=req_ctx.user_id
262+
_conversation = await _conversation_service.get_conversation(
263+
request.app, conversation_id=path_params.conversation_id
200264
)
201-
conversation = await _conversation_service.get_conversation_for_user(
265+
if _conversation.type != ConversationType.SUPPORT:
266+
raise_unsupported_type(_conversation.type)
267+
268+
# This function takes care of granting support user access to the message
269+
await _conversation_service.get_support_conversation_for_user(
202270
app=request.app,
271+
user_id=req_ctx.user_id,
272+
product_name=req_ctx.product_name,
203273
conversation_id=path_params.conversation_id,
204-
user_group_id=user_primary_gid,
205274
)
206-
if conversation.type != ConversationType.SUPPORT:
207-
raise_unsupported_type(conversation.type)
208275

209276
message = await _conversation_message_service.update_message(
210277
app=request.app,
@@ -231,16 +298,19 @@ async def delete_conversation_message(request: web.Request):
231298
_ConversationMessagePathParams, request
232299
)
233300

234-
user_primary_gid = await users_service.get_user_primary_group_id(
235-
request.app, user_id=req_ctx.user_id
301+
_conversation = await _conversation_service.get_conversation(
302+
request.app, conversation_id=path_params.conversation_id
236303
)
237-
conversation = await _conversation_service.get_conversation_for_user(
304+
if _conversation.type != ConversationType.SUPPORT:
305+
raise_unsupported_type(_conversation.type)
306+
307+
# This function takes care of granting support user access to the message
308+
await _conversation_service.get_support_conversation_for_user(
238309
app=request.app,
310+
user_id=req_ctx.user_id,
311+
product_name=req_ctx.product_name,
239312
conversation_id=path_params.conversation_id,
240-
user_group_id=user_primary_gid,
241313
)
242-
if conversation.type != ConversationType.SUPPORT:
243-
raise_unsupported_type(conversation.type)
244314

245315
await _conversation_message_service.delete_message(
246316
app=request.app,

services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
from models_library.rest_ordering import OrderBy, OrderDirection
1616
from models_library.rest_pagination import PageTotalCount
1717
from models_library.users import UserID
18+
from servicelib.redis import exclusive
1819

19-
# Import or define SocketMessageDict
20+
from ..redis import get_redis_lock_manager_client_sdk
2021
from ..users import users_service
2122
from . import _conversation_message_repository
2223
from ._conversation_service import _get_recipients
@@ -28,6 +29,9 @@
2829

2930
_logger = logging.getLogger(__name__)
3031

32+
# Redis lock key for conversation message operations
33+
CONVERSATION_MESSAGE_REDIS_LOCK_KEY = "conversation_message_update:{}"
34+
3135

3236
async def create_message(
3337
app: web.Application,
@@ -60,6 +64,73 @@ async def create_message(
6064
return created_message
6165

6266

67+
async def create_support_message_with_first_check(
68+
app: web.Application,
69+
*,
70+
user_id: UserID,
71+
project_id: ProjectID | None,
72+
conversation_id: ConversationID,
73+
# Creation attributes
74+
content: str,
75+
type_: ConversationMessageType,
76+
) -> tuple[ConversationMessageGetDB, bool]:
77+
"""Create a message and check if it's the first one with Redis lock protection.
78+
79+
This function is protected by Redis exclusive lock because:
80+
- the message creation and first message check must be kept in sync
81+
82+
Args:
83+
app: The web application instance
84+
user_id: ID of the user creating the message
85+
project_id: ID of the project (optional)
86+
conversation_id: ID of the conversation
87+
content: Content of the message
88+
type_: Type of the message
89+
90+
Returns:
91+
Tuple containing the created message and whether it's the first message
92+
"""
93+
94+
@exclusive(
95+
get_redis_lock_manager_client_sdk(app),
96+
lock_key=CONVERSATION_MESSAGE_REDIS_LOCK_KEY.format(conversation_id),
97+
blocking=True,
98+
blocking_timeout=None, # NOTE: this is a blocking call, a timeout has undefined effects
99+
)
100+
async def _create_support_message_and_check_if_it_is_first_message() -> (
101+
tuple[ConversationMessageGetDB, bool]
102+
):
103+
"""This function is protected because
104+
- the message creation and first message check must be kept in sync
105+
"""
106+
created_message = await create_message(
107+
app,
108+
user_id=user_id,
109+
project_id=project_id,
110+
conversation_id=conversation_id,
111+
content=content,
112+
type_=type_,
113+
)
114+
_, messages = await _conversation_message_repository.list_(
115+
app,
116+
conversation_id=conversation_id,
117+
offset=0,
118+
limit=1,
119+
order_by=OrderBy(
120+
field=IDStr("created"), direction=OrderDirection.ASC
121+
), # NOTE: ASC - first/oldest message first
122+
)
123+
124+
is_first_message = False
125+
if messages:
126+
first_message = messages[0]
127+
is_first_message = first_message.message_id == created_message.message_id
128+
129+
return created_message, is_first_message
130+
131+
return await _create_support_message_and_check_if_it_is_first_message()
132+
133+
63134
async def get_message(
64135
app: web.Application,
65136
*,

services/web/server/src/simcore_service_webserver/email/_core.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from aiohttp_jinja2 import render_string
1616
from settings_library.email import EmailProtocol, SMTPSettings
1717

18+
from ..products import products_web
1819
from .settings import get_plugin_settings
1920

2021
_logger = logging.getLogger(__name__)
@@ -225,6 +226,10 @@ def _render_template(
225226
return subject, html_body
226227

227228

229+
async def get_template_path(request: web.Request, filename: str) -> Path:
230+
return await products_web.get_product_template_path(request, filename)
231+
232+
228233
async def send_email_from_template(
229234
request: web.Request,
230235
*,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import logging
2+
3+
from ._core import AttachmentTuple, get_template_path, send_email_from_template
4+
5+
log = logging.getLogger(__name__)
6+
7+
8+
# prevents auto-removal by pycln
9+
# mypy: disable-error-code=truthy-function
10+
assert AttachmentTuple # nosec
11+
assert send_email_from_template # nosec
12+
assert get_template_path # nosec
13+
14+
15+
__all__: tuple[str, ...] = (
16+
"AttachmentTuple",
17+
"send_email_from_template",
18+
"get_template_path",
19+
)

services/web/server/src/simcore_service_webserver/email/utils.py

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)