Adding an endpoint to create new sample records, and an index page

for displaying all samples in a paginated view.
This commit is contained in:
Dan Funk 2020-09-23 12:43:58 -04:00
parent ab07ea783d
commit f0132df1e5
11 changed files with 725 additions and 8 deletions

View File

@ -35,6 +35,9 @@ google-cloud-firestore = "*"
globus-sdk = "*" globus-sdk = "*"
gunicorn = "*" gunicorn = "*"
twilio = "*" twilio = "*"
flask-paginate = "*"
flask-assets = "*"
pyscss = "*"
[requires] [requires]
python_version = "3.8" python_version = "3.8"

61
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "ab12e64675592a61635bac5221044eb2d23bc232b4db303cae88e5cb8f4f2251" "sha256": "490cbcef70664a97232f019f8c20c17cea8035fac4904aa2e997c2f288832b87"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -248,6 +248,14 @@
"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": { "flask-babel": {
"hashes": [ "hashes": [
"sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468", "sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468",
@ -293,6 +301,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",
@ -429,6 +444,14 @@
], ],
"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",
@ -668,6 +691,13 @@
], ],
"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",
@ -826,6 +856,13 @@
], ],
"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",
@ -854,6 +891,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": {
@ -904,6 +948,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",
@ -975,6 +1027,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

@ -4,12 +4,15 @@ import os
import connexion import connexion
import sentry_sdk import sentry_sdk
from flask import render_template, request from flask import render_template, request
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,6 +37,27 @@ 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 from communicator import forms
@ -58,18 +82,28 @@ BASE_HREF = app.config['APPLICATION_ROOT'].strip('/')
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
def index(): def index():
from communicator.models import Sample
from communicator.tables import SampleTable
# display results # 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( return render_template(
'index.html', 'index.html',
table=table,
pagination=pagination,
base_href=BASE_HREF base_href=BASE_HREF
) )
@app.route('/invitation', methods=['GET', 'POST']) @app.route('/invitation', methods=['GET', 'POST'])
def send_invitation(): def send_invitation():
form = forms.InvitationForm(request.form) form = forms.InvitationForm(request.form)
action = BASE_HREF + "/invitation" action = BASE_HREF + "/invitation"
title = "Send invitation to students" title = "Send invitation to students"
if request.method == 'POST': if request.method == 'POST':
from communicator.services.notification_service import NotificationService from communicator.services.notification_service import NotificationService
with NotificationService(app) as ns: with NotificationService(app) as ns:

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,44 @@ 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"
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,6 +12,13 @@ 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 update_data(): def update_data():
"""Updates the database based on local files placed by IVY and records """Updates the database based on local files placed by IVY and records

View File

@ -14,7 +14,6 @@ class Sample(db.Model):
in_ivy = db.Column(db.Boolean, default=False) # Has this record come in from the IVY? in_ivy = db.Column(db.Boolean, default=False) # Has this record come in from the IVY?
email_notified = db.Column(db.Boolean, default=False) email_notified = db.Column(db.Boolean, default=False)
text_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", notifications = db.relationship(Notification, back_populates="sample",
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",
order_by=Notification.date) order_by=Notification.date)

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;
}
}
}

12
communicator/tables.py Normal file
View File

@ -0,0 +1,12 @@
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?')

View File

@ -6,9 +6,18 @@
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://use.typekit.net/kwp6dli.css"> <link rel="stylesheet" href="https://use.typekit.net/kwp6dli.css">
<link rel="shortcut icon" href="{{ base_href + url_for('static', filename='favicon.ico') }}"> <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> </head>
<body> <body>
<h2>UVA Be Safe Communicator</h2> <h2>UVA Be Safe Communicator</h2>
<p>Just making sure this works ok.</p>
<h3>Records to be processed</h3>
{{ pagination.info }}
{{ pagination.links }}
{{ table }}
{{ pagination.links }}
</body> </body>
</html> </html>

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))