robo-email 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robo-email
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Reusable HTML email report rendering and local delivery utilities
|
|
5
|
+
Requires-Python: >=3.12,<3.13
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
+
Requires-Dist: matplotlib (==3.11.2)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "robo-email"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Reusable HTML email report rendering and local delivery utilities"
|
|
5
|
+
requires-python = ">=3.12,<3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"matplotlib==3.11.2",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[tool.poetry]
|
|
11
|
+
packages = [{ include = "robo_email" }]
|
|
12
|
+
exclude = ["tools/**"]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["poetry-core==2.5.0"]
|
|
16
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Generic local Windows email delivery helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable, Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
_email_sent = False
|
|
16
|
+
_email_sent_lock = threading.Lock()
|
|
17
|
+
_EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_valid_email(address: str) -> bool:
|
|
21
|
+
"""Return whether an address matches the permitted email format."""
|
|
22
|
+
return bool(_EMAIL_RE.match((address or "").strip()))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ps_escape(value: Optional[str]) -> str:
|
|
26
|
+
return (value or "").replace("'", "''")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def send_powershell_email(
|
|
30
|
+
*,
|
|
31
|
+
body_path: str | Path,
|
|
32
|
+
recipients: Iterable[str],
|
|
33
|
+
subject: str,
|
|
34
|
+
smtp_server: str = "localhost",
|
|
35
|
+
smtp_port: int = 25,
|
|
36
|
+
smtp_from: str = "",
|
|
37
|
+
attachment_path: str | Path | None = None,
|
|
38
|
+
suppress_duplicates: bool = True,
|
|
39
|
+
) -> bool:
|
|
40
|
+
"""Send an HTML email through PowerShell ``Send-MailMessage`` on Windows.
|
|
41
|
+
|
|
42
|
+
Application code supplies recipients, subject, SMTP configuration and any
|
|
43
|
+
attachment. This function has no knowledge of application environment,
|
|
44
|
+
credentials, spreadsheets, pytest, Jenkins, or business data.
|
|
45
|
+
"""
|
|
46
|
+
if os.name != "nt":
|
|
47
|
+
logger.warning("PowerShell email delivery is supported only on Windows.")
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
body_path = Path(body_path).resolve()
|
|
51
|
+
if not body_path.is_file():
|
|
52
|
+
raise FileNotFoundError(f"Email body does not exist: {body_path}")
|
|
53
|
+
|
|
54
|
+
valid_recipients = sorted(
|
|
55
|
+
{str(value).strip() for value in recipients if is_valid_email(str(value))}
|
|
56
|
+
)
|
|
57
|
+
if not valid_recipients:
|
|
58
|
+
logger.warning("Email send skipped because no valid recipients were supplied.")
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
global _email_sent
|
|
62
|
+
if suppress_duplicates:
|
|
63
|
+
with _email_sent_lock:
|
|
64
|
+
if _email_sent:
|
|
65
|
+
logger.info("Email already sent for this process; skipping duplicate send.")
|
|
66
|
+
return False
|
|
67
|
+
_email_sent = True
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
to_list = ";".join(valid_recipients)
|
|
71
|
+
safe_body_path = _ps_escape(str(body_path))
|
|
72
|
+
has_attachment = attachment_path is not None
|
|
73
|
+
safe_attachment_path = (
|
|
74
|
+
_ps_escape(str(Path(attachment_path).resolve())) if has_attachment else ""
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
ps_script = f"""
|
|
78
|
+
$ErrorActionPreference = 'Stop'
|
|
79
|
+
$bodyPath = '{safe_body_path}'
|
|
80
|
+
$attachmentPath = '{safe_attachment_path}'
|
|
81
|
+
$bodyHtml = Get-Content -Path $bodyPath -Raw -Encoding UTF8
|
|
82
|
+
$configuredFrom = '{_ps_escape(smtp_from)}'
|
|
83
|
+
$authUpn = (whoami /upn 2>$null | Select-Object -Last 1)
|
|
84
|
+
function Resolve-AdMailboxSmtp([string]$upn) {{
|
|
85
|
+
try {{
|
|
86
|
+
if ([string]::IsNullOrWhiteSpace($upn) -or -not $upn.Contains('@')) {{ return $null }}
|
|
87
|
+
$sam = $upn.Split('@')[0]
|
|
88
|
+
if ([string]::IsNullOrWhiteSpace($sam)) {{ return $null }}
|
|
89
|
+
$root = [ADSI]"LDAP://RootDSE"
|
|
90
|
+
$searchBase = "LDAP://" + $root.defaultNamingContext
|
|
91
|
+
$searcher = New-Object System.DirectoryServices.DirectorySearcher([ADSI]$searchBase)
|
|
92
|
+
$searcher.Filter = "(&(objectCategory=person)(objectClass=user)(sAMAccountName=$sam))"
|
|
93
|
+
$null = $searcher.PropertiesToLoad.Add("mail")
|
|
94
|
+
$null = $searcher.PropertiesToLoad.Add("proxyAddresses")
|
|
95
|
+
$searcher.SizeLimit = 1
|
|
96
|
+
$result = $searcher.FindOne()
|
|
97
|
+
if ($null -eq $result) {{ return $null }}
|
|
98
|
+
$props = $result.Properties
|
|
99
|
+
if ($props.Contains("proxyaddresses")) {{
|
|
100
|
+
foreach ($addr in $props["proxyaddresses"]) {{
|
|
101
|
+
if ($addr -is [string] -and $addr.StartsWith("SMTP:")) {{
|
|
102
|
+
return $addr.Substring(5).Trim().ToLowerInvariant()
|
|
103
|
+
}}
|
|
104
|
+
}}
|
|
105
|
+
}}
|
|
106
|
+
if ($props.Contains("mail") -and $props["mail"].Count -gt 0) {{
|
|
107
|
+
$mail = [string]$props["mail"][0]
|
|
108
|
+
if (-not [string]::IsNullOrWhiteSpace($mail) -and $mail.Contains('@')) {{
|
|
109
|
+
return $mail.Trim().ToLowerInvariant()
|
|
110
|
+
}}
|
|
111
|
+
}}
|
|
112
|
+
}} catch {{}}
|
|
113
|
+
return $null
|
|
114
|
+
}}
|
|
115
|
+
$resolvedMailboxFromAd = $null
|
|
116
|
+
$smtpFrom = $configuredFrom
|
|
117
|
+
if (-not [string]::IsNullOrWhiteSpace($authUpn)) {{
|
|
118
|
+
$authUpn = $authUpn.Trim().ToLowerInvariant()
|
|
119
|
+
if ($authUpn.Contains('@')) {{
|
|
120
|
+
$resolvedMailboxFromAd = Resolve-AdMailboxSmtp $authUpn
|
|
121
|
+
if (-not [string]::IsNullOrWhiteSpace($resolvedMailboxFromAd)) {{ $smtpFrom = $resolvedMailboxFromAd }}
|
|
122
|
+
else {{ $smtpFrom = $authUpn }}
|
|
123
|
+
}}
|
|
124
|
+
}}
|
|
125
|
+
$toRecipients = '{_ps_escape(to_list)}'.Split(';') | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }} | ForEach-Object {{ $_.Trim() }}
|
|
126
|
+
$attachmentExists = {'$true' if has_attachment else '$false'}
|
|
127
|
+
$fromCandidates = New-Object System.Collections.Generic.List[string]
|
|
128
|
+
function Add-FromCandidate([string]$value) {{
|
|
129
|
+
if ([string]::IsNullOrWhiteSpace($value)) {{ return }}
|
|
130
|
+
$candidate = $value.Trim().ToLowerInvariant()
|
|
131
|
+
if (-not $candidate.Contains('@')) {{ return }}
|
|
132
|
+
if (-not $fromCandidates.Contains($candidate)) {{ $fromCandidates.Add($candidate) }}
|
|
133
|
+
}}
|
|
134
|
+
Add-FromCandidate $smtpFrom
|
|
135
|
+
Add-FromCandidate $configuredFrom
|
|
136
|
+
Add-FromCandidate $resolvedMailboxFromAd
|
|
137
|
+
if ($toRecipients.Count -gt 0) {{ Add-FromCandidate $toRecipients[0] }}
|
|
138
|
+
$sendSucceeded = $false
|
|
139
|
+
$lastSendError = $null
|
|
140
|
+
foreach ($candidateFrom in $fromCandidates) {{
|
|
141
|
+
try {{
|
|
142
|
+
if ($attachmentExists) {{
|
|
143
|
+
Send-MailMessage -From $candidateFrom -To $toRecipients -Subject '{_ps_escape(subject)}' -Body $bodyHtml -BodyAsHtml -SmtpServer '{_ps_escape(smtp_server)}' -Port {int(smtp_port)} -Attachments $attachmentPath
|
|
144
|
+
}} else {{
|
|
145
|
+
Send-MailMessage -From $candidateFrom -To $toRecipients -Subject '{_ps_escape(subject)}' -Body $bodyHtml -BodyAsHtml -SmtpServer '{_ps_escape(smtp_server)}' -Port {int(smtp_port)}
|
|
146
|
+
}}
|
|
147
|
+
$sendSucceeded = $true
|
|
148
|
+
break
|
|
149
|
+
}} catch {{ $lastSendError = $_.Exception.Message }}
|
|
150
|
+
}}
|
|
151
|
+
if (-not $sendSucceeded) {{ throw "All sender candidates failed. Last error: $lastSendError" }}
|
|
152
|
+
"""
|
|
153
|
+
completed = subprocess.run(
|
|
154
|
+
[
|
|
155
|
+
"powershell",
|
|
156
|
+
"-NoProfile",
|
|
157
|
+
"-ExecutionPolicy",
|
|
158
|
+
"Bypass",
|
|
159
|
+
"-Command",
|
|
160
|
+
ps_script,
|
|
161
|
+
],
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
check=False,
|
|
165
|
+
)
|
|
166
|
+
if completed.returncode != 0:
|
|
167
|
+
logger.error(
|
|
168
|
+
"PowerShell email delivery failed (exit=%s): %s",
|
|
169
|
+
completed.returncode,
|
|
170
|
+
(completed.stderr or completed.stdout or "").strip(),
|
|
171
|
+
)
|
|
172
|
+
if suppress_duplicates:
|
|
173
|
+
with _email_sent_lock:
|
|
174
|
+
_email_sent = False
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
logger.info("Email sent successfully to %s", ", ".join(valid_recipients))
|
|
178
|
+
return True
|
|
179
|
+
except Exception:
|
|
180
|
+
if suppress_duplicates:
|
|
181
|
+
with _email_sent_lock:
|
|
182
|
+
_email_sent = False
|
|
183
|
+
raise
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Render Outlook-compatible HTML email reports with embedded charts.
|
|
2
|
+
|
|
3
|
+
The application owns its canonical report data and HTML template.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Use a non-interactive backend for matplotlib
|
|
14
|
+
try:
|
|
15
|
+
import matplotlib
|
|
16
|
+
|
|
17
|
+
matplotlib.use("Agg")
|
|
18
|
+
import matplotlib.pyplot as plt
|
|
19
|
+
from matplotlib import ticker
|
|
20
|
+
except Exception as exc:
|
|
21
|
+
logging.getLogger(__name__).warning(
|
|
22
|
+
"Matplotlib is unavailable for email charts: %s", exc
|
|
23
|
+
)
|
|
24
|
+
plt = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EmailReportGenerator:
|
|
31
|
+
"""
|
|
32
|
+
A utility class to generate an Outlook-compatible HTML email report from test data,
|
|
33
|
+
rendering charts as embedded Base64 images.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _create_chart_image(plot_function, data, title, **kwargs):
|
|
38
|
+
"""Render chart data as a Base64-encoded PNG data URI."""
|
|
39
|
+
if not plt:
|
|
40
|
+
return ""
|
|
41
|
+
try:
|
|
42
|
+
fig, ax = plt.subplots(figsize=(6, 4))
|
|
43
|
+
plot_function(fig, ax, data, title, **kwargs) # Pass fig to plot function
|
|
44
|
+
buf = BytesIO()
|
|
45
|
+
fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0.1)
|
|
46
|
+
plt.close(fig)
|
|
47
|
+
buf.seek(0)
|
|
48
|
+
image_base64 = base64.b64encode(buf.read()).decode("utf-8")
|
|
49
|
+
return f"data:image/png;base64,{image_base64}"
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.warning("Failed to render chart '%s': %s", title, exc)
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _plot_test_summary_donut(fig, ax, data, title, **kwargs):
|
|
56
|
+
"""Plots the overall test summary as a donut chart with color-coded center text."""
|
|
57
|
+
status_order = ["Passed", "Failed", "Skipped"]
|
|
58
|
+
sizes = []
|
|
59
|
+
for status in status_order:
|
|
60
|
+
value = data.get(status, 0)
|
|
61
|
+
try:
|
|
62
|
+
numeric_value = int(value)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
numeric_value = 0
|
|
65
|
+
sizes.append(max(numeric_value, 0))
|
|
66
|
+
|
|
67
|
+
# Matplotlib pie raises when all values are zero/invalid.
|
|
68
|
+
if sum(sizes) == 0:
|
|
69
|
+
ax.axis("off")
|
|
70
|
+
fig.text(0.5, 0.95, title, ha="center", va="top", fontsize=12)
|
|
71
|
+
ax.text(
|
|
72
|
+
0.5,
|
|
73
|
+
0.5,
|
|
74
|
+
"No test results available",
|
|
75
|
+
ha="center",
|
|
76
|
+
va="center",
|
|
77
|
+
fontsize=11,
|
|
78
|
+
color="#666666",
|
|
79
|
+
transform=ax.transAxes,
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
colors = {"Passed": "#66BB6A", "Failed": "#EF5350", "Skipped": "#FFA726"}
|
|
84
|
+
|
|
85
|
+
ax.pie(
|
|
86
|
+
sizes,
|
|
87
|
+
colors=[colors[status] for status in status_order],
|
|
88
|
+
startangle=90,
|
|
89
|
+
wedgeprops=dict(width=0.4),
|
|
90
|
+
)
|
|
91
|
+
centre_circle = plt.Circle((0, 0), 0.60, fc="white")
|
|
92
|
+
ax.add_artist(centre_circle)
|
|
93
|
+
|
|
94
|
+
y_pos = 0.2
|
|
95
|
+
ax.text(
|
|
96
|
+
0,
|
|
97
|
+
y_pos,
|
|
98
|
+
f"Passed: {data.get('Passed', 0)}",
|
|
99
|
+
ha="center",
|
|
100
|
+
va="center",
|
|
101
|
+
fontsize=10,
|
|
102
|
+
color=colors["Passed"],
|
|
103
|
+
weight="bold",
|
|
104
|
+
)
|
|
105
|
+
ax.text(
|
|
106
|
+
0,
|
|
107
|
+
0,
|
|
108
|
+
f"Failed: {data.get('Failed', 0)}",
|
|
109
|
+
ha="center",
|
|
110
|
+
va="center",
|
|
111
|
+
fontsize=10,
|
|
112
|
+
color=colors["Failed"],
|
|
113
|
+
weight="bold",
|
|
114
|
+
)
|
|
115
|
+
ax.text(
|
|
116
|
+
0,
|
|
117
|
+
-y_pos,
|
|
118
|
+
f"Skipped: {data.get('Skipped', 0)}",
|
|
119
|
+
ha="center",
|
|
120
|
+
va="center",
|
|
121
|
+
fontsize=10,
|
|
122
|
+
color=colors["Skipped"],
|
|
123
|
+
weight="bold",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
ax.axis("equal")
|
|
127
|
+
fig.text(0.5, 0.95, title, ha="center", va="top", fontsize=12)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _plot_stacked_bar(fig, ax, data, title, is_horizontal=True, **kwargs):
|
|
131
|
+
"""Plot passed, failed, and skipped totals as a stacked bar chart."""
|
|
132
|
+
labels = list(data.keys())
|
|
133
|
+
passed = [v.get("passed", 0) for v in data.values()]
|
|
134
|
+
failed = [v.get("failed", 0) for v in data.values()]
|
|
135
|
+
skipped = [v.get("skipped", 0) for v in data.values()]
|
|
136
|
+
|
|
137
|
+
bar_thickness = 0.5
|
|
138
|
+
colors = ["#66BB6A", "#EF5350", "#FFA726"]
|
|
139
|
+
|
|
140
|
+
if is_horizontal:
|
|
141
|
+
y_pos = range(len(labels))
|
|
142
|
+
p_bars = ax.barh(
|
|
143
|
+
y_pos, passed, height=bar_thickness, color=colors[0], label="Passed"
|
|
144
|
+
)
|
|
145
|
+
f_bars = ax.barh(
|
|
146
|
+
y_pos,
|
|
147
|
+
failed,
|
|
148
|
+
height=bar_thickness,
|
|
149
|
+
left=passed,
|
|
150
|
+
color=colors[1],
|
|
151
|
+
label="Failed",
|
|
152
|
+
)
|
|
153
|
+
s_bars = ax.barh(
|
|
154
|
+
y_pos,
|
|
155
|
+
skipped,
|
|
156
|
+
height=bar_thickness,
|
|
157
|
+
left=[p + f for p, f in zip(passed, failed)],
|
|
158
|
+
color=colors[2],
|
|
159
|
+
label="Skipped",
|
|
160
|
+
)
|
|
161
|
+
ax.set_yticks(y_pos)
|
|
162
|
+
ax.set_yticklabels(labels)
|
|
163
|
+
ax.invert_yaxis()
|
|
164
|
+
ax.set_xlabel("Number of Tests")
|
|
165
|
+
ax.xaxis.set_major_locator(ticker.MaxNLocator(integer=True))
|
|
166
|
+
else:
|
|
167
|
+
x_pos = range(len(labels))
|
|
168
|
+
p_bars = ax.bar(
|
|
169
|
+
x_pos, passed, width=bar_thickness, color=colors[0], label="Passed"
|
|
170
|
+
)
|
|
171
|
+
f_bars = ax.bar(
|
|
172
|
+
x_pos,
|
|
173
|
+
failed,
|
|
174
|
+
width=bar_thickness,
|
|
175
|
+
bottom=passed,
|
|
176
|
+
color=colors[1],
|
|
177
|
+
label="Failed",
|
|
178
|
+
)
|
|
179
|
+
s_bars = ax.bar(
|
|
180
|
+
x_pos,
|
|
181
|
+
skipped,
|
|
182
|
+
width=bar_thickness,
|
|
183
|
+
bottom=[p + f for p, f in zip(passed, failed)],
|
|
184
|
+
color=colors[2],
|
|
185
|
+
label="Skipped",
|
|
186
|
+
)
|
|
187
|
+
ax.set_xticks(x_pos)
|
|
188
|
+
ax.set_xticklabels(labels)
|
|
189
|
+
ax.set_ylabel("Number of Tests")
|
|
190
|
+
ax.yaxis.set_major_locator(ticker.MaxNLocator(integer=True))
|
|
191
|
+
|
|
192
|
+
fig.text(0.5, 0.95, title, ha="center", va="top", fontsize=12)
|
|
193
|
+
fig.legend(loc="upper center", bbox_to_anchor=(0.5, 0.9), ncol=3, frameon=False)
|
|
194
|
+
fig.subplots_adjust(top=0.8)
|
|
195
|
+
|
|
196
|
+
for i, (p, f, s) in enumerate(zip(passed, failed, skipped)):
|
|
197
|
+
if is_horizontal:
|
|
198
|
+
if p > 0:
|
|
199
|
+
ax.text(
|
|
200
|
+
p / 2,
|
|
201
|
+
i,
|
|
202
|
+
str(p),
|
|
203
|
+
ha="center",
|
|
204
|
+
va="center",
|
|
205
|
+
color="white",
|
|
206
|
+
fontsize=8,
|
|
207
|
+
)
|
|
208
|
+
if f > 0:
|
|
209
|
+
ax.text(
|
|
210
|
+
p + f / 2,
|
|
211
|
+
i,
|
|
212
|
+
str(f),
|
|
213
|
+
ha="center",
|
|
214
|
+
va="center",
|
|
215
|
+
color="white",
|
|
216
|
+
fontsize=8,
|
|
217
|
+
)
|
|
218
|
+
if s > 0:
|
|
219
|
+
ax.text(
|
|
220
|
+
p + f + s / 2,
|
|
221
|
+
i,
|
|
222
|
+
str(s),
|
|
223
|
+
ha="center",
|
|
224
|
+
va="center",
|
|
225
|
+
color="white",
|
|
226
|
+
fontsize=8,
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
if p > 0:
|
|
230
|
+
ax.text(
|
|
231
|
+
i,
|
|
232
|
+
p / 2,
|
|
233
|
+
str(p),
|
|
234
|
+
ha="center",
|
|
235
|
+
va="center",
|
|
236
|
+
color="white",
|
|
237
|
+
fontsize=8,
|
|
238
|
+
)
|
|
239
|
+
if f > 0:
|
|
240
|
+
ax.text(
|
|
241
|
+
i,
|
|
242
|
+
p + f / 2,
|
|
243
|
+
str(f),
|
|
244
|
+
ha="center",
|
|
245
|
+
va="center",
|
|
246
|
+
color="white",
|
|
247
|
+
fontsize=8,
|
|
248
|
+
)
|
|
249
|
+
if s > 0:
|
|
250
|
+
ax.text(
|
|
251
|
+
i,
|
|
252
|
+
p + f + s / 2,
|
|
253
|
+
str(s),
|
|
254
|
+
ha="center",
|
|
255
|
+
va="center",
|
|
256
|
+
color="white",
|
|
257
|
+
fontsize=8,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
DEFAULT_PLACEHOLDERS = {
|
|
262
|
+
"title": "__ROBO_EMAIL_TITLE__",
|
|
263
|
+
"summary_chart": "__ROBO_EMAIL_SUMMARY_CHART_CELL__",
|
|
264
|
+
"chart_1": "__ROBO_EMAIL_CHART_1_CELL__",
|
|
265
|
+
"chart_2": "__ROBO_EMAIL_CHART_2_CELL__",
|
|
266
|
+
"chart_3": "__ROBO_EMAIL_CHART_3_CELL__",
|
|
267
|
+
"report_link": "__ROBO_EMAIL_REPORT_LINK__",
|
|
268
|
+
"created_on": "__ROBO_EMAIL_CREATED_ON__",
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
DEFAULT_CHART_SPECS = (
|
|
272
|
+
("chart_1", "group_1_status", "Status by Group 1", True),
|
|
273
|
+
("chart_2", "group_2_status", "Status by Group 2", False),
|
|
274
|
+
("chart_3", "group_3_status", "Status by Group 3", True),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def _load_template(cls, template_path: Path, placeholders) -> str:
|
|
279
|
+
"""Load and validate the caller-supplied Outlook-compatible template."""
|
|
280
|
+
template_path = Path(template_path)
|
|
281
|
+
template = template_path.read_text(encoding="utf-8")
|
|
282
|
+
missing = [
|
|
283
|
+
placeholder
|
|
284
|
+
for placeholder in placeholders.values()
|
|
285
|
+
if placeholder not in template
|
|
286
|
+
]
|
|
287
|
+
if missing:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"Email template {template_path} is missing required "
|
|
290
|
+
f"placeholder(s): {', '.join(missing)}"
|
|
291
|
+
)
|
|
292
|
+
return template
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _chart_cell(img_src: str, alt_text: str) -> str:
|
|
296
|
+
"""Return an Outlook-safe chart cell or a plain fallback cell."""
|
|
297
|
+
safe_alt = html.escape(alt_text, quote=True)
|
|
298
|
+
if img_src:
|
|
299
|
+
safe_src = html.escape(img_src, quote=True)
|
|
300
|
+
return (
|
|
301
|
+
'<td align="center" style="padding: 10px; width: 50%;">'
|
|
302
|
+
f'<img src="{safe_src}" alt="{safe_alt}" '
|
|
303
|
+
'style="max-width: 380px; height: auto;" />'
|
|
304
|
+
"</td>"
|
|
305
|
+
)
|
|
306
|
+
return (
|
|
307
|
+
'<td align="center" style="padding: 10px; width: 50%;">'
|
|
308
|
+
f"{safe_alt} chart not available"
|
|
309
|
+
"</td>"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def generate_email_content(
|
|
314
|
+
cls,
|
|
315
|
+
data,
|
|
316
|
+
*,
|
|
317
|
+
template_path: Path,
|
|
318
|
+
placeholders=None,
|
|
319
|
+
chart_specs=None,
|
|
320
|
+
):
|
|
321
|
+
"""Render an Outlook-compatible email body from canonical report data.
|
|
322
|
+
|
|
323
|
+
Applications provide their own template and can map any three grouped
|
|
324
|
+
status charts into the reusable chart slots.
|
|
325
|
+
"""
|
|
326
|
+
placeholders = dict(placeholders or cls.DEFAULT_PLACEHOLDERS)
|
|
327
|
+
chart_specs = tuple(chart_specs or cls.DEFAULT_CHART_SPECS)
|
|
328
|
+
chart_data = data.get("chart_data") or {}
|
|
329
|
+
summary_data = chart_data.get(
|
|
330
|
+
"test_status_summary", {"Passed": 0, "Failed": 0, "Skipped": 0}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
summary_chart_img = cls._create_chart_image(
|
|
334
|
+
cls._plot_test_summary_donut, summary_data, "Test Summary"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
chart_cells = {}
|
|
338
|
+
for slot, data_key, title, is_horizontal in chart_specs:
|
|
339
|
+
image = cls._create_chart_image(
|
|
340
|
+
cls._plot_stacked_bar,
|
|
341
|
+
chart_data.get(data_key, {}),
|
|
342
|
+
title,
|
|
343
|
+
is_horizontal=is_horizontal,
|
|
344
|
+
)
|
|
345
|
+
chart_cells[slot] = cls._chart_cell(image, title)
|
|
346
|
+
|
|
347
|
+
title = html.escape(str(data.get("title", "Test Report")), quote=True)
|
|
348
|
+
report_link = html.escape(str(data.get("report_link", "#")), quote=True)
|
|
349
|
+
created_on = html.escape(
|
|
350
|
+
str(data.get("summary", {}).get("Report Created On", "N/A")),
|
|
351
|
+
quote=True,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
replacements = {
|
|
355
|
+
placeholders["title"]: title,
|
|
356
|
+
placeholders["summary_chart"]: cls._chart_cell(
|
|
357
|
+
summary_chart_img, "Test Summary"
|
|
358
|
+
),
|
|
359
|
+
placeholders["report_link"]: report_link,
|
|
360
|
+
placeholders["created_on"]: created_on,
|
|
361
|
+
}
|
|
362
|
+
for slot, _, _, _ in chart_specs:
|
|
363
|
+
replacements[placeholders[slot]] = chart_cells[slot]
|
|
364
|
+
|
|
365
|
+
rendered = cls._load_template(template_path, placeholders)
|
|
366
|
+
for placeholder, value in replacements.items():
|
|
367
|
+
rendered = rendered.replace(placeholder, value)
|
|
368
|
+
return rendered
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def generate_from_json(
|
|
372
|
+
cls,
|
|
373
|
+
json_path: Path,
|
|
374
|
+
*,
|
|
375
|
+
template_path: Path,
|
|
376
|
+
placeholders=None,
|
|
377
|
+
chart_specs=None,
|
|
378
|
+
) -> str:
|
|
379
|
+
"""Render the email body from a persisted canonical JSON report."""
|
|
380
|
+
data = json.loads(Path(json_path).read_text(encoding="utf-8"))
|
|
381
|
+
if not isinstance(data, dict):
|
|
382
|
+
raise ValueError(f"Report JSON must contain an object: {json_path}")
|
|
383
|
+
return cls.generate_email_content(
|
|
384
|
+
data,
|
|
385
|
+
template_path=template_path,
|
|
386
|
+
placeholders=placeholders,
|
|
387
|
+
chart_specs=chart_specs,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
@classmethod
|
|
391
|
+
def generate_and_save(
|
|
392
|
+
cls,
|
|
393
|
+
data,
|
|
394
|
+
*,
|
|
395
|
+
template_path: Path,
|
|
396
|
+
filename="email-body.html",
|
|
397
|
+
placeholders=None,
|
|
398
|
+
chart_specs=None,
|
|
399
|
+
):
|
|
400
|
+
"""Render and save an email body from canonical report data."""
|
|
401
|
+
content = cls.generate_email_content(
|
|
402
|
+
data,
|
|
403
|
+
template_path=template_path,
|
|
404
|
+
placeholders=placeholders,
|
|
405
|
+
chart_specs=chart_specs,
|
|
406
|
+
)
|
|
407
|
+
Path(filename).write_text(content, encoding="utf-8")
|
|
408
|
+
logger.info("Email HTML report successfully saved to: %s", filename)
|
|
409
|
+
return filename
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def generate_from_json_and_save(
|
|
413
|
+
cls,
|
|
414
|
+
json_path: Path,
|
|
415
|
+
*,
|
|
416
|
+
template_path: Path,
|
|
417
|
+
filename="email-body.html",
|
|
418
|
+
placeholders=None,
|
|
419
|
+
chart_specs=None,
|
|
420
|
+
):
|
|
421
|
+
"""Render and save an email body from persisted report JSON."""
|
|
422
|
+
content = cls.generate_from_json(
|
|
423
|
+
json_path,
|
|
424
|
+
template_path=template_path,
|
|
425
|
+
placeholders=placeholders,
|
|
426
|
+
chart_specs=chart_specs,
|
|
427
|
+
)
|
|
428
|
+
Path(filename).write_text(content, encoding="utf-8")
|
|
429
|
+
logger.info("Email HTML report rendered from %s and saved to %s", json_path, filename)
|
|
430
|
+
return filename
|