Skip to content

Commit 77c6740

Browse files
gfrnMattPrit
andauthored
[LIMS-1670] Display Diamond logo properly in emails (#5)
* Display Diamond logo properly in emails * Remove print statement Co-authored-by: Matthew Pritchard <46708056+MattPrit@users.noreply.github.com> --------- Co-authored-by: Matthew Pritchard <46708056+MattPrit@users.noreply.github.com>
1 parent f0b75bc commit 77c6740

File tree

8 files changed

+158
-125
lines changed

8 files changed

+158
-125
lines changed

MANIFEST.in

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
include src/pato/assets/DejaVuSerif.ttf
22
include src/pato/assets/DejaVuSerifB.ttf
3-
include src/pato/assets/logo.jpg
3+
include src/pato/assets/logo.jpg
4+
include src/pato/assets/logo-light.png

src/pato/assets/logo-light.png

25.1 KB
Loading

src/pato/assets/paths.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
ASSETS_PATH = os.path.dirname(__file__)
44

55
COMPANY_LOGO = os.path.join(ASSETS_PATH, "logo.jpg")
6+
COMPANY_LOGO_LIGHT = os.path.join(ASSETS_PATH, "logo-light.png")
67
DEJAVU_SERIF = os.path.join(ASSETS_PATH, "DejaVuSerif.ttf")
78
DEJAVU_SERIF_BOLD = os.path.join(ASSETS_PATH, "DejaVuSerifB.ttf")

src/pato/crud/alerts.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import json
2-
from email.mime.multipart import MIMEMultipart
3-
from email.mime.text import MIMEText
42
from smtplib import SMTP
53

64
from lims_utils.auth import GenericUser
7-
from lims_utils.tables import BLSession, DataCollectionGroup, Person, Proposal
5+
from lims_utils.tables import (
6+
BLSession,
7+
DataCollectionGroup,
8+
Person,
9+
Proposal,
10+
)
811
from sqlalchemy import select, update
912

1013
from ..models.alerts import (
@@ -15,6 +18,7 @@
1518
)
1619
from ..utils.config import Config
1720
from ..utils.database import db, unravel
21+
from ..utils.email import create_email
1822
from ..utils.generic import get_alerts_frontend_url, pascal_case_to_title
1923
from ..utils.pika import PikaPublisher
2024

@@ -54,10 +58,6 @@ def sign_up_for_alerts(
5458

5559
alert_rows = ""
5660

57-
msg = MIMEMultipart("alternative")
58-
msg["Subject"] = f"Pipeline alerts activated for {session_reference} - {group_id}"
59-
msg["From"] = Config.facility.contact_email
60-
6161
for value in ALERT_FIELDS:
6262
if (
6363
alert_params.get(value + "Min") is not None
@@ -76,15 +76,17 @@ def sign_up_for_alerts(
7676
pato_link=get_alerts_frontend_url(proposal_reference, session.visit_number),
7777
)
7878

79-
# TODO: make pure, plain no-HTML message body
80-
msg_p1 = MIMEText(message_body, "plain")
81-
msg_p2 = MIMEText(message_body, "html")
82-
83-
msg.attach(msg_p1)
84-
msg.attach(msg_p2)
79+
msg = create_email(
80+
message_body,
81+
f"Pipeline alerts activated for {session_reference} - {group_id}",
82+
proposal_reference,
83+
session.visit_number,
84+
)
8585

8686
with SMTP(Config.facility.smtp_server, Config.facility.smtp_port) as smtp:
8787
r_msg = msg
8888
r_msg["To"] = parameters.email
8989

90-
smtp.sendmail(Config.facility.contact_email, parameters.email, r_msg.as_string())
90+
smtp.sendmail(
91+
Config.facility.contact_email, parameters.email, r_msg.as_string()
92+
)

src/pato/models/alerts.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,20 @@
1010
<body>
1111
<div class="wrapper" style="padding: 0.5%; text-align:center; border-radius: 5px; border: 1px solid #efefef">
1212
<div class="header" style="background: #001d55; text-align: center; padding-top: 15px; padding-bottom: 15px; border-top-left-radius: 5px; border-top-right-radius: 5px;">
13-
<svg width="120" height="120" version="1.1" xmlns="http://www.w3.org/2000/svg">
14-
<g transform="matrix(.37271 0 0 .37271 -3.343e-7 .0014878)" stroke-width="2.6831">
15-
<path d="m0 160.98c0-88.908 72.073-160.98 160.98-160.98 88.911 0 160.99 72.073 160.99 160.98 0 88.907-72.075 160.98-160.99 160.98-88.908 0-160.98-72.073-160.98-160.98" fill="#fff"/>
16-
<path d="m222.71 254.64c-8.56 1.2229-11.317 7.8871-6.1307 14.802l23.701 31.598c23.669-13.431 43.611-32.657 57.855-55.791l-75.425 9.3905" fill="#facf07"/>
17-
<path d="m99.553 220.97c8.5615-1.2224 11.319-7.8813 6.1319-14.797l-61.997-82.665c-5.1911-6.916-3.7729-8.3307 3.1437-3.144l82.652 61.988c6.9161 5.192 13.575 2.4293 14.797-6.1267l14.613-150.7c1.2187-8.5613 3.2227-8.5613 4.4467 0l14.612 150.72c1.224 8.5573 7.8827 11.315 14.799 6.128l82.684-62.011c6.916-5.1867 8.3293-3.772 3.1427 3.144l-62.001 82.669c-5.1867 6.9161-2.4293 13.575 6.132 14.799l83.085 10.343c10.348-21.254 16.16-45.119 16.16-70.35 0-88.897-72.069-160.97-160.97-160.97-88.902 0-160.97 72.069-160.97 160.97 0 25.244 5.8208 49.123 16.173 70.381l83.369-10.38" fill="#facf07"/>
18-
<path d="m144.28 299.39c-1.2227-8.5563-7.8813-11.314-14.798-6.1307l-24.743 18.561c13.459 5.0198 27.784 8.2651 42.676 9.5104l-3.136-21.941" fill="#facf07"/>
19-
<path d="m177.95 299.37-3.1347 21.941c14.869-1.2682 29.163-4.5323 42.604-9.5682l-24.667-18.504c-6.9213-5.188-13.58-2.4255-14.803 6.1308" fill="#facf07"/>
20-
<path d="m99.553 254.65-75.744-9.4307c14.288 23.215 34.315 42.503 58.086 55.946l23.79-31.718c5.1875-6.9145 2.4296-13.573-6.1319-14.797" fill="#facf07"/>
21-
</g>
22-
</svg>
13+
<img src="cid:logo-light.png" height="45"/>
2314
</div>
2415
<div>
2516
"""
2617

27-
EMAIL_FOOTER = """
18+
EMAIL_FOOTER = Template(
19+
"""
2820
<p>To change your alert thresholds or disable alerts completely, <a href="$pato_link">update your alert preferences in PATo</a>.</p>
2921
<p style="border-top: 1px solid #001d55; background-color: #1040A1; padding: 10px; color: white;">© 2025, Diamond Light Source</p></div>
3022
"""
23+
)
3124

32-
ALERT_EMAIL_TEMPLATE = Template(EMAIL_HEADER + "$msg" + EMAIL_FOOTER)
3325
ALERT_REGISTRATION_TEMPLATE = Template(
34-
EMAIL_HEADER
35-
+ """<h1>Successfully registered alerts for $session!</h1>
26+
"""<h1>Successfully registered alerts for $session!</h1>
3627
<p>You will now receive alerts for <b>$session</b>, data collection group <b>#$dcg</b> if any values fall outside the following thresholds:</p>
3728
<table style="margin: 0 auto; text-align: center; padding: 30px;">
3829
<tr style="background-color: #1040A1; color: white;">
@@ -43,7 +34,6 @@
4334
$value_rows
4435
</table>
4536
"""
46-
+ EMAIL_FOOTER
4737
)
4838

4939
REGISTRATION_VALUE_ROW = Template(

src/pato/utils/email.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import json
2+
from email.mime.image import MIMEImage
3+
from email.mime.multipart import MIMEMultipart
4+
from email.mime.text import MIMEText
5+
from smtplib import SMTP
6+
7+
from lims_utils.database import get_session
8+
from lims_utils.logging import app_logger
9+
from lims_utils.tables import (
10+
BLSession,
11+
DataCollectionGroup,
12+
Person,
13+
Proposal,
14+
SessionHasPerson,
15+
)
16+
from pydantic import ValidationError
17+
from sqlalchemy import func, select
18+
19+
from ..assets.paths import COMPANY_LOGO_LIGHT
20+
from ..models.alerts import (
21+
EMAIL_FOOTER,
22+
EMAIL_HEADER,
23+
EmailNotification,
24+
)
25+
from ..utils.config import Config
26+
from ..utils.database import session_factory
27+
from ..utils.generic import get_alerts_frontend_url
28+
29+
30+
def create_email(
31+
msg_body: str, subject: str, proposal_reference: str, visit_number: int
32+
):
33+
"""Create email with standard header/footers.
34+
35+
Args:
36+
msg_body: Body of the message to send
37+
subject: Subject of the message
38+
proposal_reference: Proposal reference associated with the message (e.g.:cm12345)
39+
visit_number: Visit number associated with the message
40+
41+
Returns:
42+
Multipart message object
43+
"""
44+
msg = MIMEMultipart("alternative")
45+
msg["Subject"] = subject
46+
msg["From"] = Config.facility.contact_email
47+
48+
msg_p1 = MIMEText(msg_body, "plain")
49+
msg_p2 = MIMEText(
50+
EMAIL_HEADER
51+
+ msg_body
52+
+ EMAIL_FOOTER.safe_substitute(
53+
pato_link=get_alerts_frontend_url(proposal_reference, visit_number)
54+
),
55+
"html",
56+
)
57+
58+
with open(COMPANY_LOGO_LIGHT, "rb") as image_file:
59+
img = MIMEImage(image_file.read())
60+
61+
img.add_header("Content-ID", "<logo-light.png>")
62+
msg.attach(img)
63+
msg.attach(msg_p1)
64+
msg.attach(msg_p2)
65+
66+
return msg
67+
68+
69+
def email_consumer(_, _1, _2, body: bytes):
70+
"""Consume RabbitMQ message, and send out emails to all personnel in the data collection group."""
71+
app_logger.info("Received notification request: %s", body)
72+
try:
73+
json_body = json.loads(body)
74+
notification = EmailNotification.model_validate(json_body)
75+
except (json.decoder.JSONDecodeError, ValidationError):
76+
app_logger.error("Malformed email message provided: %s", body)
77+
return
78+
79+
with get_session(session_factory) as db_session:
80+
recipients = db_session.scalars(
81+
select(Person.emailAddress)
82+
.select_from(DataCollectionGroup)
83+
.join(BLSession)
84+
.join(SessionHasPerson)
85+
.join(Person)
86+
.filter(
87+
DataCollectionGroup.dataCollectionGroupId == notification.groupId,
88+
Person.emailAddress.is_not(None),
89+
)
90+
).all()
91+
92+
session = db_session.execute(
93+
select(
94+
func.concat(Proposal.proposalCode, Proposal.proposalNumber).label(
95+
"proposal"
96+
),
97+
BLSession.visit_number,
98+
)
99+
.select_from(DataCollectionGroup)
100+
.join(BLSession)
101+
.join(Proposal)
102+
.filter(DataCollectionGroup.dataCollectionGroupId == notification.groupId)
103+
).one()
104+
105+
if recipients is None or len(recipients) == 0:
106+
app_logger.error(
107+
"Data collection group %i has no associated users with valid emails",
108+
notification.groupId,
109+
)
110+
return
111+
112+
session_reference = f"{session.proposal}-{session.visit_number}"
113+
114+
msg = create_email(
115+
notification.message,
116+
f"Alert for session {session_reference}",
117+
session.proposal,
118+
session.visit_number,
119+
)
120+
121+
with SMTP(Config.facility.smtp_server, Config.facility.smtp_port) as smtp:
122+
for recipient in recipients:
123+
r_msg = msg
124+
r_msg["To"] = recipient
125+
126+
smtp.sendmail(Config.facility.contact_email, recipient, msg.as_string())

src/pato/utils/pika.py

Lines changed: 3 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,11 @@
1-
import json
21
import socket
3-
from email.mime.multipart import MIMEMultipart
4-
from email.mime.text import MIMEText
5-
from smtplib import SMTP
62
from typing import Any, Callable
73

84
import pika
9-
from lims_utils.database import get_session
10-
from lims_utils.logging import app_logger
11-
from lims_utils.tables import (
12-
BLSession,
13-
DataCollectionGroup,
14-
Person,
15-
Proposal,
16-
SessionHasPerson,
17-
)
185
from pika.channel import Channel
19-
from pydantic import ValidationError
20-
from sqlalchemy import func, select
216

22-
from ..models.alerts import ALERT_EMAIL_TEMPLATE, EmailNotification
23-
from ..utils.config import Config
24-
from ..utils.generic import get_alerts_frontend_url
25-
from .database import session_factory
7+
from .config import Config
8+
from .email import email_consumer
269

2710
_credentials = pika.PlainCredentials(Config.mq.user, Config.mq.password)
2811
_parameters = pika.ConnectionParameters(
@@ -71,79 +54,9 @@ def __enter__(self):
7154
def __exit__(self, exc_type, exc_value, traceback):
7255
self.conn.close()
7356

74-
75-
def _email_consumer(_, _1, _2, body: bytes):
76-
"""Consume RabbitMQ message, and send out emails to all personnel in the data collection group."""
77-
app_logger.info("Received notification request: %s", body)
78-
try:
79-
json_body = json.loads(body)
80-
notification = EmailNotification.model_validate(json_body)
81-
except (json.decoder.JSONDecodeError, ValidationError):
82-
app_logger.error("Malformed email message provided: %s", body)
83-
return
84-
85-
with get_session(session_factory) as db_session:
86-
recipients = db_session.scalars(
87-
select(Person.emailAddress)
88-
.select_from(DataCollectionGroup)
89-
.join(BLSession)
90-
.join(SessionHasPerson)
91-
.join(Person)
92-
.filter(
93-
DataCollectionGroup.dataCollectionGroupId == notification.groupId,
94-
Person.emailAddress.is_not(None),
95-
)
96-
).all()
97-
98-
session = db_session.execute(
99-
select(
100-
func.concat(Proposal.proposalCode, Proposal.proposalNumber).label(
101-
"proposal"
102-
),
103-
BLSession.visit_number,
104-
)
105-
.select_from(DataCollectionGroup)
106-
.join(BLSession)
107-
.join(Proposal)
108-
.filter(DataCollectionGroup.dataCollectionGroupId == notification.groupId)
109-
).one()
110-
111-
if recipients is None or len(recipients) == 0:
112-
app_logger.error(
113-
"Data collection group %i has no associated users with valid emails",
114-
notification.groupId,
115-
)
116-
return
117-
118-
session_reference = f"{session.proposal}-{session.visit_number}"
119-
120-
msg = MIMEMultipart("alternative")
121-
msg["Subject"] = f"Alert for session {session_reference}"
122-
msg["From"] = Config.facility.contact_email
123-
124-
msg_p1 = MIMEText(notification.message, "plain")
125-
msg_p2 = MIMEText(
126-
ALERT_EMAIL_TEMPLATE.safe_substitute(
127-
msg=notification.message,
128-
pato_link=get_alerts_frontend_url(session.proposal, session.visit_number),
129-
),
130-
"html",
131-
)
132-
133-
msg.attach(msg_p1)
134-
msg.attach(msg_p2)
135-
136-
with SMTP(Config.facility.smtp_server, Config.facility.smtp_port) as smtp:
137-
for recipient in recipients:
138-
r_msg = msg
139-
r_msg["To"] = recipient
140-
141-
smtp.sendmail(Config.facility.contact_email, recipient, msg.as_string())
142-
143-
14457
def start_email_consumer():
14558
with PikaConsumer() as consumer:
146-
consumer.consume(Config.mq.consumer_queue, _email_consumer)
59+
consumer.consume(Config.mq.consumer_queue, email_consumer)
14760

14861

14962
class PikaPublisher:
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
from unittest.mock import ANY, patch
22

33
from pato.utils.config import Config
4-
from pato.utils.pika import _email_consumer
4+
from pato.utils.email import email_consumer
55

66

77
def test_send_email(client):
88
"""Should send emails for all users in provided session"""
9-
with patch("pato.utils.pika.SMTP", autospec=True) as mock_smtp:
10-
_email_consumer(None, None, None, b'{"groupId": 988855, "message": "test"}')
9+
with patch("pato.utils.email.SMTP", autospec=True) as mock_smtp:
10+
email_consumer(None, None, None, b'{"groupId": 988855, "message": "test"}')
1111

1212
ctx = mock_smtp.return_value.__enter__.return_value
1313
ctx.sendmail.assert_called_with(Config.facility.contact_email, "boaty@diamond.ac.uk", ANY)
1414

1515

1616
def test_malformed(client, caplog):
1717
"""Should log error if malformed message is sent"""
18-
_email_consumer(None, None, None, b"not-valid")
18+
email_consumer(None, None, None, b"not-valid")
1919
assert "Malformed email message provided: b'not-valid'" in caplog.text
2020

2121

2222
def test_no_recipients(client, caplog):
2323
"""Should log error if no recipients are available"""
24-
_email_consumer(None, None, None, b'{"groupId": 5440741, "message": "test"}')
24+
email_consumer(None, None, None, b'{"groupId": 5440741, "message": "test"}')
2525
assert "Data collection group 5440741 has no associated users with valid emails" in caplog.text

0 commit comments

Comments
 (0)