Merge branch 'master' into feature-scheduler

This commit is contained in:
Dan Funk 2020-09-24 11:37:35 -04:00 committed by GitHub
commit 586e5c9cf3
36 changed files with 1369 additions and 125 deletions

View File

@ -21,7 +21,6 @@ install:
env: env:
global: global:
- TESTING=true - TESTING=true
- PB_ENABLED=false
- SQLALCHEMY_DATABASE_URI="postgresql://postgres:@localhost:5432/communicator_test" - SQLALCHEMY_DATABASE_URI="postgresql://postgres:@localhost:5432/communicator_test"
script: script:

View File

@ -1,23 +1,20 @@
FROM python:3.8-slim FROM python:3.8-slim
WORKDIR /app RUN apt-get update -q \
COPY Pipfile Pipfile.lock /app/ && apt-get install -y -q \
gcc \
RUN set -xe \ libssl-dev \
pip install pipenv \ curl \
&& apt-get update -q \ postgresql-client \
&& apt-get install -y -q \ gunicorn3
gcc python3-dev libssl-dev \ RUN useradd _gunicorn --no-create-home --user-group
curl postgresql-client git-core \
gunicorn3 postgresql-client
RUN set -xe \
pipenv install --dev \
&& apt-get remove -y gcc python3-dev libssl-dev \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \
&& useradd _gunicorn --no-create-home --user-group
COPY . /app/ COPY . /app/
WORKDIR /app WORKDIR /app
RUN chmod +x "/app/docker_run.sh" \
&& pip install pipenv \
&& pipenv install --dev --deploy --system --ignore-pipfile
ENTRYPOINT ["/app/docker_run.sh"]

View File

