Adding a tool for sending out bulk email notifications and a simple web form for doing so.

This commit is contained in:
Dan Funk 2020-09-22 16:22:15 -04:00
parent ccb9cf1631
commit bdee709324
15 changed files with 348 additions and 65 deletions

View File

@ -21,6 +21,8 @@ flask-mail = "*"
flask-marshmallow = "*"
flask-migrate = "*"
flask-restful = "*"
flask-wtf = "*"
flask-table = "*"
marshmallow = "*"
marshmallow-enum = "*"
marshmallow-sqlalchemy = "*"

107
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "d21912b50ed403a1436f4eb9feed3facedea30b4c9e887620d7e8359e1cdba88"
"sha256": "cab43a07d915cc528153009278d8402c8350e6d2df0e1f629dc514d402c639d6"
},
"pipfile-spec": 6,
"requires": {
@ -38,6 +38,13 @@
],
"version": "==20.2.0"
},
"babel": {
"hashes": [
"sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
"sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
],
"version": "==2.8.0"
},
"bcrypt": {
"hashes": [
"sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29",
@ -193,30 +200,30 @@
},
"cryptography": {
"hashes": [
"sha256:10c9775a3f31610cf6b694d1fe598f2183441de81cedcf1814451ae53d71b13a",
"sha256:180c9f855a8ea280e72a5d61cf05681b230c2dce804c48e9b2983f491ecc44ed",
"sha256:247df238bc05c7d2e934a761243bfdc67db03f339948b1e2e80c75d41fc7cc36",
"sha256:26409a473cc6278e4c90f782cd5968ebad04d3911ed1c402fc86908c17633e08",
"sha256:2a27615c965173c4c88f2961cf18115c08fedfb8bdc121347f26e8458dc6d237",
"sha256:2e26223ac636ca216e855748e7d435a1bf846809ed12ed898179587d0cf74618",
"sha256:321761d55fb7cb256b771ee4ed78e69486a7336be9143b90c52be59d7657f50f",
"sha256:4005b38cd86fc51c955db40b0f0e52ff65340874495af72efabb1bb8ca881695",
"sha256:4b9e96543d0784acebb70991ebc2dbd99aa287f6217546bb993df22dd361d41c",
"sha256:548b0818e88792318dc137d8b1ec82a0ab0af96c7f0603a00bb94f896fbf5e10",
"sha256:725875681afe50b41aee7fdd629cedbc4720bab350142b12c55c0a4d17c7416c",
"sha256:7a63e97355f3cd77c94bd98c59cb85fe0efd76ea7ef904c9b0316b5bbfde6ed1",
"sha256:94191501e4b4009642be21dde2a78bd3c2701a81ee57d3d3d02f1d99f8b64a9e",
"sha256:969ae512a250f869c1738ca63be843488ff5cc031987d302c1f59c7dbe1b225f",
"sha256:9f734423eb9c2ea85000aa2476e0d7a58e021bc34f0a373ac52a5454cd52f791",
"sha256:b45ab1c6ece7c471f01c56f5d19818ca797c34541f0b2351635a5c9fe09ac2e0",
"sha256:cc6096c86ec0de26e2263c228fb25ee01c3ff1346d3cfc219d67d49f303585af",
"sha256:dc3f437ca6353979aace181f1b790f0fc79e446235b14306241633ab7d61b8f8",
"sha256:e7563eb7bc5c7e75a213281715155248cceba88b11cb4b22957ad45b85903761",
"sha256:e7dad66a9e5684a40f270bd4aee1906878193ae50a4831922e454a2a457f1716",
"sha256:eb80a288e3cfc08f679f95da72d2ef90cb74f6d8a8ba69d2f215c5e110b2ca32",
"sha256:fa7fbcc40e2210aca26c7ac8a39467eae444d90a2c346cbcffd9133a166bcc67"
"sha256:21b47c59fcb1c36f1113f3709d37935368e34815ea1d7073862e92f810dc7499",
"sha256:451cdf60be4dafb6a3b78802006a020e6cd709c22d240f94f7a0696240a17154",
"sha256:4549b137d8cbe3c2eadfa56c0c858b78acbeff956bd461e40000b2164d9167c6",
"sha256:48ee615a779ffa749d7d50c291761dc921d93d7cf203dca2db663b4f193f0e49",
"sha256:559d622aef2a2dff98a892eef321433ba5bc55b2485220a8ca289c1ecc2bd54f",
"sha256:5d52c72449bb02dd45a773a203196e6d4fae34e158769c896012401f33064396",
"sha256:65beb15e7f9c16e15934569d29fb4def74ea1469d8781f6b3507ab896d6d8719",
"sha256:680da076cad81cdf5ffcac50c477b6790be81768d30f9da9e01960c4b18a66db",
"sha256:762bc5a0df03c51ee3f09c621e1cee64e3a079a2b5020de82f1613873d79ee70",
"sha256:89aceb31cd5f9fc2449fe8cf3810797ca52b65f1489002d58fe190bfb265c536",
"sha256:983c0c3de4cb9fcba68fd3f45ed846eb86a2a8b8d8bc5bb18364c4d00b3c61fe",
"sha256:99d4984aabd4c7182050bca76176ce2dbc9fa9748afe583a7865c12954d714ba",
"sha256:9d9fc6a16357965d282dd4ab6531013935425d0dc4950df2e0cf2a1b1ac1017d",
"sha256:a7597ffc67987b37b12e09c029bd1dc43965f75d328076ae85721b84046e9ca7",
"sha256:ab010e461bb6b444eaf7f8c813bb716be2d78ab786103f9608ffd37a4bd7d490",
"sha256:b12e715c10a13ca1bd27fbceed9adc8c5ff640f8e1f7ea76416352de703523c8",
"sha256:b2bded09c578d19e08bd2c5bb8fed7f103e089752c9cf7ca7ca7de522326e921",
"sha256:b372026ebf32fe2523159f27d9f0e9f485092e43b00a5adacf732192a70ba118",
"sha256:cb179acdd4ae1e4a5a160d80b87841b3d0e0be84af46c7bb2cd7ece57a39c4ba",
"sha256:e97a3b627e3cb63c415a16245d6cef2139cca18bb1183d1b9375a1c14e83f3b3",
"sha256:f0e099fc4cc697450c3dd4031791559692dd941a95254cb9aeded66a7aa8b9bc",
"sha256:f99317a0fa2e49917689b8cf977510addcfaaab769b3f899b9c481bbd76730c2"
],
"version": "==3.1"
"version": "==3.1.1"
},
"docxtpl": {
"hashes": [
@ -241,6 +248,13 @@
"index": "pypi",
"version": "==1.5.6"
},
"flask-babel": {
"hashes": [
"sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468",
"sha256:f9faf45cdb2e1a32ea2ec14403587d4295108f35017a7821a2b1acb8cfd9257d"
],
"version": "==2.0.0"
},
"flask-bcrypt": {
"hashes": [
"sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f"
@ -294,6 +308,21 @@
],
"version": "==2.4.4"
},
"flask-table": {
"hashes": [
"sha256:320e5756cd7252e902e03b6cd1087f2c7ebc31364341b482f41f30074d10a770"
],
"index": "pypi",
"version": "==0.5.0"
},
"flask-wtf": {
"hashes": [
"sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2",
"sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720"
],
"index": "pypi",
"version": "==0.14.3"
},
"globus-sdk": {
"hashes": [
"sha256:883a862ddd17b0f4868ec55d6697a64c13d91c41b9fa5103198d2140053abac2",
@ -400,6 +429,14 @@
],
"version": "==2.10"
},
"importlib-metadata": {
"hashes": [
"sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83",
"sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"
],
"markers": "python_version < '3.8'",
"version": "==1.7.0"
},
"inflection": {
"hashes": [
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
@ -818,6 +855,13 @@
"sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c"
],
"version": "==2.3.3"
},
"zipp": {
"hashes": [
"sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6",
"sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"
],
"version": "==3.2.0"
}
},
"develop": {
@ -868,6 +912,14 @@
"index": "pypi",
"version": "==5.3"
},
"importlib-metadata": {
"hashes": [
"sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83",
"sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"
],
"markers": "python_version < '3.8'",
"version": "==1.7.0"
},
"iniconfig": {
"hashes": [
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
@ -939,6 +991,13 @@
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
],
"version": "==0.10.1"
},
"zipp": {
"hashes": [
"sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6",
"sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"
],
"version": "==3.2.0"
}
}
}

