Basic email configuration

This commit is contained in:
Dan Funk 2020-09-17 11:16:41 -04:00
parent f2d809d9d9
commit 091450857c
13 changed files with 388 additions and 16 deletions

View File

@ -21,6 +21,30 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Status"
/update_data:
get:
operationId: communicator.api.admin.update_data
summary: Checks the local file system and firecloud for data and loads it into the db.
security: [] # Disable security for this endpoint only.
responses:
'200':
description: Status indicator that the app is up and alive.
content:
text/plain:
schema:
type: string
/notify_by_email:
get:
operationId: communicator.api.admin.notify_by_email
summary: when called, reviews all samples, and sends out any pending notifications.
security: [] # Disable security for this endpoint only.
responses:
'200':
description: Just returns a 200 if it was successful.
content:
text/plain:
schema:
type: string
components:
schemas:

View File

@ -1,3 +1,44 @@
from operator import or_
from communicator import db, app
from communicator.errors import ApiError, CommError
from communicator.models import Sample
from communicator.services.firebase_service import FirebaseService
from communicator.services.ivy_service import IvyService
from communicator.services.notification_service import NotificationService
from communicator.services.sample_service import SampleService
def status():
return {"status":"good"}
def update_data():
"""Updates the database based on local files placed by IVY and recoreds
read in from the firecloud database."""
fb_service = FirebaseService()
ivy_service = IvyService()
samples = fb_service.get_samples()
samples.extend(ivy_service.load_directory())
SampleService().add_or_update_records(samples)
def notify_by_email():
"""Sends out notifications via email"""
samples = db.session.query(Sample)\
.filter(Sample.result_code != None)\
.filter(Sample.email_notified == False).all()
notifier = NotificationService(app)
for sample in samples:
try:
notifier.send_result_email(sample)
sample.email_notified = True
except CommError as ce:
print("Error")

View File

@ -0,0 +1,11 @@
from communicator import db
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime)
type = db.Column(db.String) # Either 'email' or 'text'
successful = db.Column(db.Boolean)
error_message = db.Column(db.String)
sample_barcode = db.Column(db.String, db.ForeignKey('sample.barcode'), nullable=False)
sample = db.relationship("Sample")

View File

@ -1,4 +1,5 @@
from communicator import db
from communicator.models.notification import Notification
class Sample(db.Model):
@ -9,9 +10,13 @@ class Sample(db.Model):
phone = db.Column(db.String)
email = db.Column(db.String)
result_code = db.Column(db.String)
notified = db.Column(db.Boolean, default=False)
in_firebase = db.Column(db.Boolean, default=False) # Does this record exist in Firebase?
in_ivy = db.Column(db.Boolean, default=False) # Has this record come in from the IVY?
email_notified = db.Column(db.Boolean, default=False)
text_notified = db.Column(db.Boolean, default=False)
notifications = db.relationship(Notification, back_populates="sample",
cascade="all, delete, delete-orphan",
order_by=Notification.date)
def merge(self, sample):
if sample.phone:

View File

@ -16,7 +16,10 @@ class FirebaseService(object):
def get_samples(self):
# Then query for documents
samples = self.db.collection(u'samples')
fb_samples = self.db.collection(u'samples')
samples = []
for s in fb_samples.stream():
samples.append(FirebaseService.record_to_sample(s))
return samples
@staticmethod

View File

@ -2,13 +2,26 @@ import csv
from dateutil import parser
from communicator import app
from communicator.errors import CommError
from communicator.models.sample import Sample
from os import listdir
from os.path import isfile, join
class IvyService(object):
"""Opens files uploaded to the server from IVY and imports them into the database. """
def __init__(self):
self.path = app.config['IVY_IMPORT_DIR']
def load_directory(self):
onlyfiles = [f for f in listdir(self.path) if isfile(join(self.path, f))]
samples = []
for file in onlyfiles:
samples.extend(IvyService.samples_from_ivy_file(join(self.path, file)))
return samples
@staticmethod
def samples_from_ivy_file(file_name):
rows = []

View File

