Skip to content

Commit 1dd885b

Browse files
[MPT-14189] - Improve MsTeams notification style
1 parent 79bad10 commit 1dd885b

File tree

5 files changed

+218
-104
lines changed

5 files changed

+218
-104
lines changed

ffc/billing/notification_helper.py

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
import textwrap
44
from datetime import date
55

6+
from adaptive_cards import card_types as ct # type: ignore[import-untyped]
7+
68
from ffc.billing.dataclasses import (
79
NotificationLevel,
810
ProcessResult,
911
ProcessResultInfo,
1012
)
1113
from ffc.notifications import (
14+
ColumnHeader,
1215
NotificationDetails,
1316
send_exception,
1417
send_info,
@@ -51,36 +54,40 @@ def _build_notification_title_text(
5154
)
5255

5356

54-
def _build_notification_details(level: NotificationLevel, details: list) -> NotificationDetails:
57+
def _build_notification_details(details: list) -> NotificationDetails:
5558
"""
5659
This function builds a NotificationDetails object depending on the
5760
given notification level.
5861
"""
59-
60-
if level == NotificationLevel.SUCCESS:
61-
return NotificationDetails(
62-
("Authorization", "Journal"),
63-
[
64-
(
65-
f"{item.authorization_id}",
66-
f"{item.journal_id or '-'}",
67-
)
68-
for item in details
69-
],
70-
)
71-
else:
72-
return NotificationDetails(
73-
("Authorization", "Journal", "Status", "Message"),
74-
[
75-
(
76-
f"{item.authorization_id}",
77-
f"{item.journal_id or '-'}",
78-
f"{item.result.value.upper()}",
79-
"\n\n".join(textwrap.wrap(item.message or "-", width=80)),
80-
)
81-
for item in details
82-
],
83-
)
62+
prefix_icon = {
63+
"JOURNAL_GENERATED": "✅",
64+
"JOURNAL_SKIPPED": "⏭️",
65+
"ERROR": "❌",
66+
}
67+
68+
return NotificationDetails(
69+
header=(
70+
ColumnHeader(
71+
"Authorization", width="120px", horizontal_alignment=ct.HorizontalAlignment.CENTER
72+
),
73+
ColumnHeader(
74+
"Journal", width="120px", horizontal_alignment=ct.HorizontalAlignment.CENTER
75+
),
76+
ColumnHeader(
77+
"Status", width="50px", horizontal_alignment=ct.HorizontalAlignment.CENTER
78+
),
79+
ColumnHeader("Message", width="stretch"),
80+
),
81+
rows=[
82+
(
83+
f"{item.authorization_id}",
84+
f"{item.journal_id or ''}",
85+
f"{prefix_icon.get(item.result.value.upper(), '')}",
86+
"\n\n".join(textwrap.wrap(item.message or "", width=80)),
87+
)
88+
for item in details
89+
],
90+
)
8491

8592

8693
async def _send_notification(
@@ -94,7 +101,7 @@ async def _send_notification(
94101
await func(
95102
title=title,
96103
text=text,
97-
details=_build_notification_details(level=level, details=results_counter_details),
104+
details=_build_notification_details(details=results_counter_details),
98105
)
99106

100107

ffc/billing/process_billing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ async def process(self) -> ProcessResultInfo:
298298
authorization_id=self.authorization_id,
299299
result=ProcessResult.JOURNAL_GENERATED,
300300
)
301-
result_info.journal_id = (created_journal or {}).get("id", "-")
301+
result_info.journal_id = (created_journal or {}).get("id", "")
302302
return result_info
303303
else:
304304
result_info = ProcessResultInfo(

ffc/notifications.py

Lines changed: 91 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,32 @@
22
import functools
33
import logging
44
import os
5+
from dataclasses import dataclass
56
from datetime import datetime
67
from enum import Enum
8+
from typing import Any
79

810
import httpx
911
from adaptive_cards import card_types as ct
1012
from adaptive_cards.actions import ActionOpenUrl
1113
from adaptive_cards.card import AdaptiveCard
12-
from adaptive_cards.containers import Column, ColumnSet
14+
from adaptive_cards.card_types import MSTeams, MSTeamsCardWidth
15+
from adaptive_cards.containers import Column, ColumnSet, Container
1316
from adaptive_cards.elements import TextBlock
1417
from django.conf import settings
1518
from jinja2 import Environment, FileSystemLoader, select_autoescape
1619
from markdown_it import MarkdownIt
1720
from mpt_extension_sdk.mpt_http.base import MPTClient
18-
from mpt_extension_sdk.mpt_http.mpt import get_rendered_template, notify
21+
from mpt_extension_sdk.mpt_http.mpt import (
22+
get_rendered_template,
23+
notify,
24+
)
1925

2026
from ffc.flows.order import OrderContext
2127
from ffc.parameters import PARAM_CONTACT, get_ordering_parameter
2228

2329
logger = logging.getLogger(__name__)
24-
NotifyCategories = Enum("NotifyCategories", settings.MPT_NOTIFY_CATEGORIES)
30+
NotifyCategories = Enum("NotifyCategories", settings.MPT_NOTIFY_CATEGORIES) # type: ignore[misc]
2531

2632

2733
def dateformat(date_string):
@@ -40,6 +46,7 @@ def dateformat(date_string):
4046

4147
env.filters["dateformat"] = dateformat
4248

49+
4350
def mpt_notify(
4451
mpt_client,
4552
account_id: str,
@@ -117,9 +124,9 @@ def send_mpt_notification(client: MPTClient, order_context: type[OrderContext])
117124
"portal_base_url": settings.MPT_PORTAL_BASE_URL,
118125
}
119126
buyer_name = order_context.order["agreement"]["buyer"]["name"]
120-
subject = f"Order status update {order_context.order_id} " f"for {buyer_name}"
127+
subject = f"Order status update {order_context.order_id} for {buyer_name}"
121128
if order_context.order["status"] == "Querying":
122-
subject = f"This order need your attention {order_context.order_id} " f"for {buyer_name}"
129+
subject = f"This order need your attention {order_context.order_id} for {buyer_name}"
123130
mpt_notify(
124131
client,
125132
order_context.order["agreement"]["client"]["id"],
@@ -142,41 +149,86 @@ def notify_unhandled_exception_in_teams(process, order_id, traceback): # pragma
142149
)
143150

144151

152+
@dataclass
153+
class ColumnHeader:
154+
text: str
155+
width: str = "auto"
156+
horizontal_alignment: ct.HorizontalAlignment | None = None
157+
145158

146159
class NotificationDetails:
147-
def __init__(self, header: tuple[str, ...], rows: list[tuple[str, ...]]):
160+
def __init__(self, header: tuple[str | ColumnHeader, ...], rows: list[tuple[str, ...]]):
148161
if not all(len(t) == len(header) for t in rows):
149162
raise ValueError("All rows must have the same number of columns as the header.")
150163
self.header = header
151164
self.rows = rows
152165

153-
def to_column_set(self) -> ColumnSet:
154-
columns = []
155-
for title in self.header:
156-
column = Column(
157-
width="auto",
158-
items=[
159-
TextBlock(
160-
text=title,
161-
weight=ct.FontWeight.BOLDER,
162-
wrap=True,
163-
)
164-
],
166+
@staticmethod
167+
def _get_header_text_and_width(col: str | ColumnHeader) -> tuple[str, str]:
168+
if isinstance(col, ColumnHeader):
169+
return col.text, col.width
170+
return str(col), "auto"
171+
172+
def to_container(self) -> Container:
173+
items = []
174+
175+
# Header row
176+
header_columns = []
177+
for col in self.header:
178+
text, width = self._get_header_text_and_width(col)
179+
alignment = (
180+
col.horizontal_alignment.value
181+
if isinstance(col, ColumnHeader) and col.horizontal_alignment
182+
else None
165183
)
166-
columns.append(column)
167-
168-
column_set = ColumnSet(columns=columns)
169-
for row_idx, row in enumerate(self.rows):
170-
for col_idx, item in enumerate(row):
171-
columns[col_idx].items.append(
172-
TextBlock(
173-
text=item,
174-
wrap=True,
175-
color=ct.Colors.DEFAULT if row_idx % 2 == 0 else ct.Colors.ACCENT,
184+
header_columns.append(
185+
Column(
186+
width=width,
187+
items=[
188+
TextBlock(
189+
text=text,
190+
horizontal_alignment=alignment,
191+
weight=ct.FontWeight.BOLDER,
192+
wrap=True,
193+
color=ct.Colors.ACCENT,
194+
)
195+
],
196+
)
197+
)
198+
items.append(ColumnSet(columns=header_columns))
199+
200+
# Data rows
201+
for _idx, row in enumerate(self.rows):
202+
row_columns = []
203+
for col_idx, value in enumerate(row):
204+
col = self.header[col_idx]
205+
_, width = self._get_header_text_and_width(col)
206+
alignment = (
207+
col.horizontal_alignment.value
208+
if isinstance(col, ColumnHeader) and col.horizontal_alignment
209+
else None
210+
)
211+
row_columns.append(
212+
Column(
213+
width=width,
214+
items=[
215+
TextBlock(
216+
text=value,
217+
horizontal_alignment=alignment,
218+
wrap=True,
219+
color=ct.Colors.DEFAULT,
220+
)
221+
],
176222
)
177223
)
224+
items.append(
225+
ColumnSet(
226+
columns=row_columns,
227+
spacing=ct.Spacing.SMALL,
228+
)
229+
)
178230

179-
return column_set
231+
return Container(items=items)
180232

181233

182234
async def send_notification(
@@ -190,7 +242,7 @@ async def send_notification(
190242
logger.warning("MSTeams notifications are disabled.")
191243
return
192244

193-
card_items = [
245+
card_items: list[Any] = [
194246
TextBlock(
195247
text=title,
196248
size=ct.FontSize.LARGE,
@@ -205,19 +257,17 @@ async def send_notification(
205257
),
206258
]
207259
if details:
208-
card_items.append(details.to_column_set())
260+
card_items.append(details.to_container())
209261

262+
card_actions = []
210263
if open_url:
211-
card_items.append(
212-
ActionOpenUrl(
213-
title="Open",
214-
url=open_url,
215-
)
216-
)
264+
card_actions.append(ActionOpenUrl(title="Open", url=open_url))
217265

218-
version: str = "1.4"
219-
card: AdaptiveCard = AdaptiveCard.new().version(version).add_items(card_items).create()
220-
card.msteams = {"width": "Full"}
266+
card = (
267+
AdaptiveCard.new().version("1.4").add_items(card_items).add_actions(card_actions).create()
268+
)
269+
270+
card.msteams = MSTeams(width=MSTeamsCardWidth.FULL)
221271
message = {
222272
"type": "message",
223273
"attachments": [
@@ -227,6 +277,7 @@ async def send_notification(
227277
}
228278
],
229279
}
280+
230281
async with httpx.AsyncClient() as client:
231282
response = await client.post(
232283
settings.EXTENSION_CONFIG["MSTEAMS_NOTIFICATIONS_WEBHOOKS_URL"],

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ max-line-length = 100
144144
[tool.mypy]
145145
warn_no_return = false
146146

147+
[[tool.mypy.overrides]]
148+
module = "adaptive_cards.*"
149+
ignore_missing_imports = true
150+
151+
[[tool.mypy.overrides]]
152+
module = "mpt_extension_sdk.*"
153+
ignore_missing_imports = true
154+
155+
147156
[[tool.mypy.overrides]]
148157
module = "django.*"
149158
ignore_missing_imports = true

0 commit comments

Comments
 (0)