View File

@ -3,6 +3,7 @@ import os
import connexion
import sentry_sdk
from flask import render_template, request
from flask_cors import CORS
from flask_mail import Mail
from flask_marshmallow import Marshmallow
@ -10,7 +11,6 @@ from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sentry_sdk.integrations.flask import FlaskIntegration
logging.basicConfig(level=logging.INFO)
# API, fully defined in api.yml
@ -36,6 +36,8 @@ ma = Marshmallow(app)
from communicator import models
from communicator import api
from communicator import forms
connexion_app.add_api('api.yml', base_path='/v1.0')
# Convert list of allowed origins to list of regexes
@ -50,6 +52,38 @@ if app.config['SENTRY_ENVIRONMENT']:
integrations=[FlaskIntegration()]
)
### HTML Pages
BASE_HREF = app.config['APPLICATION_ROOT'].strip('/')
@app.route('/', methods=['GET', 'POST'])
def index():
# display results
return render_template(
'index.html',
base_href=BASE_HREF
)
@app.route('/invitation', methods=['GET', 'POST'])
def send_invitation():
form = forms.InvitationForm(request.form)
action = BASE_HREF + "/invitation"
title = "Send invitation to students"
if request.method == 'POST':
from communicator.services.notification_service import NotificationService
with NotificationService(app) as ns:
ns.send_invitations(form.date.data, form.location.data, form.emails.data)
return render_template(
'form.html',
form=form,
action=action,
title=title,
description_map={},
base_href=BASE_HREF
)
# Access tokens
@app.cli.command()
def globus_token():
@ -57,18 +91,21 @@ def globus_token():
ivy_service = IvyService()
ivy_service.get_access_token()
@app.cli.command()
def list_files():
from communicator.services.ivy_service import IvyService
ivy_service = IvyService()
ivy_service.list_files()
@app.cli.command()
def transfer():
from communicator.services.ivy_service import IvyService
ivy_service = IvyService()
ivy_service.request_transfer()
@app.cli.command()
def delete():
from communicator.services.ivy_service import IvyService