@ -21,18 +21,24 @@ flask-mail = "*"
flask-marshmallow = "*" flask-marshmallow = "*"
flask-migrate = "*" flask-migrate = "*"
flask-restful = "*" flask-restful = "*"
flask-wtf = "*"
flask-table = "*"
marshmallow = "*" marshmallow = "*"
marshmallow-enum = "*" marshmallow-enum = "*"
marshmallow-sqlalchemy = "*" marshmallow-sqlalchemy = "*"
sentry-sdk = {extras = ["flask"],version = "==0.14.4"} sentry-sdk = {extras = ["flask"],version = "==0.14.4"}
swagger-ui-bundle = "*" swagger-ui-bundle = "*"
spiffworkflow = {git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"}
webtest = "*" webtest = "*"
python-box = "*" python-box = "*"
psycopg2-binary = "*" psycopg2-binary = "*"
google-cloud-firestore = "*" google-cloud-firestore = "*"
globus-sdk = "*" globus-sdk = "*"
apscheduler = "*" apscheduler = "*"
gunicorn = "*"
twilio = "*"
flask-paginate = "*"
flask-assets = "*"
pyscss = "*"
[requires] [requires]
python_version = "3.8" python_version = "3.8"

153
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "11bbdfff553eecca38caef64677e8e9fb43705ea35b83f220c663cb8396d1cd6" "sha256": "490cbcef70664a97232f019f8c20c17cea8035fac4904aa2e997c2f288832b87"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -55,6 +55,13 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0" "version": "==20.2.0"
}, },
"babel": {
"hashes": [
"sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
"sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
],
"version": "==2.8.0"
},
"bcrypt": { "bcrypt": {
"hashes": [ "hashes": [
"sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29", "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29",
@ -236,30 +243,30 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:10c9775a3f31610cf6b694d1fe598f2183441de81cedcf1814451ae53d71b13a", "sha256:21b47c59fcb1c36f1113f3709d37935368e34815ea1d7073862e92f810dc7499",
"sha256:180c9f855a8ea280e72a5d61cf05681b230c2dce804c48e9b2983f491ecc44ed", "sha256:451cdf60be4dafb6a3b78802006a020e6cd709c22d240f94f7a0696240a17154",
"sha256:247df238bc05c7d2e934a761243bfdc67db03f339948b1e2e80c75d41fc7cc36", "sha256:4549b137d8cbe3c2eadfa56c0c858b78acbeff956bd461e40000b2164d9167c6",
"sha256:26409a473cc6278e4c90f782cd5968ebad04d3911ed1c402fc86908c17633e08", "sha256:48ee615a779ffa749d7d50c291761dc921d93d7cf203dca2db663b4f193f0e49",
"sha256:2a27615c965173c4c88f2961cf18115c08fedfb8bdc121347f26e8458dc6d237", "sha256:559d622aef2a2dff98a892eef321433ba5bc55b2485220a8ca289c1ecc2bd54f",
"sha256:2e26223ac636ca216e855748e7d435a1bf846809ed12ed898179587d0cf74618", "sha256:5d52c72449bb02dd45a773a203196e6d4fae34e158769c896012401f33064396",
"sha256:321761d55fb7cb256b771ee4ed78e69486a7336be9143b90c52be59d7657f50f", "sha256:65beb15e7f9c16e15934569d29fb4def74ea1469d8781f6b3507ab896d6d8719",
"sha256:4005b38cd86fc51c955db40b0f0e52ff65340874495af72efabb1bb8ca881695", "sha256:680da076cad81cdf5ffcac50c477b6790be81768d30f9da9e01960c4b18a66db",
"sha256:4b9e96543d0784acebb70991ebc2dbd99aa287f6217546bb993df22dd361d41c", "sha256:762bc5a0df03c51ee3f09c621e1cee64e3a079a2b5020de82f1613873d79ee70",
"sha256:548b0818e88792318dc137d8b1ec82a0ab0af96c7f0603a00bb94f896fbf5e10", "sha256:89aceb31cd5f9fc2449fe8cf3810797ca52b65f1489002d58fe190bfb265c536",
"sha256:725875681afe50b41aee7fdd629cedbc4720bab350142b12c55c0a4d17c7416c", "sha256:983c0c3de4cb9fcba68fd3f45ed846eb86a2a8b8d8bc5bb18364c4d00b3c61fe",
"sha256:7a63e97355f3cd77c94bd98c59cb85fe0efd76ea7ef904c9b0316b5bbfde6ed1", "sha256:99d4984aabd4c7182050bca76176ce2dbc9fa9748afe583a7865c12954d714ba",
"sha256:94191501e4b4009642be21dde2a78bd3c2701a81ee57d3d3d02f1d99f8b64a9e", "sha256:9d9fc6a16357965d282dd4ab6531013935425d0dc4950df2e0cf2a1b1ac1017d",
"sha256:969ae512a250f869c1738ca63be843488ff5cc031987d302c1f59c7dbe1b225f", "sha256:a7597ffc67987b37b12e09c029bd1dc43965f75d328076ae85721b84046e9ca7",
"sha256:9f734423eb9c2ea85000aa2476e0d7a58e021bc34f0a373ac52a5454cd52f791", "sha256:ab010e461bb6b444eaf7f8c813bb716be2d78ab786103f9608ffd37a4bd7d490",
"sha256:b45ab1c6ece7c471f01c56f5d19818ca797c34541f0b2351635a5c9fe09ac2e0", "sha256:b12e715c10a13ca1bd27fbceed9adc8c5ff640f8e1f7ea76416352de703523c8",
"sha256:cc6096c86ec0de26e2263c228fb25ee01c3ff1346d3cfc219d67d49f303585af", "sha256:b2bded09c578d19e08bd2c5bb8fed7f103e089752c9cf7ca7ca7de522326e921",
"sha256:dc3f437ca6353979aace181f1b790f0fc79e446235b14306241633ab7d61b8f8", "sha256:b372026ebf32fe2523159f27d9f0e9f485092e43b00a5adacf732192a70ba118",
"sha256:e7563eb7bc5c7e75a213281715155248cceba88b11cb4b22957ad45b85903761", "sha256:cb179acdd4ae1e4a5a160d80b87841b3d0e0be84af46c7bb2cd7ece57a39c4ba",
"sha256:e7dad66a9e5684a40f270bd4aee1906878193ae50a4831922e454a2a457f1716", "sha256:e97a3b627e3cb63c415a16245d6cef2139cca18bb1183d1b9375a1c14e83f3b3",
"sha256:eb80a288e3cfc08f679f95da72d2ef90cb74f6d8a8ba69d2f215c5e110b2ca32", "sha256:f0e099fc4cc697450c3dd4031791559692dd941a95254cb9aeded66a7aa8b9bc",
"sha256:fa7fbcc40e2210aca26c7ac8a39467eae444d90a2c346cbcffd9133a166bcc67" "sha256:f99317a0fa2e49917689b8cf977510addcfaaab769b3f899b9c481bbd76730c2"
], ],
"version": "==3.1" "version": "==3.1.1"
}, },
"docxtpl": { "docxtpl": {
"hashes": [ "hashes": [
@ -284,6 +291,21 @@
"index": "pypi", "index": "pypi",
"version": "==1.5.6" "version": "==1.5.6"
}, },
"flask-assets": {
"hashes": [
"sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2",
"sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b"
],
"index": "pypi",
"version": "==2.0"
},
"flask-babel": {
"hashes": [
"sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468",
"sha256:f9faf45cdb2e1a32ea2ec14403587d4295108f35017a7821a2b1acb8cfd9257d"
],
"version": "==2.0.0"
},
"flask-bcrypt": { "flask-bcrypt": {
"hashes": [ "hashes": [
"sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f" "sha256:d71c8585b2ee1c62024392ebdbc447438564e2c8c02b4e57b56a4cafd8d13c5f"
@ -322,6 +344,13 @@
"index": "pypi", "index": "pypi",
"version": "==2.5.3" "version": "==2.5.3"
}, },
"flask-paginate": {
"hashes": [
"sha256:4d5c746e4f7b639a9bb72fac0c2452d58e5297b88d6ecda3340153a59453d581"
],
"index": "pypi",
"version": "==0.7.0"
},
"flask-restful": { "flask-restful": {
"hashes": [ "hashes": [
"sha256:5ea9a5991abf2cb69b4aac19793faac6c032300505b325687d7c305ffaa76915", "sha256:5ea9a5991abf2cb69b4aac19793faac6c032300505b325687d7c305ffaa76915",
@ -344,6 +373,20 @@
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.18.2" "version": "==0.18.2"
"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": { "globus-sdk": {
"hashes": [ "hashes": [
@ -440,6 +483,14 @@
], ],
"version": "==1.32.0" "version": "==1.32.0"
}, },
"gunicorn": {
"hashes": [
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
"version": "==20.0.4"
},
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
@ -448,6 +499,14 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10" "version": "==2.10"
}, },
"importlib-metadata": {
"hashes": [
"sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da",
"sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"
],
"markers": "python_version < '3.8'",
"version": "==2.0.0"
},
"inflection": { "inflection": {
"hashes": [ "hashes": [
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
@ -725,6 +784,13 @@
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.5'",
"version": "==0.17.3" "version": "==0.17.3"
}, },
"pyscss": {
"hashes": [
"sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf"
],
"index": "pypi",
"version": "==1.3.7"
},
"python-box": { "python-box": {
"hashes": [ "hashes": [
"sha256:b7a6f3edd2f71e2475d93163b6465f637a2714b155acafef17408b06e55282b3", "sha256:b7a6f3edd2f71e2475d93163b6465f637a2714b155acafef17408b06e55282b3",
@ -829,10 +895,6 @@
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.5'",
"version": "==2.0.1" "version": "==2.0.1"
}, },
"spiffworkflow": {
"git": "https://github.com/sartography/SpiffWorkflow.git",
"ref": "cfd1b994ac3bcc7f1d6459b00ab7455b76b942bc"
},
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
"sha256:072766c3bd09294d716b2d114d46ffc5ccf8ea0b714a4e1c48253014b771c6bb", "sha256:072766c3bd09294d716b2d114d46ffc5ccf8ea0b714a4e1c48253014b771c6bb",
@ -885,6 +947,12 @@
"sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4" "sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4"
], ],
"version": "==2.1" "version": "==2.1"
"twilio": {
"hashes": [
"sha256:df1cf8f7e62fbe4d412e66204ee7f948cd31ff18173ff4690475834174eedaf0"
],
"index": "pypi",
"version": "==6.45.3"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
@ -910,6 +978,13 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==1.4.4" "version": "==1.4.4"
}, },
"webassets": {
"hashes": [
"sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd",
"sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"
],
"version": "==2.0"
},
"webob": { "webob": {
"hashes": [ "hashes": [
"sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b",
@ -940,6 +1015,13 @@
"sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c" "sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c"
], ],
"version": "==2.3.3" "version": "==2.3.3"
},
"zipp": {
"hashes": [
"sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6",
"sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"
],
"version": "==3.2.0"
} }
}, },
"develop": { "develop": {
@ -991,6 +1073,14 @@
"index": "pypi", "index": "pypi",
"version": "==5.3" "version": "==5.3"
}, },
"importlib-metadata": {
"hashes": [
"sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da",
"sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"
],
"markers": "python_version < '3.8'",
"version": "==2.0.0"
},
"iniconfig": { "iniconfig": {
"hashes": [ "hashes": [
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
@ -1068,6 +1158,13 @@
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
], ],
"version": "==0.10.1" "version": "==0.10.1"
},
"zipp": {
"hashes": [
"sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6",
"sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f"
],
"version": "==3.2.0"
} }
} }
} }

View File

@ -3,13 +3,16 @@ import os
import connexion import connexion
import sentry_sdk import sentry_sdk
from flask import render_template, request, redirect, url_for
from flask_assets import Environment
from flask_cors import CORS from flask_cors import CORS
from flask_mail import Mail from flask_mail import Mail
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_paginate import Pagination, get_page_parameter
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sentry_sdk.integrations.flask import FlaskIntegration from sentry_sdk.integrations.flask import FlaskIntegration
from webassets import Bundle
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -34,8 +37,31 @@ db = SQLAlchemy(app)
migrate = Migrate(app, db) migrate = Migrate(app, db)
ma = Marshmallow(app) ma = Marshmallow(app)
# Asset management
url_map = app.url_map
try:
for rule in url_map.iter_rules('static'):
url_map._rules.remove(rule)
except ValueError:
# no static view was created yet
pass
app.add_url_rule(
app.static_url_path + '/<path:filename>',
endpoint='static', view_func=app.send_static_file)
assets = Environment(app)
assets.init_app(app)
assets.url = app.static_url_path
scss = Bundle(
'scss/app.scss',
filters='pyscss',
output='app.css'
)
assets.register('app_scss', scss)
from communicator import models from communicator import models
from communicator import api from communicator import api
from communicator import forms
connexion_app.add_api('api.yml', base_path='/v1.0') connexion_app.add_api('api.yml', base_path='/v1.0')
# Convert list of allowed origins to list of regexes # Convert list of allowed origins to list of regexes
@ -50,6 +76,88 @@ if app.config['SENTRY_ENVIRONMENT']:
integrations=[FlaskIntegration()] integrations=[FlaskIntegration()]
) )
### HTML Pages
BASE_HREF = app.config['APPLICATION_ROOT'].strip('/')
@app.route('/', methods=['GET'])
def index():
from communicator.models import Sample
from communicator.tables import SampleTable
# display results
page = request.args.get(get_page_parameter(), type=int, default=1)
samples = db.session.query(Sample).order_by(Sample.date.desc())
pagination = Pagination(page=page, total=samples.count(), search=False, record_name='samples')
table = SampleTable(samples.paginate(page,10,error_out=False).items)
return render_template(
'index.html',
table=table,
pagination=pagination,
base_href=BASE_HREF
)
@app.route('/invitation', methods=['GET', 'POST'])
def send_invitation():
from communicator.models.invitation import Invitation
from communicator.tables import InvitationTable
form = forms.InvitationForm(request.form)
action = BASE_HREF + "/invitation"
title = "Send invitation to students"
if request.method == 'POST' and form.validate():
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 redirect(url_for('send_invitation'))
# display results
page = request.args.get(get_page_parameter(), type=int, default=1)
invites = db.session.query(Invitation).order_by(Invitation.date_sent.desc())
pagination = Pagination(page=page, total=invites.count(), search=False, record_name='samples')
table = InvitationTable(invites.paginate(page,10,error_out=False).items)
return render_template(
'form.html',
form=form,
table=table,
pagination=pagination,
action=action,
title=title,
description_map={},
base_href=BASE_HREF
)
@app.route('/imported_files', methods=['GET'])
def list_imported_files_from_ivy():
from communicator.models.ivy_file import IvyFile
from communicator.tables import IvyFileTable
# display results
page = request.args.get(get_page_parameter(), type=int, default=1)
files = db.session.query(IvyFile).order_by(IvyFile.date_added.desc())
pagination = Pagination(page=page, total=files.count(), search=False, record_name='samples')
table = IvyFileTable(files.paginate(page, 10, error_out=False).items)
return render_template(
'imported_files.html',
table=table,
pagination=pagination,
base_href=BASE_HREF
)
@app.route('/sso')
def sso():
response = ""
response += "<h1>Headers</h1>"
response += "<ul>"
for k, v in request.headers:
response += "<li><b>%s</b> %s</li>\n" % (k, v)
response += "<h1>Environment</h1>"
for k, v in request.environ:
response += "<li><b>%s</b> %s</li>\n" % (k, v)
return response
# Access tokens # Access tokens
@app.cli.command() @app.cli.command()
def globus_token(): def globus_token():
@ -57,18 +165,21 @@ def globus_token():
ivy_service = IvyService() ivy_service = IvyService()
ivy_service.get_access_token() ivy_service.get_access_token()
@app.cli.command() @app.cli.command()
def list_files(): def list_files():
from communicator.services.ivy_service import IvyService from communicator.services.ivy_service import IvyService
ivy_service = IvyService() ivy_service = IvyService()
ivy_service.list_files() ivy_service.list_files()
@app.cli.command() @app.cli.command()
def transfer(): def transfer():
from communicator.services.ivy_service import IvyService from communicator.services.ivy_service import IvyService
ivy_service = IvyService() ivy_service = IvyService()
ivy_service.request_transfer() ivy_service.request_transfer()
@app.cli.command() @app.cli.command()
def delete(): def delete():
from communicator.services.ivy_service import IvyService from communicator.services.ivy_service import IvyService