@ -0,0 +1,98 @@
import smtplib
import uuid
from email.header import Header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import render_template
from flask_mail import Message
from communicator import db, mail, app
from communicator.errors import ApiError, CommError
TEST_MESSAGES = []
class NotificationService(object):
"""Provides common tools for working with an Email"""
def __init__(self, app):
self.app = app
self.sender = app.config['MAIL_SENDER']
def email_server(self):
server = smtplib.SMTP(host=self.app.config['MAIL_SERVER'],
port=self.app.config['MAIL_PORT'],
timeout=self.app.config['MAIL_TIMEOUT'])
server.ehlo()
if self.app.config['MAIL_USE_TLS']:
server.starttls()
if self.app.config['MAIL_USERNAME']:
server.login(self.app.config['MAIL_USERNAME'],
self.app.config['MAIL_PASSWORD'])
return server
def tracking_code(self):
return str(uuid.uuid4())[:16]
def send_result_email(self, sample):
subject = "UVA: BE SAFE Notification"
link = f"https://besafe.virginia.edu/result-demo?code={sample.result_code}"
tracking_code = self.tracking_code()
text_body = render_template("result_email.txt",
link=link,
sample=sample,
tracking_code=tracking_code)
html_body = render_template("result_email.html",
link=link,
sample=sample,
tracking_code=tracking_code)
self.send_email(subject, recipients=[sample.email], text_body=text_body, html_body=html_body)
return tracking_code
def send_email(self, subject, recipients, text_body, html_body, sender=None, ical=None):
msgRoot = MIMEMultipart('related')
msgRoot.set_charset('utf8')
if sender is None:
sender = self.sender
msgRoot['Subject'] = Header(subject.encode('utf-8'), 'utf-8').encode()
msgRoot['From'] = sender
msgRoot['To'] = ', '.join(recipients)
msgRoot.preamble = 'This is a multi-part message in MIME format.'
msgAlternative = MIMEMultipart('alternative')
msgRoot.attach(msgAlternative)
part1 = MIMEText(text_body, 'plain', _charset='UTF-8')
part2 = MIMEText(html_body, 'html', _charset='UTF-8')
msgAlternative.attach(part1)
msgAlternative.attach(part2)
# Leaving this on here, just in case we need it later.
if ical:
ical_atch = MIMEText(ical.decode("utf-8"),'calendar')
ical_atch.add_header('Filename','event.ics')
ical_atch.add_header('Content-Disposition','attachment; filename=event.ics')
msgRoot.attach(ical_atch)
if 'TESTING' in self.app.config and self.app.config['TESTING']:
print("TEST: Recording Emails, not sending - %s - to:%s" % (subject, recipients))
TEST_MESSAGES.append(msgRoot)
return
try:
server = self.email_server()
server.sendmail(sender, recipients, msgRoot.as_bytes())
server.quit()
except Exception as e:
app.logger.error('An exception happened in EmailService', exc_info=True)
app.logger.error(str(e))
raise CommError(5000, f"failed to send email to {', '.join(recipients)}", e)

View File

@ -0,0 +1,144 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>UVA BE SAFE</title>
<style>
/* -------------------------------------
INLINED WITH htmlemail.io/inline
------------------------------------- */
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Autism DRIVE Portal: Resources for Translational Medicine in Virginia</span>
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
<img style="margin:0; padding:0 0 30px 0" src="{{logo_url}}" alt="Autism DRIVE"/>
{% block content %}{% endblock %}
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
<span class="apple-link" style="color: #999999; font-size: 12px; text-align: center;">
You received this email from UVA's BE SAFE system.
Please note that email is not a secure form of communication and should not be used to discuss any confidential matters, including personal health information, given its confidentiality cannot be assured.
</span>
</td>
</tr>
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
Powered by <a href=" {{ site_url }}" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">BE SAFE</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -38,8 +38,6 @@ GITHUB_REPO = environ.get('GITHUB_REPO', None)
TARGET_BRANCH = environ.get('TARGET_BRANCH', None)
# Email configuration
DEFAULT_SENDER = 'askresearch@virginia.edu'
FALLBACK_EMAILS = ['askresearch@virginia.edu', 'sartographysupport@googlegroups.com']
MAIL_DEBUG = environ.get('MAIL_DEBUG', default=True)
MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io')
MAIL_PORT = environ.get('MAIL_PORT', default=2525)
@ -47,14 +45,8 @@ MAIL_USE_SSL = environ.get('MAIL_USE_SSL', default=False)
MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default=False)
MAIL_USERNAME = environ.get('MAIL_USERNAME', default='')
MAIL_PASSWORD = environ.get('MAIL_PASSWORD', default='')
MAIL_SENDER = 'askresearch@virginia.edu'
# Ivy Directory
IVY_IMPORT_DIR = os.path.join(basedir, '..', 'example_ivy_data')
# Firebase connection
FIREBASE = {
"apiKey": "AIzaSyCZHvaAQJKGiU1McxqgbrH-_KPV92JofUA",
"authDomain": "uva-covid19-testing-kiosk.firebaseapp.com",
"databaseURL": "https://uva-covid19-testing-kiosk.firebaseio.com",
"storageBucket": "uva-covid19-testing-kiosk.appspot.com",
"projectId": "uva-covid19-testing-kiosk",
"messagingSenderId": "452622162774",
"appId": "1:452622162774:web:f2b513f3c1765fc9b954f7"
}

