From f0132df1e5287ed99f30f6fbcbbc8fefdba5ea4c Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Wed, 23 Sep 2020 12:43:58 -0400 Subject: [PATCH] Adding an endpoint to create new sample records, and an index page for displaying all samples in a paginated view. --- Pipfile | 3 + Pipfile.lock | 61 ++++- communicator/__init__.py | 36 ++- communicator/api.yml | 40 ++- communicator/api/admin.py | 7 + communicator/models/sample.py | 1 - communicator/static/app.css | 201 ++++++++++++++ communicator/static/scss/app.scss | 334 ++++++++++++++++++++++++ communicator/tables.py | 12 + communicator/templates/index.html | 11 +- tests/services/test_sample_endpoints.py | 27 ++ 11 files changed, 725 insertions(+), 8 deletions(-) create mode 100644 communicator/static/app.css create mode 100644 communicator/static/scss/app.scss create mode 100644 communicator/tables.py create mode 100644 tests/services/test_sample_endpoints.py diff --git a/Pipfile b/Pipfile index fce70df..de3ecdf 100644 --- a/Pipfile +++ b/Pipfile @@ -35,6 +35,9 @@ google-cloud-firestore = "*" globus-sdk = "*" gunicorn = "*" twilio = "*" +flask-paginate = "*" +flask-assets = "*" +pyscss = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 7395b76..3e7f5a2 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ab12e64675592a61635bac5221044eb2d23bc232b4db303cae88e5cb8f4f2251" + "sha256": "490cbcef70664a97232f019f8c20c17cea8035fac4904aa2e997c2f288832b87" }, "pipfile-spec": 6, "requires": { @@ -248,6 +248,14 @@ "index": "pypi", "version": "==1.5.6" }, + "flask-assets": { + "hashes": [ + "sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2", + "sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b" + ], + "index": "pypi", + "version": "==2.0" + }, "flask-babel": { "hashes": [ "sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468", @@ -293,6 +301,13 @@ "index": "pypi", "version": "==2.5.3" }, + "flask-paginate": { + "hashes": [ + "sha256:4d5c746e4f7b639a9bb72fac0c2452d58e5297b88d6ecda3340153a59453d581" + ], + "index": "pypi", + "version": "==0.7.0" + }, "flask-restful": { "hashes": [ "sha256:5ea9a5991abf2cb69b4aac19793faac6c032300505b325687d7c305ffaa76915", @@ -429,6 +444,14 @@ ], "version": "==2.10" }, + "importlib-metadata": { + "hashes": [ + "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", + "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" + ], + "markers": "python_version < '3.8'", + "version": "==2.0.0" + }, "inflection": { "hashes": [ "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", @@ -668,6 +691,13 @@ ], "version": "==0.17.3" }, + "pyscss": { + "hashes": [ + "sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf" + ], + "index": "pypi", + "version": "==1.3.7" + }, "python-box": { "hashes": [ "sha256:b7a6f3edd2f71e2475d93163b6465f637a2714b155acafef17408b06e55282b3", @@ -826,6 +856,13 @@ ], "version": "==1.4.4" }, + "webassets": { + "hashes": [ + "sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd", + "sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724" + ], + "version": "==2.0" + }, "webob": { "hashes": [ "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b", @@ -854,6 +891,13 @@ "sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c" ], "version": "==2.3.3" + }, + "zipp": { + "hashes": [ + "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6", + "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f" + ], + "version": "==3.2.0" } }, "develop": { @@ -904,6 +948,14 @@ "index": "pypi", "version": "==5.3" }, + "importlib-metadata": { + "hashes": [ + "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", + "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" + ], + "markers": "python_version < '3.8'", + "version": "==2.0.0" + }, "iniconfig": { "hashes": [ "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", @@ -975,6 +1027,13 @@ "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" ], "version": "==0.10.1" + }, + "zipp": { + "hashes": [ + "sha256:43f4fa8d8bb313e65d8323a3952ef8756bf40f9a5c3ea7334be23ee4ec8278b6", + "sha256:b52f22895f4cfce194bc8172f3819ee8de7540aa6d873535a8668b730b8b411f" + ], + "version": "==3.2.0" } } } diff --git a/communicator/__init__.py b/communicator/__init__.py index c380e3c..10d3224 100644 --- a/communicator/__init__.py +++ b/communicator/__init__.py @@ -4,12 +4,15 @@ import os import connexion import sentry_sdk from flask import render_template, request +from flask_assets import Environment from flask_cors import CORS from flask_mail import Mail from flask_marshmallow import Marshmallow from flask_migrate import Migrate +from flask_paginate import Pagination, get_page_parameter from flask_sqlalchemy import SQLAlchemy from sentry_sdk.integrations.flask import FlaskIntegration +from webassets import Bundle logging.basicConfig(level=logging.INFO) @@ -34,6 +37,27 @@ db = SQLAlchemy(app) migrate = Migrate(app, db) 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 + '/', + 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 api from communicator import forms @@ -58,18 +82,28 @@ BASE_HREF = app.config['APPLICATION_ROOT'].strip('/') @app.route('/', methods=['GET', 'POST']) 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(): 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: diff --git a/communicator/api.yml b/communicator/api.yml index 75230c4..d80c275 100644 --- a/communicator/api.yml +++ b/communicator/api.yml @@ -6,8 +6,6 @@ info: name: MIT servers: - url: http://localhost:5000/v1.0 -security: - - jwt: ['secret'] paths: /status: get: @@ -45,10 +43,44 @@ paths: text/plain: schema: 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: schemas: Status: properties: status: - type: string \ No newline at end of file + 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" diff --git a/communicator/api/admin.py b/communicator/api/admin.py index 64dc5b1..a2028f9 100644 --- a/communicator/api/admin.py +++ b/communicator/api/admin.py @@ -12,6 +12,13 @@ from communicator.services.sample_service import SampleService def status(): 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(): """Updates the database based on local files placed by IVY and records diff --git a/communicator/models/sample.py b/communicator/models/sample.py index 5324f2e..359fe2b 100644 --- a/communicator/models/sample.py +++ b/communicator/models/sample.py @@ -14,7 +14,6 @@ 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) diff --git a/communicator/static/app.css b/communicator/static/app.css new file mode 100644 index 0000000..e16eec8 --- /dev/null +++ b/communicator/static/app.css @@ -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; } diff --git a/communicator/static/scss/app.scss b/communicator/static/scss/app.scss new file mode 100644 index 0000000..c476b1c --- /dev/null +++ b/communicator/static/scss/app.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/communicator/tables.py b/communicator/tables.py new file mode 100644 index 0000000..a98a6b7 --- /dev/null +++ b/communicator/tables.py @@ -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?') diff --git a/communicator/templates/index.html b/communicator/templates/index.html index 5a7f816..d54a02f 100644 --- a/communicator/templates/index.html +++ b/communicator/templates/index.html @@ -6,9 +6,18 @@ + {% assets 'app_scss' %} + + {% endassets %} +

UVA Be Safe Communicator

-

Just making sure this works ok.

+ +

Records to be processed

+{{ pagination.info }} +{{ pagination.links }} +{{ table }} +{{ pagination.links }} diff --git a/tests/services/test_sample_endpoints.py b/tests/services/test_sample_endpoints.py new file mode 100644 index 0000000..b7882c9 --- /dev/null +++ b/tests/services/test_sample_endpoints.py @@ -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)) +