View File

@ -6,8 +6,6 @@ info:
name: MIT name: MIT
servers: servers:
- url: http://localhost:5000/v1.0 - url: http://localhost:5000/v1.0
security:
- jwt: ['secret']
paths: paths:
/status: /status:
get: get:
@ -45,10 +43,52 @@ paths:
text/plain: text/plain:
schema: schema:
type: string type: string
/sample:
post:
operationId: communicator.api.admin.add_sample
summary: Creates a new sample
tags:
- Samples
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Sample'
responses:
'200':
description: Sample created successfully
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Sample"
delete:
operationId: communicator.api.admin.clear_samples
summary: Removes the given samples completely.
tags:
- Studies
responses:
'204':
description: All Samples removed.
components: components:
schemas: schemas:
Status: Status:
properties: properties:
status: status:
type: string type: string
Sample:
properties:
barcode:
type: string
example: "000000111-202009091449-4321"
student_id:
type: string
example: "000000111"
date:
type: string
format: date_time
example: "2019-12-25T09:12:33.001Z"
location:
type: string
example: "0001"

View File

@ -12,16 +12,25 @@ from communicator.services.sample_service import SampleService
def status(): def status():
return {"status":"good"} return {"status":"good"}
def add_sample(body):
sample = Sample(barcode=body['barcode'],
student_id=body['student_id'],
date=body['date'],
location=body['location'])
db.session.add(sample)
db.session.commit()
def clear_samples():
db.session.query(Sample).delete()
db.session.commit()
def update_data(): 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. No longer attempts
read in from the firecloud database.""" to pull files from the Firebase service."""
fb_service = FirebaseService()
ivy_service = IvyService() ivy_service = IvyService()
samples = ivy_service.load_directory()
samples = fb_service.get_samples()
samples.extend(ivy_service.load_directory())
SampleService().add_or_update_records(samples) SampleService().add_or_update_records(samples)
db.session.commit()
def notify_by_email(): def notify_by_email():

