diff --git a/communicator/api.yml b/communicator/api.yml index 5a13c7d..75230c4 100644 --- a/communicator/api.yml +++ b/communicator/api.yml @@ -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: diff --git a/communicator/api/admin.py b/communicator/api/admin.py index 69b58c7..e5f380a 100644 --- a/communicator/api/admin.py +++ b/communicator/api/admin.py @@ -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") + + + + + diff --git a/communicator/models/notification.py b/communicator/models/notification.py new file mode 100644 index 0000000..fccf32b --- /dev/null +++ b/communicator/models/notification.py @@ -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") diff --git a/communicator/models/sample.py b/communicator/models/sample.py index 15838c7..359fe2b 100644 --- a/communicator/models/sample.py +++ b/communicator/models/sample.py @@ -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: diff --git a/communicator/services/firebase_service.py b/communicator/services/firebase_service.py index dcb0ad9..1cbddfa 100644 --- a/communicator/services/firebase_service.py +++ b/communicator/services/firebase_service.py @@ -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 diff --git a/communicator/services/ivy_service.py b/communicator/services/ivy_service.py index 480b99c..bf5cb59 100644 --- a/communicator/services/ivy_service.py +++ b/communicator/services/ivy_service.py @@ -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 = [] diff --git a/communicator/services/notification_service.py b/communicator/services/notification_service.py new file mode 100644 index 0000000..9d82544 --- /dev/null +++ b/communicator/services/notification_service.py @@ -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) + diff --git a/communicator/templates/base_email.html b/communicator/templates/base_email.html new file mode 100644 index 0000000..188eba3 --- /dev/null +++ b/communicator/templates/base_email.html @@ -0,0 +1,144 @@ + + + + + + UVA BE SAFE + + + + + + + + + +
  +
+ + + + + + + + + + + +
+ + + + +
+ Autism DRIVE + {% block content %}{% endblock %} +
+
+ + + + + + +
+
 
+ + \ No newline at end of file diff --git a/config/default.py b/config/default.py index be35ec7..f0fb087 100644 --- a/config/default.py +++ b/config/default.py @@ -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" -} diff --git a/example_ivy_data/2020-09-16-1.csv b/example_ivy_data/2020-09-16-1.csv new file mode 100644 index 0000000..ed8796e --- /dev/null +++ b/example_ivy_data/2020-09-16-1.csv @@ -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 diff --git a/tests/base_test.py b/tests/base_test.py index 29e1726..cf0ead6 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -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 \ No newline at end of file diff --git a/tests/services/test_firebase_service.py b/tests/services/test_firebase_service.py index 2803e08..6223fcf 100644 --- a/tests/services/test_firebase_service.py +++ b/tests/services/test_firebase_service.py @@ -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())) diff --git a/tests/services/test_notification_service.py b/tests/services/test_notification_service.py new file mode 100644 index 0000000..c66763c --- /dev/null +++ b/tests/services/test_notification_service.py @@ -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'])) \ No newline at end of file