View File

@ -14,7 +14,7 @@ def status():
def update_data():
"""Updates the database based on local files placed by IVY and recoreds
"""Updates the database based on local files placed by IVY and records
read in from the firecloud database."""
fb_service = FirebaseService()
ivy_service = IvyService()
@ -22,6 +22,7 @@ def update_data():
samples = fb_service.get_samples()
samples.extend(ivy_service.load_directory())
SampleService().add_or_update_records(samples)
db.session.commit()
def notify_by_email():

10
communicator/forms.py Normal file
View File

@ -0,0 +1,10 @@
from flask_table import Table, Col, LinkCol, BoolCol, DatetimeCol, NestedTableCol
from flask_wtf import FlaskForm
from wtforms import SelectMultipleField, StringField, BooleanField, SelectField, validators, HiddenField, TextAreaField
from wtforms.widgets import TextArea
class InvitationForm(FlaskForm):
location = StringField('Location (ex. Newcomb Hall, South Meeting Room)', [validators.DataRequired()])
date = StringField('Date (ex. Monday, September 23 from 10:00 am-5:00 pm ', [validators.DataRequired()])
emails = TextAreaField('Emails (newline delimited)', render_kw={'rows': 50, 'cols': 50})

View File

@ -0,0 +1,8 @@
from sqlalchemy import func
from communicator import db
class IvyFile(db.Model):
file_name = db.Column(db.String, primary_key=True)
date_added = db.Column(db.DateTime(timezone=True), default=func.now())

View File

@ -14,6 +14,7 @@ class Sample(db.Model):
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)
ivy_file_name = db.Column(db.String)
notifications = db.relationship(Notification, back_populates="sample",
cascade="all, delete, delete-orphan",
order_by=Notification.date)

View File

@ -18,7 +18,6 @@ class FirebaseService(object):
self.db = firestore.Client(project="uva-covid19-testing-kiosk",
credentials= credentials)
def get_samples(self):
# Then query for documents
fb_samples = self.db.collection(u'samples')

View File

@ -3,8 +3,9 @@ import csv
import globus_sdk
from dateutil import parser
from communicator import app
from communicator import app, db
from communicator.errors import CommError
from communicator.models.ivy_file import IvyFile
from communicator.models.sample import Sample
from os import listdir
from os.path import isfile, join
@ -30,8 +31,10 @@ class IvyService(object):
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)))
for file_name in onlyfiles:
samples = IvyService.samples_from_ivy_file(join(self.path, file_name))
ivy_file = IvyFile(file_name=file_name)
db.session.add(ivy_file)
return samples
@staticmethod

View File

@ -5,21 +5,88 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import render_template
from flask_mail import Message
from twilio.rest import Client
from communicator import db, mail, app
from communicator.errors import ApiError, CommError
from communicator import app
from communicator.errors import CommError
TEST_MESSAGES = []
class NotificationService(object):
"""Provides common tools for working with an Email"""
"""Provides common tools for working with email and text messages, please use this
using the "with" syntax, to assure connections are properly closed.
ex:
with NotificationService() as notifier:
notifier.send_result_email(sample)
notifier.send_result_text(sample)
"""
def __init__(self, app):
self.app = app
self.sender = app.config['MAIL_SENDER']
def email_server(self):
def __enter__(self):
self.email_server = self._get_email_server()
self.twilio_client = self._get_twilio_client()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.email_server.close()
# No way to close the twilio client that I can see.
def get_link(self, sample):
return f"https://besafe.virginia.edu/result-demo?code={sample.result_code}"
def send_result_sms(self, sample):
link = self.get_link(sample)
message = self.twilio_client.messages.create(
to=sample.phone,
from_=self.app.config['TWILIO_NUMBER'],
body=f"You have an important notification from UVA Be Safe, please visit: {link}")
print(message.sid)
def send_result_email(self, sample):
link = self.get_link(sample)
subject = "UVA: BE SAFE Notification"
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_invitations(self, date, location, email_string):
emails = email_string.splitlines()
for email in emails:
subject = "UVA: BE SAFE - Appointment"
tracking_code = self._tracking_code()
text_body = render_template("invitation_email.txt",
date=date,
location=location,
tracking_code=tracking_code)
html_body = render_template("invitation_email.html",
date=date,
location=location,
tracking_code=tracking_code)
self._send_email(subject, recipients=[email], text_body=text_body, html_body=html_body)
def _tracking_code(self):
return str(uuid.uuid4())[:16]
def _get_email_server(self):
print("Server:" + self.app.config['MAIL_SERVER'])
server = smtplib.SMTP(host=self.app.config['MAIL_SERVER'],
port=self.app.config['MAIL_PORT'],
@ -32,30 +99,11 @@ class NotificationService(object):
self.app.config['MAIL_PASSWORD'])
return server
def tracking_code(self):
return str(uuid.uuid4())[:16]
def _get_twilio_client(self):
return Client(self.app.config['TWILIO_SID'],
self.app.config['TWILIO_TOKEN'])
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):
def _send_email(self, subject, recipients, text_body, html_body, sender=None, ical=None):
msgRoot = MIMEMultipart('related')
msgRoot.set_charset('utf8')
@ -78,9 +126,9 @@ class NotificationService(object):
# 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')
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']:
@ -89,11 +137,8 @@ class NotificationService(object):
return
try:
server = self.email_server()
server.sendmail(sender, recipients, msgRoot.as_bytes())
server.quit()
self.email_server.sendmail(sender, recipients, msgRoot.as_bytes())
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,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Be SAFE Notification System</title>
<base href="/">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://use.typekit.net/kwp6dli.css">
</head>
<style type="text/css">
input {
width: 500px;
margin-bottom: 20px;
}
</style>
<body>
<h2>{{ title }}</h2>
<p>{{ details|safe }}</p>
<form action="{{ action }}" method="post">
{{ form.csrf_token() }}
{% for field in form if field.name != "csrf_token" %}
<div class="form-field {{ field.widget.input_type }}">
<div class="form-field-label">{{ field.label() }}:</div>
<div class="form-field-input">{{ field }}</div>
<div class="form-field-help">{{ description_map[field.name] }}</div>
{% for error in field.errors %}
<div class="form-field-error">{{ error }}</div>
{% endfor %}
</div>
{% endfor %}
<button class="btn btn-primary" type="submit">Submit</button>
<a href="{{ url_for('index') }}" class="btn btn-default">Cancel</a>
</form>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>UVA Be Safe Communicator</title>
<base href="/">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://use.typekit.net/kwp6dli.css">
<link rel="shortcut icon" href="{{ base_href + url_for('static', filename='favicon.ico') }}">
</head>
<body>
<h2>UVA Be Safe Communicator</h2>
<p>Just making sure this works ok.</p>
</body>
</html>

View File

@ -0,0 +1,39 @@
{% extends "base_email.html" %}
{% block content %}
<p style="font-family: sans-serif; font-size: 24px; font-weight: bold; margin: 0; Margin-bottom: 15px;">
This is your invitation from the University of Virginia Be SAFE System.
</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">
To help keep our classes in-person, and to help keep our community safe, we are launching Be SAFE*
(Screening Asymptomatic Finding Effort). Be SAFE is a quick and easy coronavirus screening
program that will rely on saliva samples to detect the virus, with the goal of identifying silent
carriers before they have the opportunity to transmit the coronavirus. The saliva screenings are
mandatory and free of charge. The screenings will be done at multiple locations around Grounds and
only take about 2-3 minutes. The ultimate goal is to test all asymptomatic students who are coming
on Grounds periodically throughout the semester and to get them results within hours (rather than days).
</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">
Your next screening test is scheduled at:
</p>
<p style="font-family: sans-serif; font-size: 24px; font-weight: bold; margin: 0; Margin-bottom: 15px;">
{{location}}
</p>
<p style="font-family: sans-serif; font-size: 24px; font-weight: bold; margin: 0; Margin-bottom: 15px;">
{{date}}
</p>
<p style="font-family: sans-serif; font-size: 24px; font-weight: bold; margin: 0; Margin-bottom: 15px;">
You will need to bring your student ID card with you.
</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">
For more information please visit https://besafe.virginia.edu/
</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">
Questions? Email us at asksafe@virginia.edu
</p>
{% endblock %}

View File

@ -0,0 +1,22 @@
This is your invitation from the University of Virginia Be SAFE System.
-----------------------------------------------------------------------
To help keep our classes in-person, and to help keep our community safe, we are launching Be SAFE*
(Screening Asymptomatic Finding Effort). Be SAFE is a quick and easy coronavirus screening
program that will rely on saliva samples to detect the virus, with the goal of identifying silent
carriers before they have the opportunity to transmit the coronavirus. The saliva screenings are
mandatory and free of charge. The screenings will be done at multiple locations around Grounds and
only take about 2-3 minutes. The ultimate goal is to test all asymptomatic students who are coming
on Grounds periodically throughout the semester and to get them results within hours (rather than days).
==========================================
Your next screening test is scheduled at:
{{location}}
{{date}}
==========================================
You will need to bring your student ID card with you.
For more information please visit https://besafe.virginia.edu/
Questions? Email us at asksafe@virginia.edu

View File

@ -57,3 +57,8 @@ GLOBUS_TRANSFER_RT = environ.get('GLOBUS_TRANSFER_RT')
GLOBUS_TRANSFER_AT = environ.get('GLOBUS_TRANSFER_AT')
GLOBUS_IVY_ENDPOINT = environ.get('GLOBUS_IVY_ENDPOINT')
GLOBUS_DTN_ENDPOINT = environ.get('GLOBUS_DTN_ENDPOINT')
# Twilio SMS Messages
TWILIO_SID = environ.get('TWILIO_SID')
TWILIO_TOKEN = environ.get('TWILIO_TOKEN')