20
communicator/forms.py Normal file
View File

@ -0,0 +1,20 @@
import re
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, \
ValidationError
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': 10, 'cols': 50})
def validate_emails(form, field):
all_emails = field.data.splitlines()
EMAIL_REGEX = re.compile('^[a-z0-9]+[._a-z0-9]+[@]\w+[.]\w{2,3}$')
for email in all_emails:
if not re.search(EMAIL_REGEX, email):
raise ValidationError(f'Invalid email \'{email}\', Emails must each be on a seperate line.')

View File

@ -0,0 +1,11 @@
from sqlalchemy import func
from communicator import db
class Invitation(db.Model):
id = db.Column(db.Integer, primary_key=True)
date_sent = db.Column(db.DateTime(timezone=True), default=func.now())
location = db.Column(db.String)
date = db.Column(db.String)
total_recipients = db.Column(db.Integer)

View File

@ -0,0 +1,9 @@
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())
sample_count = db.Column(db.Integer)

View File

@ -1,7 +1,5 @@
import json import json
import os
from google.auth.credentials import Credentials
from google.cloud import firestore from google.cloud import firestore
from google.oauth2 import service_account from google.oauth2 import service_account
@ -18,7 +16,6 @@ class FirebaseService(object):
self.db = firestore.Client(project="uva-covid19-testing-kiosk", self.db = firestore.Client(project="uva-covid19-testing-kiosk",
credentials= credentials) credentials= credentials)
def get_samples(self): def get_samples(self):
# Then query for documents # Then query for documents
fb_samples = self.db.collection(u'samples') fb_samples = self.db.collection(u'samples')

View File

@ -1,10 +1,12 @@
import csv import csv
from datetime import datetime
import globus_sdk import globus_sdk
from dateutil import parser from dateutil import parser
from communicator import app from communicator import app, db
from communicator.errors import CommError from communicator.errors import CommError
from communicator.models.ivy_file import IvyFile
from communicator.models.sample import Sample from communicator.models.sample import Sample
from os import listdir from os import listdir
from os.path import isfile, join from os.path import isfile, join
@ -22,16 +24,20 @@ class IvyService(object):
self.GLOBUS_IVY_ENDPOINT = app.config['GLOBUS_IVY_ENDPOINT'] self.GLOBUS_IVY_ENDPOINT = app.config['GLOBUS_IVY_ENDPOINT']
self.GLOBUS_DTN_ENDPOINT = app.config['GLOBUS_DTN_ENDPOINT'] self.GLOBUS_DTN_ENDPOINT = app.config['GLOBUS_DTN_ENDPOINT']
print("Client ID:" + self.GLOBUS_CLIENT_ID)
self.client = globus_sdk.NativeAppAuthClient(self.GLOBUS_CLIENT_ID)
self.client.oauth2_start_flow(refresh_tokens=True)
def load_directory(self): def load_directory(self):
onlyfiles = [f for f in listdir(self.path) if isfile(join(self.path, f))] onlyfiles = [f for f in listdir(self.path) if isfile(join(self.path, f))]
samples = [] samples = []
for file in onlyfiles: for file_name in onlyfiles:
samples.extend(IvyService.samples_from_ivy_file(join(self.path, file))) samples = IvyService.samples_from_ivy_file(join(self.path, file_name))
ivy_file = db.session.query(IvyFile).filter(IvyFile.file_name == file_name).first()
if not ivy_file:
ivy_file = IvyFile(file_name=file_name, sample_count=len(samples))
else:
ivy_file.date_added = datetime.now()
ivy_file.sample_count = len(samples)
db.session.add(ivy_file)
db.session.commit()
return samples return samples
@staticmethod @staticmethod
@ -102,6 +108,9 @@ class IvyService(object):
def get_transfer_client(self): def get_transfer_client(self):
self.client = globus_sdk.NativeAppAuthClient(self.GLOBUS_CLIENT_ID)
self.client.oauth2_start_flow(refresh_tokens=True)
authorizer = globus_sdk.RefreshTokenAuthorizer( authorizer = globus_sdk.RefreshTokenAuthorizer(
self.GLOBUS_TRANSFER_RT, self.client, access_token=self.GLOBUS_TRANSFER_AT, expires_at=self.EXPIRES_AT) self.GLOBUS_TRANSFER_RT, self.client, access_token=self.GLOBUS_TRANSFER_AT, expires_at=self.EXPIRES_AT)
tc = globus_sdk.TransferClient(authorizer=authorizer) tc = globus_sdk.TransferClient(authorizer=authorizer)

View File