View File

@ -0,0 +1,7 @@
Student ID|Student Cellphone|Student Email|Test Date Time|Test Kiosk Loc|Test Result Code
987654321|555/555-5555|rkc7h@virginia.edu|202009030809|4321|8726520277
987654322|555/555-5556|testpositive@virginia.edu|202009060919|4321|8269722523
987655321|555/555-5558|testnegetive@virginia.edu|202009070719|4321|1142270225
000000111|555/555-5555|rkc7h@virginia.edu|202009091449|4321|8726520277
000000222|555/555-5556|testpositive@virginia.edu|202009091449|4321|8269722523
000000333|555/555-5558|testnegetive@virginia.edu|202009091449|4321|1142270225
1 Student ID Student Cellphone Student Email Test Date Time Test Kiosk Loc Test Result Code
2 987654321 555/555-5555 rkc7h@virginia.edu 202009030809 4321 8726520277
3 987654322 555/555-5556 testpositive@virginia.edu 202009060919 4321 8269722523
4 987655321 555/555-5558 testnegetive@virginia.edu 202009070719 4321 1142270225
5 000000111 555/555-5555 rkc7h@virginia.edu 202009091449 4321 8726520277
6 000000222 555/555-5556 testpositive@virginia.edu 202009091449 4321 8269722523
7 000000333 555/555-5558 testnegetive@virginia.edu 202009091449 4321 1142270225

View File

@ -1,6 +1,9 @@
# Set environment variable to testing before loading.
# IMPORTANT - Environment must be loaded before app, models, etc....
import base64
import os
import quopri
import re
import unittest
os.environ["TESTING"] = "true"
@ -47,3 +50,19 @@ class BaseTest(unittest.TestCase):
def tearDown(self):
db.session.query(Sample).delete()
db.session.commit()
def decode(self, encoded_words):
"""
Useful for checking the content of email messages
(which we store in an array for testing)
"""
encoded_word_regex = r'=\?{1}(.+)\?{1}([b|q])\?{1}(.+)\?{1}='
charset, encoding, encoded_text = re.match(encoded_word_regex,
encoded_words).groups()
if encoding == 'b':
byte_string = base64.b64decode(encoded_text)
elif encoding == 'q':
byte_string = quopri.decodestring(encoded_text)
text = byte_string.decode(charset)
text = text.replace("_", " ")
return text

View File

@ -8,6 +8,4 @@ class FirebaseServiceTest(BaseTest):
def test_get_samples(self):
service = FirebaseService()
samples = service.get_samples()
for sample in samples.stream():
print(u'{} => {}'.format(sample.id, sample.to_dict()))

View File

@ -0,0 +1,17 @@
from tests.base_test import BaseTest
from communicator import app
from communicator.models import Sample
from communicator.services.notification_service import TEST_MESSAGES, NotificationService
class TestNotificationService(BaseTest):
def test_send_notification(self):
message_count = len(TEST_MESSAGES)
notifier = NotificationService(app)
sample = Sample(email="dan@stauntonmakerspace.com", result_code="1234")
notifier.send_result_email(sample)
self.assertEqual(len(TEST_MESSAGES), message_count + 1)
self.assertEqual("UVA: BE SAFE Notification", self.decode(TEST_MESSAGES[-1]['subject']))