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
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% 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