@ -3,24 +3,105 @@ import uuid
from email.header import Header from email.header import Header
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
import re
from flask import render_template from flask import render_template
from flask_mail import Message from twilio.rest import Client
from communicator import db, mail, app from communicator import app, db
from communicator.errors import ApiError, CommError from communicator.errors import CommError
from communicator.models.invitation import Invitation
TEST_MESSAGES = [] TEST_MESSAGES = []
class NotificationService(object): 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): def __init__(self, app):
self.app = app self.app = app
self.sender = app.config['MAIL_SENDER'] self.sender = app.config['MAIL_SENDER']
self.BASE_HREF = app.config['APPLICATION_ROOT'].strip('/')
def __enter__(self):
if 'TESTING' in self.app.config and self.app.config['TESTING']:
return 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):
if 'TESTING' in self.app.config and self.app.config['TESTING']:
return
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,
base_url = self.BASE_HREF,
sample=sample,
tracking_code=tracking_code)
html_body = render_template("result_email.html",
link=link,
base_url = self.BASE_HREF,
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()
subject = "UVA: BE SAFE - Appointment"
tracking_code = self._tracking_code()
text_body = render_template("invitation_email.txt",
date=date,
location=location,
base_url = self.BASE_HREF,
tracking_code=tracking_code)
html_body = render_template("invitation_email.html",
date=date,
location=location,
base_url = self.BASE_HREF,
tracking_code=tracking_code)
self._send_email(subject, recipients=[self.sender], bcc=emails, text_body=text_body, html_body=html_body)
invitation_log = Invitation(location=location, date=date, total_recipients=len(emails))
db.session.add(invitation_log)
db.session.commit()
def _tracking_code(self):
return str(uuid.uuid4())[:16]
def _get_email_server(self):
def email_server(self):
print("Server:" + self.app.config['MAIL_SERVER'])
server = smtplib.SMTP(host=self.app.config['MAIL_SERVER'], server = smtplib.SMTP(host=self.app.config['MAIL_SERVER'],
port=self.app.config['MAIL_PORT'], port=self.app.config['MAIL_PORT'],
timeout=self.app.config['MAIL_TIMEOUT']) timeout=self.app.config['MAIL_TIMEOUT'])
@ -32,30 +113,11 @@ class NotificationService(object):
self.app.config['MAIL_PASSWORD']) self.app.config['MAIL_PASSWORD'])
return server return server
def tracking_code(self): def _get_twilio_client(self):
return str(uuid.uuid4())[:16] return Client(self.app.config['TWILIO_SID'],
self.app.config['TWILIO_TOKEN'])
def send_result_email(self, sample): def _send_email(self, subject, recipients, text_body, html_body, bcc=[], sender=None, ical=None):
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 = MIMEMultipart('related')
msgRoot.set_charset('utf8') msgRoot.set_charset('utf8')
@ -78,9 +140,9 @@ class NotificationService(object):
# Leaving this on here, just in case we need it later. # Leaving this on here, just in case we need it later.
if ical: if ical:
ical_atch = MIMEText(ical.decode("utf-8"),'calendar') ical_atch = MIMEText(ical.decode("utf-8"), 'calendar')
ical_atch.add_header('Filename','event.ics') ical_atch.add_header('Filename', 'event.ics')
ical_atch.add_header('Content-Disposition','attachment; filename=event.ics') ical_atch.add_header('Content-Disposition', 'attachment; filename=event.ics')
msgRoot.attach(ical_atch) msgRoot.attach(ical_atch)
if 'TESTING' in self.app.config and self.app.config['TESTING']: if 'TESTING' in self.app.config and self.app.config['TESTING']:
@ -88,12 +150,11 @@ class NotificationService(object):
TEST_MESSAGES.append(msgRoot) TEST_MESSAGES.append(msgRoot)
return return
all_recipients = recipients + bcc
try: try:
server = self.email_server() self.email_server.sendmail(sender, all_recipients, msgRoot.as_bytes())
server.sendmail(sender, recipients, msgRoot.as_bytes())
server.quit()
except Exception as e: except Exception as e:
app.logger.error('An exception happened in EmailService', exc_info=True) app.logger.error('An exception happened in EmailService', exc_info=True)
app.logger.error(str(e)) app.logger.error(str(e))
raise CommError(5000, f"failed to send email to {', '.join(recipients)}", e) raise CommError(5000, f"failed to send email to {', '.join(recipients)}", e)

201
communicator/static/app.css Normal file
View File

@ -0,0 +1,201 @@
.mat-icon {
font-family: 'Material Icons', sans-serif;
font-size: 24px; }
.text-center {
text-align: center; }
html, body {
padding: 1em;
margin: 0;
font-family: Arial, sans-serif;
font-size: 16px; }
table {
border: 1px solid #cacaca;
background-color: white;
width: 100%;
text-align: left;
border-collapse: collapse; }
table th, table td {
padding: 0.5em; }
table td, table.blueTable th {
border: 1px solid #cacaca; }
table tbody td {
font-size: 14px; }
table tr:nth-child(even) {
background: #ededed; }
table thead {
background-color: #495e9d; }
table thead th {
font-size: 16px;
font-weight: bold;
color: white;
border-left: 1px solid #cacaca; }
table thead th:first-child {
border-left: none; }
table tfoot {
font-size: 16px;
font-weight: bold;
color: white;
background-color: #cacaca; }
table tfoot td {
font-size: 16px; }
table tfoot .links {
text-align: right; }
table tfoot .links a {
display: inline-block;
background: #495e9d;
color: white;
padding: 2px 8px;
border-radius: 5px; }
.btn {
font-size: 16px;
padding: 0.5em 1em;
border-radius: 5px;
text-decoration: none;
color: white;
white-space: nowrap;
border: none; }
.btn:hover {
text-decoration: none; }
.btn.btn-icon {
font-family: 'Material Icons', sans-serif;
font-size: 24px;
border: none; }
.btn.btn-icon.btn-default {
color: #4e4e4e;
background-color: transparent;
border: none; }
.btn.btn-icon.btn-default:hover {
color: #373737;
background-color: transparent; }
.btn.btn-icon.btn-primary {
color: #232D4B;
background-color: transparent; }
.btn.btn-icon.btn-primary:hover {
color: #191f34;
background-color: transparent; }
.btn.btn-icon.btn-accent {
color: #E57200;
background-color: transparent; }
.btn.btn-icon.btn-accent:hover {
color: #a05000;
background-color: transparent; }
.btn.btn-icon.btn-warn {
color: #DF1E43;
background-color: transparent; }
.btn.btn-icon.btn-warn:hover {
color: #9c152f;
background-color: transparent; }
.btn.btn-default {
color: #373737;
background-color: white;
border: 1px solid #cacaca; }
.btn.btn-default:hover {
background-color: #ededed; }
.btn.btn-primary {
background-color: #232D4B; }
.btn.btn-primary:hover {
background-color: #191f34; }
.btn.btn-warn {
background-color: #DF1E43; }
.btn.btn-warn:hover {
background-color: #9c152f; }
.btn.btn-accent {
background-color: #E57200; }
.btn.btn-accent:hover {
background-color: #a05000; }
select.multi {
height: 600px; }
.form-field {
display: flex;
max-width: 100vw;
margin-bottom: 40px;
padding: 2em; }
.form-field.hidden {
display: none; }
.form-field:nth-child(even) {
background-color: #ededed; }
.form-field .form-field-label, .form-field .form-field-help,
.form-field .form-field-input {
width: 30%;
text-align: left;
margin-right: 24px; }
.form-field .form-field-label {
font-weight: bold;
text-align: right; }
.form-field .form-field-input input {
width: 100%;
text-align: left; }
.form-field .form-field-input input[type=checkbox] {
width: 24px; }
.form-field .form-field-help {
font-style: italic; }
.form-field .form-field-error {
color: #DF1E43; }
.alert {
padding: 20px;
background-color: #4e4e4e;
color: white;
margin-bottom: 15px;
opacity: 1;
border-radius: 5px;
transform: scale3d(1, 1, 1);
transition: all 0.5s ease-in-out; }
.alert.warn {
background-color: #DF1E43; }
.alert.success {
background-color: #64B343; }
.alert.info {
background-color: #cacaca;
color: black; }
.alert .btn-close {
margin-left: 15px;
color: white;
font-weight: bold;
float: right;
font-size: 22px;
line-height: 20px;
cursor: pointer;
transition: 0.3s; }
.alert .btn-close:hover {
color: black; }
.alert.fade-out {
opacity: 0; }
.alert.shrink {
transform: scale3d(0, 0, 0);
padding: 0;
margin: 0; }
.highlight {
font-weight: bolder;
font-style: italic; }
.pagination-page-info {
padding: 0.6em;
padding-left: 0;
width: 40em;
margin: 0.5em;
margin-left: 0;
font-size: 12px; }
.pagination-page-info b {
color: black;
background: #6aa6ed;
padding-left: 2px;
padding: 0.1em 0.25em;
font-size: 150%; }
.pagination ul li {
display: inline-block;
width: 30px;
height: 20px;
border: 1px solid black;
text-align: center; }
.pagination ul li.active {
background-color: #ededed; }

View File

@ -0,0 +1,334 @@
// COLOR PALETTE
// gray
$color-gray: #4e4e4e;
$color-gray-light-2: scale-color($color-gray, $lightness: +90%);
$color-gray-light-1: scale-color($color-gray, $lightness: +70%);
$color-gray-light: $color-gray-light-1;
$color-gray-dark: scale-color($color-gray, $lightness: -30%);
// primary (UVA "Jefferson Blue")
$color-primary: #232D4B;
$color-primary-light: scale-color($color-primary, $lightness: +30%);
$color-primary-dark: scale-color($color-primary, $lightness: -30%);
// accent (UVA "Rotunda Orange")
$color-accent: #E57200;
$color-accent-light: scale-color($color-accent, $lightness: +30%);
$color-accent-dark: scale-color($color-accent, $lightness: -30%);
// warn (UVA "Emergency Red")
$color-warn: #DF1E43;
$color-warn-light: scale-color($color-warn, $lightness: +30%);
$color-warn-dark: scale-color($color-warn, $lightness: -30%);
// success (Green)
$color-success: #64B343;
$color-success-light: scale-color($color-success, $lightness: +30%);
$color-success-dark: scale-color($color-success, $lightness: -30%);
$font-size-default: 16px;
$font-size-lg: 24px;
$font-size-md: 16px;
$font-size-sm: 14px;
@mixin mat-icon {
font-family: 'Material Icons', sans-serif;
font-size: $font-size-lg;
}
.mat-icon {
@include mat-icon;
}
.text-center {
text-align: center;
}
html, body {
padding: 1em;
margin: 0;
font-family: Arial, sans-serif;
font-size: $font-size-default;
}
table {
border: 1px solid $color-gray-light;
background-color: white;
width: 100%;
text-align: left;
border-collapse: collapse;
th, td {
padding: 0.5em;
}
td, &.blueTable th {
border: 1px solid $color-gray-light;
}
tbody td {
font-size: $font-size-sm;
}
tr:nth-child(even) {
background: $color-gray-light-2;
}
thead {
background-color: $color-primary-light;
th {
font-size: $font-size-default;
font-weight: bold;
color: white;
border-left: 1px solid $color-gray-light;
}
}
thead th:first-child {
border-left: none;
}
tfoot {
font-size: $font-size-default;
font-weight: bold;
color: white;
background-color: $color-gray-light;
td {
font-size: $font-size-default;
}
.links {
text-align: right;
a {
display: inline-block;
background: $color-primary-light;
color: white;
padding: 2px 8px;
border-radius: 5px;
}
}
}
}
.btn {
font-size: $font-size-default;
padding: 0.5em 1em;
border-radius: 5px;
text-decoration: none;
color: white;
white-space: nowrap;
border: none;
&:hover {
text-decoration: none;
}
&.btn-icon {
@include mat-icon;
border: none;
&.btn-default {
color: $color-gray;
background-color: transparent;
border: none;
&:hover {
color: $color-gray-dark;
background-color: transparent;
}
}
&.btn-primary {
color: $color-primary;
background-color: transparent;
&:hover {
color: $color-primary-dark;
background-color: transparent;
}
}
&.btn-accent {
color: $color-accent;
background-color: transparent;
&:hover {
color: $color-accent-dark;
background-color: transparent;
}
}
&.btn-warn {
color: $color-warn;
background-color: transparent;
&:hover {
color: $color-warn-dark;
background-color: transparent;
}
}
}
&.btn-default {
color: $color-gray-dark;
background-color: white;
border: 1px solid $color-gray-light;
&:hover {
background-color: $color-gray-light-2;
}
}
&.btn-primary {
background-color: $color-primary;
&:hover {
background-color: $color-primary-dark;
}
}
&.btn-warn {
background-color: $color-warn;
&:hover {
background-color: $color-warn-dark;
}
}
&.btn-accent {
background-color: $color-accent;
&:hover {
background-color: $color-accent-dark;
}
}
}
select.multi {
height: 600px;
}
.form-field {
display: flex;
max-width: 100vw;
margin-bottom: 40px;
padding: 2em;
&.hidden {
display: none;
}
&:nth-child(even) {
background-color: $color-gray-light-2;
}
.form-field-label,
.form-field-help,
.form-field-input {
width: 30%;
text-align: left;
margin-right: 24px;
}
.form-field-label {
font-weight: bold;
text-align: right;
}
.form-field-input input {
width: 100%;
text-align: left;
&[type=checkbox] {
width: 24px;
}
}
.form-field-help {
font-style: italic;
}
.form-field-error {
color: $color-warn;
}
}
.alert {
padding: 20px;
background-color: $color-gray;
color: white;
margin-bottom: 15px;
opacity: 1;
border-radius: 5px;
transform: scale3d(1, 1, 1);
transition: all 0.5s ease-in-out;
&.warn { background-color: $color-warn; }
&.success { background-color: $color-success; }
&.info { background-color: $color-gray-light; color: black; }
.btn-close {
margin-left: 15px;
color: white;
font-weight: bold;
float: right;
font-size: 22px;
line-height: 20px;
cursor: pointer;
transition: 0.3s;
&:hover {
color: black;
}
}
&.fade-out {
opacity: 0;
}
&.shrink {
transform: scale3d(0, 0, 0);
padding: 0;
margin: 0;
}
}
.highlight {
font-weight: bolder;
font-style: italic;
}
.pagination-page-info {
padding: .6em;
padding-left: 0;
width: 40em;
margin: .5em;
margin-left: 0;
font-size: 12px;
}
.pagination-page-info b {
color: black;
background: #6aa6ed;
padding-left: 2px;
padding: .1em .25em;
font-size: 150%;
}
.pagination {
ul {
li {
display: inline-block;
width: 30px;
height: 20px;
border: 1px solid black;
text-align: center;
}
li.active {
background-color: $color-gray-light-2;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

29
communicator/tables.py Normal file
View File

@ -0,0 +1,29 @@
from flask_table import Table, Col, DatetimeCol, BoolCol
class SampleTable(Table):
def sort_url(self, col_id, reverse=False):
pass
barcode = Col('Barcode')
student_id = Col('Student Id')
date = DatetimeCol('Date', "medium")
location = Col('Location')
email_notified = BoolCol('Emailed?')
text_notified = BoolCol('Texted?')
class IvyFileTable(Table):
def sort_url(self, col_id, reverse=False):
pass
file_name = Col('File Name')
date_added = DatetimeCol('Date', "medium")
sample_count = Col('Total Records')
class InvitationTable(Table):
def sort_url(self, col_id, reverse=False):
pass
date_sent = DatetimeCol('Date Sent', "medium")
location = Col('Location')
date = Col('Date')
total_recipients = Col('# Recipients')

View File

@ -103,7 +103,7 @@
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"> <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr> <tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color:#232d4b; padding: 12px"> <td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color:#232d4b; padding: 12px">
<img src="https://besafe.virginia.edu/themes/custom/de_theme/logo.svg"> <img src="{{base_url + '/images/uva_logo.png'}}" alt="University of Virginia"/>
</td> </td>
</tr> </tr>
@ -132,7 +132,7 @@
</tr> </tr>
<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;"> <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>. Powered by <div style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">BE SAFE</div>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -0,0 +1,52 @@
<!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">
<link rel="shortcut icon" href="{{ base_href + url_for('static', filename='favicon.ico') }}">
{% assets 'app_scss' %}
<link href="{{ base_href + ASSET_URL }}" rel="stylesheet" type="text/css">
{% endassets %}
<link rel="shortcut icon" href="{{ base_href + url_for('static', filename='favicon.ico') }}">
</head>
<style type="text/css">
input {
width: 500px;
margin-bottom: 20px;
}
</style>
<body>
<a href="{{base_href}}"><< Home</a>
<h3>Previous Entries</h3>
{{ pagination.info }}
{{ pagination.links }}
{{ table }}
{{ pagination.links }}
<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,25 @@
<!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') }}">
{% assets 'app_scss' %}
<link href="{{ base_href + ASSET_URL }}" rel="stylesheet" type="text/css">
{% endassets %}
<link rel="shortcut icon" href="{{ base_href + url_for('static', filename='favicon.ico') }}">
</head>
<body>
<a href="{{base_href}}"><< Home</a>
<h2>UVA Be Safe Communicator</h2>
<h3>The following files were imported from IVY</h3>
{{ pagination.info }}
{{ pagination.links }}
{{ table }}
{{ pagination.links }}
</body>
</html>

View File

@ -0,0 +1,24 @@
<!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') }}">
{% assets 'app_scss' %}
<link href="{{ base_href + ASSET_URL }}" rel="stylesheet" type="text/css">
{% endassets %}
<link rel="shortcut icon" href="{{ base_href + url_for('static', filename='favicon.ico') }}">
</head>
<body>
<h2>UVA Be Safe Communicator</h2>
<a href="{{base_href + '/invitation'}}">Send Invitations</a> | <a href="{{base_href + '/imported_files'}}">View imported files</a>
<h3>Records to be processed</h3>
{{ pagination.info }}
{{ pagination.links }}
{{ table }}
{{ pagination.links }}
</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

@ -39,17 +39,18 @@ GITHUB_REPO = environ.get('GITHUB_REPO', None)
TARGET_BRANCH = environ.get('TARGET_BRANCH', None) TARGET_BRANCH = environ.get('TARGET_BRANCH', None)
# Email configuration # Email configuration
MAIL_DEBUG = environ.get('MAIL_DEBUG', default=True) MAIL_DEBUG = environ.get('MAIL_DEBUG', default="false") == "true"
MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io') MAIL_SERVER = environ.get('MAIL_SERVER', default='smtp.mailtrap.io')
MAIL_PORT = environ.get('MAIL_PORT', default=2525) MAIL_PORT = environ.get('MAIL_PORT', default=2525)
MAIL_USE_SSL = environ.get('MAIL_USE_SSL', default=False) MAIL_USE_SSL = environ.get('MAIL_USE_SSL', default="false") == "true"
MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default=False) MAIL_USE_TLS = environ.get('MAIL_USE_TLS', default="false") == "true"
MAIL_USERNAME = environ.get('MAIL_USERNAME', default='xxx') MAIL_USERNAME = environ.get('MAIL_USERNAME', default='')
MAIL_PASSWORD = environ.get('MAIL_PASSWORD', default='yyy') MAIL_PASSWORD = environ.get('MAIL_PASSWORD', default='')
MAIL_SENDER = 'askresearch@virginia.edu' MAIL_SENDER = 'askresearch@virginia.edu'
MAIL_TIMEOUT = 10
# Ivy Directory # Ivy Directory
IVY_IMPORT_DIR = os.path.join(basedir, '..', 'example_ivy_data') IVY_IMPORT_DIR = environ.get('IVY_IMPORT_DIR', default='')
# Globus endpoint connections # Globus endpoint connections
GLOBUS_CLIENT_ID = environ.get('GLOBUS_CLIENT_ID') GLOBUS_CLIENT_ID = environ.get('GLOBUS_CLIENT_ID')
@ -57,3 +58,12 @@ GLOBUS_TRANSFER_RT = environ.get('GLOBUS_TRANSFER_RT')
GLOBUS_TRANSFER_AT = environ.get('GLOBUS_TRANSFER_AT') GLOBUS_TRANSFER_AT = environ.get('GLOBUS_TRANSFER_AT')
GLOBUS_IVY_ENDPOINT = environ.get('GLOBUS_IVY_ENDPOINT') GLOBUS_IVY_ENDPOINT = environ.get('GLOBUS_IVY_ENDPOINT')
GLOBUS_DTN_ENDPOINT = environ.get('GLOBUS_DTN_ENDPOINT') GLOBUS_DTN_ENDPOINT = environ.get('GLOBUS_DTN_ENDPOINT')
# Twilio SMS Messages
TWILIO_SID = environ.get('TWILIO_SID')
TWILIO_TOKEN = environ.get('TWILIO_TOKEN')
TWILIO_NUMBER = environ.get('TWILIO_NUMBER')
# Firestore configuration
FIRESTORE_JSON = environ.get('FIRESTORE_JSON')

View File

@ -19,15 +19,8 @@ function branch_to_tag () {
if [ "$1" == "master" ]; then echo "latest"; else echo "$1" ; fi if [ "$1" == "master" ]; then echo "latest"; else echo "$1" ; fi
} }
function branch_to_deploy_group() {
echo "covid";
# if [[ $1 =~ ^(rrt\/.*)$ ]]; then echo "rrt"; else echo "crconnect" ; fi
}
DOCKER_TAG=$(branch_to_tag "$TRAVIS_BRANCH") DOCKER_TAG=$(branch_to_tag "$TRAVIS_BRANCH")
DEPLOY_GROUP=$(branch_to_deploy_group "$TRAVIS_BRANCH")
echo "DOCKER_REPO = $DOCKER_REPO" echo "DOCKER_REPO = $DOCKER_REPO"
echo "DOCKER_TAG = $DOCKER_TAG" echo "DOCKER_TAG = $DOCKER_TAG"

View File

@ -19,6 +19,12 @@ if [ "$RESET_DB" = "true" ]; then
fi fi
# THIS MUST BE THE LAST COMMAND! # THIS MUST BE THE LAST COMMAND!
if [ -z "$PORT0" ]
then
echo "$PORT0 is not set, setting to 5000"
PORT0=5000
fi
if [ "$APPLICATION_ROOT" = "/" ]; then if [ "$APPLICATION_ROOT" = "/" ]; then
pipenv run gunicorn --bind 0.0.0.0:$PORT0 wsgi:app pipenv run gunicorn --bind 0.0.0.0:$PORT0 wsgi:app
else else

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: 3525dcf128cb
Revises: 39a845579587
Create Date: 2020-09-23 16:23:09.383030
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3525dcf128cb'
down_revision = '39a845579587'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('invitation',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_sent', sa.DateTime(timezone=True), nullable=True),
sa.Column('location', sa.String(), nullable=True),
sa.Column('date', sa.String(), nullable=True),
sa.Column('total_recipients', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('invitation')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""empty message
Revision ID: 39a845579587
Revises: fa4b41e0bfe6
Create Date: 2020-09-23 15:50:44.987745
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '39a845579587'
down_revision = 'fa4b41e0bfe6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('ivy_file', sa.Column('sample_count', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('ivy_file', 'sample_count')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""empty message
Revision ID: fa4b41e0bfe6
Revises: d904b7b3c1c0
Create Date: 2020-09-23 14:05:50.766928
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fa4b41e0bfe6'
down_revision = 'd904b7b3c1c0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('ivy_file',
sa.Column('file_name', sa.String(), nullable=False),
sa.Column('date_added', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('file_name')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('ivy_file')
# ### end Alembic commands ###

View File

@ -0,0 +1,2 @@
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
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

View File

@ -0,0 +1,3 @@
Student ID|Student Cellphone|Student Email|Test Date Time|Test Kiosk Loc|Test Result Code
987655321|555/555-5558|testnegetive@virginia.edu|202009070719|4321|1142270225
1 Student ID Student Cellphone Student Email Test Date Time Test Kiosk Loc Test Result Code
2 987655321 555/555-5558 testnegetive@virginia.edu 202009070719 4321 1142270225

View File

@ -0,0 +1,3 @@
Student ID|Student Cellphone|Student Email|Test Date Time|Test Kiosk Loc|Test Result Code
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 000000222 555/555-5556 testpositive@virginia.edu 202009091449 4321 8269722523
3 000000333 555/555-5558 testnegetive@virginia.edu 202009091449 4321 1142270225

View File

@ -0,0 +1,4 @@
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
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

View File

@ -1,9 +1,11 @@
from tests.base_test import BaseTest
import os import os
import unittest import unittest
import globus_sdk import globus_sdk
from tests.base_test import BaseTest
from communicator import app from communicator.models.ivy_file import IvyFile
from communicator import app, db
from communicator.errors import CommError from communicator.errors import CommError
from communicator.services.ivy_service import IvyService from communicator.services.ivy_service import IvyService
@ -22,5 +24,11 @@ class IvyServiceTest(BaseTest):
ivy_incorrect_file = os.path.join(app.root_path, '..', 'tests', 'data', 'incorrect.csv') ivy_incorrect_file = os.path.join(app.root_path, '..', 'tests', 'data', 'incorrect.csv')
IvyService.samples_from_ivy_file(ivy_incorrect_file) IvyService.samples_from_ivy_file(ivy_incorrect_file)
def test_load_directory(self):
self.assertEquals(0, db.session.query(IvyFile).count())
app.config['IVY_IMPORT_DIR'] = os.path.join(app.root_path, '..', 'tests', 'data', 'import_directory')
records = IvyService().load_directory()
files = db.session.query(IvyFile).all()
self.assertEquals(4, len(files))

View File

@ -10,8 +10,9 @@ class TestNotificationService(BaseTest):
def test_send_notification(self): def test_send_notification(self):
message_count = len(TEST_MESSAGES) message_count = len(TEST_MESSAGES)
notifier = NotificationService(app)
sample = Sample(email="dan@stauntonmakerspace.com", result_code="1234") sample = Sample(email="dan@stauntonmakerspace.com", result_code="1234")
notifier.send_result_email(sample) with NotificationService(app) as notifier:
notifier.send_result_email(sample)
self.assertEqual(len(TEST_MESSAGES), message_count + 1) self.assertEqual(len(TEST_MESSAGES), message_count + 1)
self.assertEqual("UVA: BE SAFE Notification", self.decode(TEST_MESSAGES[-1]['subject'])) self.assertEqual("UVA: BE SAFE Notification", self.decode(TEST_MESSAGES[-1]['subject']))

View File

@ -0,0 +1,27 @@
from tests.base_test import BaseTest
import json
from communicator.models import Sample
from communicator import db
class TestSampleEndpoint(BaseTest):
def test_create_sample(self):
sample_json = {"barcode": "000000111-202009091449-4321",
"location": "4321",
"date": "2020-09-09T14:49:00+0000",
"student_id": "000000111"}
# Test add sample
samples = db.session.query(Sample).all()
self.assertEquals(0, len(samples))
rv = self.app.post('/v1.0/sample',
content_type="application/json",
data=json.dumps(sample_json))
samples = db.session.query(Sample).all()
self.assertEquals(1, len(samples))