From 0fa80ea5fbcf491c0b90ae2c61e007b9e96b2fcd Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Fri, 22 May 2020 14:56:46 -0400 Subject: [PATCH 01/15] Runs database upgrade/downgrade/reset based on environment variables --- Dockerfile | 1 - docker_run.sh | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 063151b..d708d51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,6 @@ COPY . /protocol-builder-mock/ ENV FLASK_APP=/protocol-builder-mock/app.py # run webserver by default -CMD ["pipenv", "run", "flask", "db", "upgrade"] CMD ["pipenv", "run", "python", "/protocol-builder-mock/run.py"] # expose ports diff --git a/docker_run.sh b/docker_run.sh index a24b6db..5886817 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -2,5 +2,20 @@ # run migrations export FLASK_APP=./app.py -pipenv run flask db upgrade + +if [ "$DOWNGRADE_DB" = "true" ]; then + echo 'Downgrading database...' + pipenv run flask db downgrade +fi + +if [ "$UPGRADE_DB" = "true" ]; then + echo 'Upgrading database...' + pipenv run flask db upgrade +fi + +if [ "$RESET_DB" = "true" ]; then + echo 'Resetting database...' + pipenv run flask load-example-data +fi + pipenv run python ./run.py From 4a03fb1edec8208623dab5b5beba257386f07b0b Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sat, 23 May 2020 12:29:18 -0400 Subject: [PATCH 02/15] Sets base href from environment variable --- app.py | 2 +- config/default.py | 1 + templates/index.html | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index f776df0..85f200c 100644 --- a/app.py +++ b/app.py @@ -107,7 +107,7 @@ def index(): # display results studies = db.session.query(Study).order_by(Study.DATE_MODIFIED.desc()).all() table = StudyTable(studies) - return render_template('index.html', table=table) + return render_template('index.html', table=table, BASE_HREF=app.config['BASE_HREF']) @app.route('/new_study', methods=['GET', 'POST']) diff --git a/config/default.py b/config/default.py index 0d552d9..3b70fbf 100644 --- a/config/default.py +++ b/config/default.py @@ -8,6 +8,7 @@ FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5001") CORS_ENABLED = False DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" +BASE_HREF = environ.get('BASE_HREF', default="/") DB_HOST = environ.get('DB_HOST', default="localhost") DB_PORT = environ.get('DB_PORT', default="5432") diff --git a/templates/index.html b/templates/index.html index 273fcd3..cb241c7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,6 +2,7 @@ Protocol Builder Mock + {% assets 'app_scss' %} From 7699e757afc1a5425d66f69797d7c8fb849e4739 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sat, 23 May 2020 21:35:00 -0400 Subject: [PATCH 03/15] Adds base href to all routes and sets base path for Connexion API --- api.yml | 2 -- app.py | 19 ++++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/api.yml b/api.yml index 8786c3a..ac59e64 100644 --- a/api.yml +++ b/api.yml @@ -10,8 +10,6 @@ info: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html # Added by API Auto Mocking Plugin -servers: - - url: http://localhost:5000/pb # tags are used for organizing operations tags: - name: CR-Connect diff --git a/app.py b/app.py index 85f200c..93977a5 100644 --- a/app.py +++ b/app.py @@ -41,7 +41,6 @@ def get_form(id, requirement_code): conn = connexion.App('Protocol Builder', specification_dir='./') -conn.add_api('api.yml') app = conn.app @@ -55,6 +54,8 @@ else: app.config.root_path = app.instance_path app.config.from_pyfile('config.py', silent=True) +BASE_HREF = app.config['BASE_HREF'] +conn.add_api('api.yml', base_path=BASE_HREF + '/pb') db = SQLAlchemy(app) migrate = Migrate(app, db) ma = Marshmallow(app) @@ -82,7 +83,7 @@ def has_no_empty_params(rule): return len(defaults) >= len(arguments) -@app.route("/site_map") +@app.route(BASE_HREF + '/site_map') def site_map(): links = [] for rule in app.url_map.iter_rules(): @@ -102,7 +103,7 @@ from models import Study, RequiredDocument, Investigator, StudySchema, RequiredD StudyDetails, StudyDetailsSchema -@app.route('/', methods=['GET', 'POST']) +@app.route(BASE_HREF + '/', methods=['GET', 'POST']) def index(): # display results studies = db.session.query(Study).order_by(Study.DATE_MODIFIED.desc()).all() @@ -110,7 +111,7 @@ def index(): return render_template('index.html', table=table, BASE_HREF=app.config['BASE_HREF']) -@app.route('/new_study', methods=['GET', 'POST']) +@app.route(BASE_HREF + '/new_study', methods=['GET', 'POST']) def new_study(): form = StudyForm(request.form) action = "/new_study" @@ -128,7 +129,7 @@ def new_study(): description_map=description_map) -@app.route('/study/', methods=['GET', 'POST']) +@app.route(BASE_HREF + '/study/', methods=['GET', 'POST']) def edit_study(study_id): study = db.session.query(Study).filter(Study.STUDYID == study_id).first() form = StudyForm(request.form, obj=study) @@ -149,7 +150,7 @@ def edit_study(study_id): description_map={}) -@app.route('/investigator/', methods=['GET', 'POST']) +@app.route(BASE_HREF + '/investigator/', methods=['GET', 'POST']) def new_investigator(study_id): form = InvestigatorForm(request.form) action = "/investigator/" + study_id @@ -169,14 +170,14 @@ def new_investigator(study_id): description_map={}) -@app.route('/del_investigator/', methods=['GET']) +@app.route(BASE_HREF + '/del_investigator/', methods=['GET']) def del_investigator(inv_id): db.session.query(Investigator).filter(Investigator.id == inv_id).delete() db.session.commit() return redirect('/') -@app.route('/del_study/', methods=['GET']) +@app.route(BASE_HREF + '/del_study/', methods=['GET']) def del_study(study_id): db.session.query(RequiredDocument).filter(RequiredDocument.STUDYID == study_id).delete() db.session.query(Investigator).filter(Investigator.STUDYID == study_id).delete() @@ -211,7 +212,7 @@ def _update_study(study, form): db.session.commit() -@app.route('/study_details/', methods=['GET', 'POST']) +@app.route(BASE_HREF + '/study_details/', methods=['GET', 'POST']) def study_details(study_id): study_details = db.session.query(StudyDetails).filter(StudyDetails.STUDYID == study_id).first() if not study_details: From 4c88afbdd3f1473b29fdd7e25bf60bfc3eb31557 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sat, 23 May 2020 21:58:58 -0400 Subject: [PATCH 04/15] Adds trailing slash to base href if needed --- app.py | 20 ++++++++++---------- config/default.py | 6 +++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index 93977a5..7e60efe 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import datetime import os +import re from datetime import date import connexion @@ -54,8 +55,7 @@ else: app.config.root_path = app.instance_path app.config.from_pyfile('config.py', silent=True) -BASE_HREF = app.config['BASE_HREF'] -conn.add_api('api.yml', base_path=BASE_HREF + '/pb') +conn.add_api('api.yml', base_path=app.config['BASE_HREF'] + 'pb') db = SQLAlchemy(app) migrate = Migrate(app, db) ma = Marshmallow(app) @@ -83,7 +83,7 @@ def has_no_empty_params(rule): return len(defaults) >= len(arguments) -@app.route(BASE_HREF + '/site_map') +@app.route(app.config['BASE_HREF'] + '/site_map') def site_map(): links = [] for rule in app.url_map.iter_rules(): @@ -103,7 +103,7 @@ from models import Study, RequiredDocument, Investigator, StudySchema, RequiredD StudyDetails, StudyDetailsSchema -@app.route(BASE_HREF + '/', methods=['GET', 'POST']) +@app.route(app.config['BASE_HREF'] + '/', methods=['GET', 'POST']) def index(): # display results studies = db.session.query(Study).order_by(Study.DATE_MODIFIED.desc()).all() @@ -111,7 +111,7 @@ def index(): return render_template('index.html', table=table, BASE_HREF=app.config['BASE_HREF']) -@app.route(BASE_HREF + '/new_study', methods=['GET', 'POST']) +@app.route(app.config['BASE_HREF'] + '/new_study', methods=['GET', 'POST']) def new_study(): form = StudyForm(request.form) action = "/new_study" @@ -129,7 +129,7 @@ def new_study(): description_map=description_map) -@app.route(BASE_HREF + '/study/', methods=['GET', 'POST']) +@app.route(app.config['BASE_HREF'] + '/study/', methods=['GET', 'POST']) def edit_study(study_id): study = db.session.query(Study).filter(Study.STUDYID == study_id).first() form = StudyForm(request.form, obj=study) @@ -150,7 +150,7 @@ def edit_study(study_id): description_map={}) -@app.route(BASE_HREF + '/investigator/', methods=['GET', 'POST']) +@app.route(app.config['BASE_HREF'] + '/investigator/', methods=['GET', 'POST']) def new_investigator(study_id): form = InvestigatorForm(request.form) action = "/investigator/" + study_id @@ -170,14 +170,14 @@ def new_investigator(study_id): description_map={}) -@app.route(BASE_HREF + '/del_investigator/', methods=['GET']) +@app.route(app.config['BASE_HREF'] + '/del_investigator/', methods=['GET']) def del_investigator(inv_id): db.session.query(Investigator).filter(Investigator.id == inv_id).delete() db.session.commit() return redirect('/') -@app.route(BASE_HREF + '/del_study/', methods=['GET']) +@app.route(app.config['BASE_HREF'] + '/del_study/', methods=['GET']) def del_study(study_id): db.session.query(RequiredDocument).filter(RequiredDocument.STUDYID == study_id).delete() db.session.query(Investigator).filter(Investigator.STUDYID == study_id).delete() @@ -212,7 +212,7 @@ def _update_study(study, form): db.session.commit() -@app.route(BASE_HREF + '/study_details/', methods=['GET', 'POST']) +@app.route(app.config['BASE_HREF'] + '/study_details/', methods=['GET', 'POST']) def study_details(study_id): study_details = db.session.query(StudyDetails).filter(StudyDetails.STUDYID == study_id).first() if not study_details: diff --git a/config/default.py b/config/default.py index 3b70fbf..82e133d 100644 --- a/config/default.py +++ b/config/default.py @@ -1,3 +1,4 @@ +import re import os from os import environ @@ -8,7 +9,9 @@ FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5001") CORS_ENABLED = False DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" -BASE_HREF = environ.get('BASE_HREF', default="/") + +# Add trailing slash to base path +BASE_HREF = re.sub(r'//', '/', '/%s/' % environ.get('BASE_HREF', default="/").strip('/')) DB_HOST = environ.get('DB_HOST', default="localhost") DB_PORT = environ.get('DB_PORT', default="5432") @@ -25,3 +28,4 @@ print('=== USING DEFAULT CONFIG: ===') print('DB_HOST = ', DB_HOST) print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) +print('BASE_HREF = ', BASE_HREF) From 8fa36b968c024edfc64f72fee64350a2af02ed8e Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 00:05:24 -0400 Subject: [PATCH 05/15] Apparently, APPLICATION_ROOT does something. --- app.py | 20 ++++++++++---------- config/default.py | 4 ++-- templates/index.html | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app.py b/app.py index 7e60efe..1f0023c 100644 --- a/app.py +++ b/app.py @@ -55,7 +55,7 @@ else: app.config.root_path = app.instance_path app.config.from_pyfile('config.py', silent=True) -conn.add_api('api.yml', base_path=app.config['BASE_HREF'] + 'pb') +conn.add_api('api.yml', base_path='/pb') db = SQLAlchemy(app) migrate = Migrate(app, db) ma = Marshmallow(app) @@ -83,7 +83,7 @@ def has_no_empty_params(rule): return len(defaults) >= len(arguments) -@app.route(app.config['BASE_HREF'] + '/site_map') +@app.route('/site_map') def site_map(): links = [] for rule in app.url_map.iter_rules(): @@ -103,15 +103,15 @@ from models import Study, RequiredDocument, Investigator, StudySchema, RequiredD StudyDetails, StudyDetailsSchema -@app.route(app.config['BASE_HREF'] + '/', methods=['GET', 'POST']) +@app.route('/', methods=['GET', 'POST']) def index(): # display results studies = db.session.query(Study).order_by(Study.DATE_MODIFIED.desc()).all() table = StudyTable(studies) - return render_template('index.html', table=table, BASE_HREF=app.config['BASE_HREF']) + return render_template('index.html', table=table, APPLICATION_ROOT=app.config['APPLICATION_ROOT']) -@app.route(app.config['BASE_HREF'] + '/new_study', methods=['GET', 'POST']) +@app.route('/new_study', methods=['GET', 'POST']) def new_study(): form = StudyForm(request.form) action = "/new_study" @@ -129,7 +129,7 @@ def new_study(): description_map=description_map) -@app.route(app.config['BASE_HREF'] + '/study/', methods=['GET', 'POST']) +@app.route('/study/', methods=['GET', 'POST']) def edit_study(study_id): study = db.session.query(Study).filter(Study.STUDYID == study_id).first() form = StudyForm(request.form, obj=study) @@ -150,7 +150,7 @@ def edit_study(study_id): description_map={}) -@app.route(app.config['BASE_HREF'] + '/investigator/', methods=['GET', 'POST']) +@app.route('/investigator/', methods=['GET', 'POST']) def new_investigator(study_id): form = InvestigatorForm(request.form) action = "/investigator/" + study_id @@ -170,14 +170,14 @@ def new_investigator(study_id): description_map={}) -@app.route(app.config['BASE_HREF'] + '/del_investigator/', methods=['GET']) +@app.route('/del_investigator/', methods=['GET']) def del_investigator(inv_id): db.session.query(Investigator).filter(Investigator.id == inv_id).delete() db.session.commit() return redirect('/') -@app.route(app.config['BASE_HREF'] + '/del_study/', methods=['GET']) +@app.route('/del_study/', methods=['GET']) def del_study(study_id): db.session.query(RequiredDocument).filter(RequiredDocument.STUDYID == study_id).delete() db.session.query(Investigator).filter(Investigator.STUDYID == study_id).delete() @@ -212,7 +212,7 @@ def _update_study(study, form): db.session.commit() -@app.route(app.config['BASE_HREF'] + '/study_details/', methods=['GET', 'POST']) +@app.route('/study_details/', methods=['GET', 'POST']) def study_details(study_id): study_details = db.session.query(StudyDetails).filter(StudyDetails.STUDYID == study_id).first() if not study_details: diff --git a/config/default.py b/config/default.py index 82e133d..3c140fe 100644 --- a/config/default.py +++ b/config/default.py @@ -11,7 +11,7 @@ DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" # Add trailing slash to base path -BASE_HREF = re.sub(r'//', '/', '/%s/' % environ.get('BASE_HREF', default="/").strip('/')) +APPLICATION_ROOT = re.sub(r'//', '/', '/%s/' % environ.get('APPLICATION_ROOT', default="/").strip('/')) DB_HOST = environ.get('DB_HOST', default="localhost") DB_PORT = environ.get('DB_PORT', default="5432") @@ -28,4 +28,4 @@ print('=== USING DEFAULT CONFIG: ===') print('DB_HOST = ', DB_HOST) print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) -print('BASE_HREF = ', BASE_HREF) +print('APPLICATION_ROOT = ', APPLICATION_ROOT) diff --git a/templates/index.html b/templates/index.html index cb241c7..6760d61 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,7 +2,7 @@ Protocol Builder Mock - + {% assets 'app_scss' %} From a633cc7c0984f07be025af950ea6a64c7898f4da Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 18:30:57 -0400 Subject: [PATCH 06/15] Reorganizes files for consistency between repos. Completely refactors Dockerfile to install from wheel. --- .gitignore | 2 +- Dockerfile | 60 ++++-- Pipfile | 1 + Pipfile.lock | 257 ++++++++++++++----------- app.py => pb/__init__.py | 10 +- api.yml => pb/api.yml | 8 +- forms.py => pb/forms.py | 9 +- models.py => pb/models.py | 2 +- run.py | 2 +- setup.cfg | 5 + setup.py | 3 + static/app.css | 133 +++++++++++++ tests/__init__.py | 0 test_sanity.py => tests/test_sanity.py | 6 +- 14 files changed, 344 insertions(+), 154 deletions(-) rename app.py => pb/__init__.py (96%) rename api.yml => pb/api.yml (99%) rename forms.py => pb/forms.py (90%) rename models.py => pb/models.py (99%) create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 static/app.css create mode 100644 tests/__init__.py rename test_sanity.py => tests/test_sanity.py (95%) diff --git a/.gitignore b/.gitignore index 1a77a3e..729225f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ __pycache__/ app.db static/.webassets-cache* -static/*.css +pb/static/*.css diff --git a/Dockerfile b/Dockerfile index d708d51..76b9055 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,48 @@ -FROM python:3.7 +# +# https://medium.com/@greut/building-a-python-package-a-docker-image-using-pipenv-233d8793b6cc +# https://github.com/greut/pipenv-to-wheel +# +FROM kennethreitz/pipenv as pipenv -ENV PATH=/root/.local/bin:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin +ADD . /app +WORKDIR /app -# install node and yarn -RUN apt-get update -RUN apt-get -y install postgresql-client libpcre3 libpcre3-dev +RUN pipenv install --dev \ + && pipenv lock -r > requirements.txt \ + && pipenv run python setup.py bdist_wheel -# config project dir -RUN mkdir /protocol-builder-mock -WORKDIR /protocol-builder-mock +# ---------------------------------------------------------------------------- +FROM ubuntu:bionic -# install python requirements -RUN pip install pipenv -ADD Pipfile /protocol-builder-mock/ -ADD Pipfile.lock /protocol-builder-mock/ -RUN pipenv install --dev +ARG DEBIAN_FRONTEND=noninteractive -# include rejoiner code (gets overriden by local changes) -COPY . /protocol-builder-mock/ +COPY --from=pipenv /app/dist/*.whl . -ENV FLASK_APP=/protocol-builder-mock/app.py +RUN set -xe \ + && apt-get update -q \ + && apt-get install -y -q \ + python3-minimal \ + python3-wheel \ + python3-pip \ + gunicorn3 \ + postgresql-client \ + && python3 -m pip install *.whl \ + && apt-get remove -y python3-pip python3-wheel \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -f *.whl \ + && rm -rf /root/.cache \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app \ + && useradd _gunicorn --no-create-home --user-group -# run webserver by default -CMD ["pipenv", "run", "python", "/protocol-builder-mock/run.py"] - -# expose ports -EXPOSE 5001 +USER _gunicorn +COPY ./static /app/static +COPY ./docker_run.sh /app/ +COPY ./wait-for-it.sh /app/ +WORKDIR /app +CMD ["gunicorn3", \ + "--bind", "0.0.0.0:8000", \ + "pb:app"] diff --git a/Pipfile b/Pipfile index 2d13f1d..5f4a440 100644 --- a/Pipfile +++ b/Pipfile @@ -4,6 +4,7 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] +pbr = "*" [packages] flask = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 34da46f..c59122e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ca92325f9ff90d6263f261dc514de998c618a3e91614f9c1987b2f0db9b72dcf" + "sha256": "6d81da4e1f722cf965aeff5e24c86278b5df745f0eadaa2b401d54e7f836c654" }, "pipfile-spec": 6, "requires": { @@ -38,10 +38,10 @@ }, "certifi": { "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" ], - "version": "==2019.11.28" + "version": "==2020.4.5.1" }, "chardet": { "hashes": [ @@ -52,10 +52,10 @@ }, "click": { "hashes": [ - "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", - "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "version": "==7.1.1" + "version": "==7.1.2" }, "clickclick": { "hashes": [ @@ -69,11 +69,11 @@ "swagger-ui" ], "hashes": [ - "sha256:bf32bfae6af337cfa4a8489c21516adbe5c50e3f8dc0b7ed2394ce8dde218018", - "sha256:c568e579f84be808e387dcb8570bb00a536891be1318718a0dad3ba90f034191" + "sha256:1ccfac57d4bb7adf4295ba6f5e48f5a1f66057df6a0713417766c9b5235182ee", + "sha256:5439e9659a89c4380d93a07acfbf3380d70be4130574de8881e5f0dfec7ad0e2" ], "index": "pypi", - "version": "==2.6.0" + "version": "==2.7.0" }, "decorator": { "hashes": [ @@ -84,11 +84,11 @@ }, "flask": { "hashes": [ - "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", - "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6" + "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", + "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" ], "index": "pypi", - "version": "==1.1.1" + "version": "==1.1.2" }, "flask-assets": { "hashes": [ @@ -107,11 +107,11 @@ }, "flask-marshmallow": { "hashes": [ - "sha256:01520ef1851ccb64d4ffb33196cddff895cc1302ae1585bff1abf58684a8111a", - "sha256:28b969193958d9602ab5d6add6d280e0e360c8e373d3492c2f73b024ecd36374" + "sha256:6e6aec171b8e092e0eafaf035ff5b8637bf3a58ab46f568c4c1bab02f2a3c196", + "sha256:a1685536e7ab5abdc712bbc1ac1a6b0b50951a368502f7985e7d1c27b3c21e59" ], "index": "pypi", - "version": "==0.11.0" + "version": "==0.12.0" }, "flask-migrate": { "hashes": [ @@ -146,32 +146,31 @@ }, "gevent": { "hashes": [ - "sha256:0774babec518a24d9a7231d4e689931f31b332c4517a771e532002614e270a64", - "sha256:0e1e5b73a445fe82d40907322e1e0eec6a6745ca3cea19291c6f9f50117bb7ea", - "sha256:0ff2b70e8e338cf13bedf146b8c29d475e2a544b5d1fe14045aee827c073842c", - "sha256:107f4232db2172f7e8429ed7779c10f2ed16616d75ffbe77e0e0c3fcdeb51a51", - "sha256:14b4d06d19d39a440e72253f77067d27209c67e7611e352f79fe69e0f618f76e", - "sha256:1b7d3a285978b27b469c0ff5fb5a72bcd69f4306dbbf22d7997d83209a8ba917", - "sha256:1eb7fa3b9bd9174dfe9c3b59b7a09b768ecd496debfc4976a9530a3e15c990d1", - "sha256:2711e69788ddb34c059a30186e05c55a6b611cb9e34ac343e69cf3264d42fe1c", - "sha256:28a0c5417b464562ab9842dd1fb0cc1524e60494641d973206ec24d6ec5f6909", - "sha256:3249011d13d0c63bea72d91cec23a9cf18c25f91d1f115121e5c9113d753fa12", - "sha256:44089ed06a962a3a70e96353c981d628b2d4a2f2a75ea5d90f916a62d22af2e8", - "sha256:4bfa291e3c931ff3c99a349d8857605dca029de61d74c6bb82bd46373959c942", - "sha256:50024a1ee2cf04645535c5ebaeaa0a60c5ef32e262da981f4be0546b26791950", - "sha256:53b72385857e04e7faca13c613c07cab411480822ac658d97fd8a4ddbaf715c8", - "sha256:74b7528f901f39c39cdbb50cdf08f1a2351725d9aebaef212a29abfbb06895ee", - "sha256:7d0809e2991c9784eceeadef01c27ee6a33ca09ebba6154317a257353e3af922", - "sha256:896b2b80931d6b13b5d9feba3d4eebc67d5e6ec54f0cf3339d08487d55d93b0e", - "sha256:8d9ec51cc06580f8c21b41fd3f2b3465197ba5b23c00eb7d422b7ae0380510b0", - "sha256:9f7a1e96fec45f70ad364e46de32ccacab4d80de238bd3c2edd036867ccd48ad", - "sha256:ab4dc33ef0e26dc627559786a4fba0c2227f125db85d970abbf85b77506b3f51", - "sha256:d1e6d1f156e999edab069d79d890859806b555ce4e4da5b6418616322f0a3df1", - "sha256:d752bcf1b98174780e2317ada12013d612f05116456133a6acf3e17d43b71f05", - "sha256:e5bcc4270671936349249d26140c267397b7b4b1381f5ec8b13c53c5b53ab6e1" + "sha256:00b03601b8dd1ee2aa07811cb60a4befe36173b15d91c6e207e37f8d77dd6fac", + "sha256:0acc15ba2ac2a555529ad82d5a28fc85dbb6b2ff947657d67bebfd352e2b5c14", + "sha256:15eae3cd450dac7dae7f4ac59e01db1378965c9ef565c39c5ae78c5a888f9ac9", + "sha256:1dc7f1f6bc1f67d625e4272b01e717eba0b4fa024d2ff7934c8d320674d6f7fa", + "sha256:1dd95433be45e1115053878366e3f5332ae99c39cb345be23851327c062b9f4a", + "sha256:28b7d83b4327ceb79668eca2049bf4b9ce66d5ace18a88335e3035b573f889fd", + "sha256:31dc5d4ab8172cc00c4ff17cb18edee633babd961f64bf54214244d769bc3a74", + "sha256:38db524ea88d81d596b2cbb6948fced26654a15fec40ea4529224e239a6f45e8", + "sha256:3ff477b6d275396123faf8ce2d5b82f96d85ba264e0b9d4b56a2bac49d1b9adc", + "sha256:4d2729dd4bf9c4d0f29482f53cdf9fc90a498aebb5cd7ae8b45d35657437d2ac", + "sha256:52e5cd607749ed3b8aa0272cacf2c11deec61fca4c3bec57a9fea8c49316627d", + "sha256:5c604179cebcc57f10505d8db177b92a715907815a464b066e7eba322d1c33ac", + "sha256:88c76df4967c5229f853aa67ad1b394d9e4f985b0359c9bc9879416bba3e7c68", + "sha256:929c33df8e9bcbe31906024fcd21580bd018196dbd3249eb5b2f19d63e11092d", + "sha256:92edc18a357473e01a4e4a82c073ed3c99ceca6e3ce93c23668dd4a2401f07dc", + "sha256:937d36730f2b0dee3387712074b1f15b802e2e074a3d7c6dcaf70521236d607c", + "sha256:9b4e940fc6071afebb86ba5f48dbb5f1fc3cb96ebeb8cf145eb5b499e9c6ee33", + "sha256:a7805934e8ce81610b61f806572c3d504cedd698cc8c9460d78d2893ba598c4a", + "sha256:d07a2afe4215731eb57d5b257a2e7e7e170d8a7ae1f02f6d0682cd3403debea9", + "sha256:e01d5373528e4ebdde66dc47a608d225fa3c4408ccd828d26c49b7ff75d82bd9", + "sha256:efd9546468502a30ddd4699c3124ccb9d3099130f9b5ae1e2a54ad5b46e86120", + "sha256:fcb64f3a28420d1b872b7ef41b12e8a1a4dcadfc8eff3c09993ab0cdf52584a1" ], "index": "pypi", - "version": "==1.4.0" + "version": "==20.5.0" }, "greenlet": { "hashes": [ @@ -210,23 +209,25 @@ }, "importlib-metadata": { "hashes": [ - "sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302", - "sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b" + "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", + "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" ], "markers": "python_version < '3.8'", - "version": "==1.5.0" + "version": "==1.6.0" }, "infinity": { "hashes": [ + "sha256:91069282767a8695b880feda218948aafc1b89fddddd9b1b156792d9de8f6234", "sha256:dc4aa138d7e366fc00d2e741e32c78a0fecd16b74f8daeb3f7408b459668005c" ], "version": "==1.4" }, "inflection": { "hashes": [ - "sha256:18ea7fb7a7d152853386523def08736aa8c32636b047ade55f7578c4edeb16ca" + "sha256:32a5c3341d9583ec319548b9015b7fbdf8c429cbcb575d326c33ae3a0e90d52c", + "sha256:9a15d3598f01220e93f2207c432cfede50daff53137ce660fb8be838ef1ca6cc" ], - "version": "==0.3.1" + "version": "==0.4.0" }, "intervals": { "hashes": [ @@ -243,10 +244,10 @@ }, "jinja2": { "hashes": [ - "sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250", - "sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49" + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], - "version": "==2.11.1" + "version": "==2.11.2" }, "jsonschema": { "hashes": [ @@ -315,18 +316,18 @@ }, "marshmallow": { "hashes": [ - "sha256:90854221bbb1498d003a0c3cc9d8390259137551917961c8b5258c64026b2f85", - "sha256:ac2e13b30165501b7d41fc0371b8df35944f5849769d136f20e2c5f6cdc6e665" + "sha256:c2673233aa21dde264b84349dc2fd1dce5f30ed724a0a00e75426734de5b84ab", + "sha256:f88fe96434b1f0f476d54224d59333eba8ca1a203a2695683c1855675c4049a7" ], - "version": "==3.5.1" + "version": "==3.6.0" }, "marshmallow-sqlalchemy": { "hashes": [ - "sha256:9301c6fd197bd97337820ea1417aa1233d0ee3e22748ebd5821799bc841a57e8", - "sha256:dde9e20bcb710e9e59f765a38e3d6d17f1b2d6b4320cbdc2cea0f6b57f70d08c" + "sha256:3247e41e424146340b03a369f2b7c6f0364477ccedc4e2481e84d5f3a8d3c67f", + "sha256:dbbe51d28bb28e7ee2782e51310477f7a2c5a111a301f6dd8e264e11ab820427" ], "index": "pypi", - "version": "==0.22.3" + "version": "==0.23.0" }, "openapi-spec-validator": { "hashes": [ @@ -338,41 +339,39 @@ }, "psycopg2-binary": { "hashes": [ - "sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29", - "sha256:086f7e89ec85a6704db51f68f0dcae432eff9300809723a6e8782c41c2f48e03", - "sha256:18ca813fdb17bc1db73fe61b196b05dd1ca2165b884dd5ec5568877cabf9b039", - "sha256:19dc39616850342a2a6db70559af55b22955f86667b5f652f40c0e99253d9881", - "sha256:2166e770cb98f02ed5ee2b0b569d40db26788e0bf2ec3ae1a0d864ea6f1d8309", - "sha256:3a2522b1d9178575acee4adf8fd9f979f9c0449b00b4164bb63c3475ea6528ed", - "sha256:3aa773580f85a28ffdf6f862e59cb5a3cc7ef6885121f2de3fca8d6ada4dbf3b", - "sha256:3b5deaa3ee7180585a296af33e14c9b18c218d148e735c7accf78130765a47e3", - "sha256:407af6d7e46593415f216c7f56ba087a9a42bd6dc2ecb86028760aa45b802bd7", - "sha256:4c3c09fb674401f630626310bcaf6cd6285daf0d5e4c26d6e55ca26a2734e39b", - "sha256:4c6717962247445b4f9e21c962ea61d2e884fc17df5ddf5e35863b016f8a1f03", - "sha256:50446fae5681fc99f87e505d4e77c9407e683ab60c555ec302f9ac9bffa61103", - "sha256:5057669b6a66aa9ca118a2a860159f0ee3acf837eda937bdd2a64f3431361a2d", - "sha256:5dd90c5438b4f935c9d01fcbad3620253da89d19c1f5fca9158646407ed7df35", - "sha256:659c815b5b8e2a55193ede2795c1e2349b8011497310bb936da7d4745652823b", - "sha256:69b13fdf12878b10dc6003acc8d0abf3ad93e79813fd5f3812497c1c9fb9be49", - "sha256:7a1cb80e35e1ccea3e11a48afe65d38744a0e0bde88795cc56a4d05b6e4f9d70", - "sha256:7e6e3c52e6732c219c07bd97fff6c088f8df4dae3b79752ee3a817e6f32e177e", - "sha256:7f42a8490c4fe854325504ce7a6e4796b207960dabb2cbafe3c3959cb00d1d7e", - "sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e", - "sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103", - "sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6", - "sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1", - "sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9", - "sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e", - "sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f", - "sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd", - "sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8", - "sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f", - "sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4", - "sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964", - "sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08" + "sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac", + "sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a", + "sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5", + "sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04", + "sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1", + "sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5", + "sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce", + "sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434", + "sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9", + "sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057", + "sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98", + "sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522", + "sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505", + "sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa", + "sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3", + "sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f", + "sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4", + "sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4", + "sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266", + "sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66", + "sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38", + "sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3", + "sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389", + "sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab", + "sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb", + "sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6", + "sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d", + "sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162", + "sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e", + "sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd" ], "index": "pypi", - "version": "==2.8.4" + "version": "==2.8.5" }, "pyrsistent": { "hashes": [ @@ -382,10 +381,10 @@ }, "pyscss": { "hashes": [ - "sha256:123c1a9087f1c420bea891ebf19d5222262c7d30ced20bb38586023de28c9d4f" + "sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf" ], "index": "pypi", - "version": "==1.3.6" + "version": "==1.3.7" }, "python-dateutil": { "hashes": [ @@ -404,10 +403,10 @@ }, "pytz": { "hashes": [ - "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", - "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" + "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", + "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" ], - "version": "==2019.3" + "version": "==2020.1" }, "pyyaml": { "hashes": [ @@ -434,23 +433,50 @@ }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.14.0" + "version": "==1.15.0" }, "sqlalchemy": { "hashes": [ - "sha256:c4cca4aed606297afbe90d4306b49ad3a4cd36feb3f87e4bfd655c57fd9ef445" + "sha256:128bc917ed20d78143a45024455ff0aed7d3b96772eba13d5dbaf9cc57e5c41b", + "sha256:156a27548ba4e1fed944ff9fcdc150633e61d350d673ae7baaf6c25c04ac1f71", + "sha256:27e2efc8f77661c9af2681755974205e7462f1ae126f498f4fe12a8b24761d15", + "sha256:2a12f8be25b9ea3d1d5b165202181f2b7da4b3395289000284e5bb86154ce87c", + "sha256:31c043d5211aa0e0773821fcc318eb5cbe2ec916dfbc4c6eea0c5188971988eb", + "sha256:65eb3b03229f684af0cf0ad3bcc771970c1260a82a791a8d07bffb63d8c95bcc", + "sha256:6cd157ce74a911325e164441ff2d9b4e244659a25b3146310518d83202f15f7a", + "sha256:703c002277f0fbc3c04d0ae4989a174753a7554b2963c584ce2ec0cddcf2bc53", + "sha256:869bbb637de58ab0a912b7f20e9192132f9fbc47fc6b5111cd1e0f6cdf5cf9b0", + "sha256:8a0e0cd21da047ea10267c37caf12add400a92f0620c8bc09e4a6531a765d6d7", + "sha256:8d01e949a5d22e5c4800d59b50617c56125fc187fbeb8fa423e99858546de616", + "sha256:925b4fe5e7c03ed76912b75a9a41dfd682d59c0be43bce88d3b27f7f5ba028fb", + "sha256:9cb1819008f0225a7c066cac8bb0cf90847b2c4a6eb9ebb7431dbd00c56c06c5", + "sha256:a87d496884f40c94c85a647c385f4fd5887941d2609f71043e2b73f2436d9c65", + "sha256:a9030cd30caf848a13a192c5e45367e3c6f363726569a56e75dc1151ee26d859", + "sha256:a9e75e49a0f1583eee0ce93270232b8e7bb4b1edc89cc70b07600d525aef4f43", + "sha256:b50f45d0e82b4562f59f0e0ca511f65e412f2a97d790eea5f60e34e5f1aabc9a", + "sha256:b7878e59ec31f12d54b3797689402ee3b5cfcb5598f2ebf26491732758751908", + "sha256:ce1ddaadee913543ff0154021d31b134551f63428065168e756d90bdc4c686f5", + "sha256:ce2646e4c0807f3461be0653502bb48c6e91a5171d6e450367082c79e12868bf", + "sha256:ce6c3d18b2a8ce364013d47b9cad71db815df31d55918403f8db7d890c9d07ae", + "sha256:e4e2664232005bd306f878b0f167a31f944a07c4de0152c444f8c61bbe3cfb38", + "sha256:e8aa395482728de8bdcca9cc0faf3765ab483e81e01923aaa736b42f0294f570", + "sha256:eb4fcf7105bf071c71068c6eee47499ab8d4b8f5a11fc35147c934f0faa60f23", + "sha256:ed375a79f06cad285166e5be74745df1ed6845c5624aafadec4b7a29c25866ef", + "sha256:f35248f7e0d63b234a109dd72fbfb4b5cb6cb6840b221d0df0ecbf54ab087654", + "sha256:f502ef245c492b391e0e23e94cba030ab91722dcc56963c85bfd7f3441ea2bbe", + "sha256:fe01bac7226499aedf472c62fa3b85b2c619365f3f14dd222ffe4f3aa91e5f98" ], "index": "pypi", - "version": "==1.3.15" + "version": "==1.3.17" }, "sqlalchemy-utils": { "hashes": [ - "sha256:f268af5bc03597fe7690d60df3e5f1193254a83e07e4686f720f61587ec4493a" + "sha256:7a7fab14bed80df065412bbf71a0a9b0bfeb4b7c111c2d9bffe57283082f3a6b" ], - "version": "==0.36.3" + "version": "==0.36.6" }, "swagger-ui-bundle": { "hashes": [ @@ -462,16 +488,16 @@ }, "urllib3": { "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "version": "==1.25.8" + "version": "==1.25.9" }, "validators": { "hashes": [ - "sha256:b192e6bde7d617811d59f50584ed240b580375648cd032d106edeb3164099508" + "sha256:31e8bb01b48b48940a021b8a9576b840f98fa06b91762ef921d02cb96d38727a" ], - "version": "==0.14.2" + "version": "==0.15.0" }, "webassets": { "hashes": [ @@ -482,17 +508,17 @@ }, "werkzeug": { "hashes": [ - "sha256:169ba8a33788476292d04186ab33b01d6add475033dfc07215e6d219cc077096", - "sha256:6dc65cf9091cf750012f56f2cad759fa9e879f511b5ff8685e456b4e3bf90d16" + "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", + "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], - "version": "==1.0.0" + "version": "==1.0.1" }, "wtforms": { "hashes": [ - "sha256:0cdbac3e7f6878086c334aa25dc5a33869a3954e9d1e015130d65a69309b3b61", - "sha256:e3ee092c827582c50877cdbd49e9ce6d2c5c1f6561f849b3b068c1b8029626f1" + "sha256:6ff8635f4caeed9f38641d48cfe019d0d3896f41910ab04494143fc027866e1b", + "sha256:861a13b3ae521d6700dac3b2771970bd354a63ba7043ecc3a82b5288596a1972" ], - "version": "==2.2.1" + "version": "==2.3.1" }, "wtforms-alchemy": { "hashes": [ @@ -515,5 +541,14 @@ "version": "==3.1.0" } }, - "develop": {} + "develop": { + "pbr": { + "hashes": [ + "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", + "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8" + ], + "index": "pypi", + "version": "==5.4.5" + } + } } diff --git a/app.py b/pb/__init__.py similarity index 96% rename from app.py rename to pb/__init__.py index 1f0023c..8056a2c 100644 --- a/app.py +++ b/pb/__init__.py @@ -11,7 +11,6 @@ from flask_sqlalchemy import SQLAlchemy from flask_marshmallow import Marshmallow from flask_migrate import Migrate from sqlalchemy import func - from wtforms.ext.appengine.db import model_form PROTOCOLS = {} @@ -40,8 +39,7 @@ def get_study_details(studyid): def get_form(id, requirement_code): return - -conn = connexion.App('Protocol Builder', specification_dir='./') +conn = connexion.App('Protocol Builder', specification_dir='pb') app = conn.app @@ -66,7 +64,7 @@ assets.register('app_scss', scss) # Loads all the descriptions from the API so we can display them in the editor. description_map = {} -with open(r'api.yml') as file: +with open(r'pb/api.yml') as file: api_config = yaml.load(file, Loader=yaml.FullLoader) study_detail_properties = api_config['components']['schemas']['StudyDetail']['properties'] for schema in api_config['components']['schemas']: @@ -98,8 +96,8 @@ def site_map(): # ************************** # WEB FORMS # ************************** -from forms import StudyForm, StudyTable, InvestigatorForm, StudyDetailsForm -from models import Study, RequiredDocument, Investigator, StudySchema, RequiredDocumentSchema, InvestigatorSchema, \ +from pb.forms import StudyForm, StudyTable, InvestigatorForm, StudyDetailsForm +from pb.models import Study, RequiredDocument, Investigator, StudySchema, RequiredDocumentSchema, InvestigatorSchema, \ StudyDetails, StudyDetailsSchema diff --git a/api.yml b/pb/api.yml similarity index 99% rename from api.yml rename to pb/api.yml index ac59e64..6893ea9 100644 --- a/api.yml +++ b/pb/api.yml @@ -20,7 +20,7 @@ paths: tags: - CR-Connect summary: A list of all studies related to a given UVA ID - operationId: app.get_user_studies + operationId: pb.get_user_studies description: "By passing in a valid UVA Id (ex: dhf8r) it will return a list of all studies that exist for that user in Protocol Builder" parameters: - in: query @@ -49,7 +49,7 @@ paths: tags: - CR-Connect summary: Required documents - operationId: app.required_docs + operationId: pb.required_docs description: A list of all documents Protocol Builder considers required, given input from the PI parameters: - in: query @@ -72,7 +72,7 @@ paths: tags: - CR-Connect summary: Personnel associated with this study. - operationId: app.investigators + operationId: pb.investigators description: A list of everyone that is associated with the study, including the PI, Study Coordinator, etc... This is currently returned on the "study" endpoint with other information. parameters: - in: query @@ -101,7 +101,7 @@ paths: get: tags: - CR-Connect - operationId: app.get_study_details + operationId: pb.get_study_details summary: Details about a specific protocol. responses: 200: diff --git a/forms.py b/pb/forms.py similarity index 90% rename from forms.py rename to pb/forms.py index 0b8e7f6..ceae06d 100644 --- a/forms.py +++ b/pb/forms.py @@ -1,12 +1,9 @@ -import sys - -from flask_table import Table, Col, DateCol, LinkCol, BoolCol, DatetimeCol, NestedTableCol +from flask_table import Table, Col, LinkCol, BoolCol, DatetimeCol, NestedTableCol from flask_wtf import FlaskForm -from wtforms import SelectMultipleField, SubmitField, StringField, IntegerField, BooleanField, DateField, widgets, \ - SelectField, validators, HiddenField +from wtforms import SelectMultipleField, StringField, BooleanField, SelectField, validators, HiddenField from wtforms_alchemy import ModelForm -from models import RequiredDocument, Investigator, StudyDetails +from pb.models import RequiredDocument, Investigator, StudyDetails class StudyForm(FlaskForm): diff --git a/models.py b/pb/models.py similarity index 99% rename from models.py rename to pb/models.py index 023ea0c..42c2b96 100644 --- a/models.py +++ b/pb/models.py @@ -1,5 +1,5 @@ from sqlalchemy import func -from app import db, ma +from pb import db, ma class Study(db.Model): diff --git a/run.py b/run.py index 6058c0e..9eb95ca 100644 --- a/run.py +++ b/run.py @@ -1,4 +1,4 @@ -from app import app +from pb import app if __name__ == "__main__": flask_port = app.config['FLASK_PORT'] app.run(host='0.0.0.0', port=flask_port) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9e50b6b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[metadata] +name = pb + +[files] +packages = pb diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..159a3d3 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup(setup_requires=["pbr"], pbr=True) diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..aaa9fcd --- /dev/null +++ b/static/app.css @@ -0,0 +1,133 @@ +.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; + width: 100%; + margin-bottom: 40px; + padding: 2em; } + .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: 40px; } + .form-field .form-field-label { + font-weight: bold; } + .form-field .form-field-input input { + width: 100%; } + .form-field .form-field-help { + font-style: italic; } + .form-field .form-field-error { + color: #DF1E43; } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_sanity.py b/tests/test_sanity.py similarity index 95% rename from test_sanity.py rename to tests/test_sanity.py index 2be2710..29e32d0 100644 --- a/test_sanity.py +++ b/tests/test_sanity.py @@ -3,9 +3,9 @@ import os os.environ["TESTING"] = "true" import unittest -from app import app, db -from forms import StudyForm -from models import Study, RequiredDocument +from pb import app, db +from pb.forms import StudyForm +from pb.models import Study, RequiredDocument class Sanity_Check_Test(unittest.TestCase): From c88b6847f2f449ab6d81d5a89fda75547f32e7bb Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 20:52:51 -0400 Subject: [PATCH 07/15] Refactors Dockerfile again. Installs gunicorn. Don't run gunicorn until the container starts up. --- .gitignore | 2 +- Dockerfile | 63 +++++++++++++++++++--------------------------------- Pipfile | 1 + Pipfile.lock | 11 +++++++-- 4 files changed, 34 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 729225f..1a77a3e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ __pycache__/ app.db static/.webassets-cache* -pb/static/*.css +static/*.css diff --git a/Dockerfile b/Dockerfile index 76b9055..841be2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,31 @@ -# -# https://medium.com/@greut/building-a-python-package-a-docker-image-using-pipenv-233d8793b6cc -# https://github.com/greut/pipenv-to-wheel -# -FROM kennethreitz/pipenv as pipenv +FROM python:3.7-slim -ADD . /app WORKDIR /app - -RUN pipenv install --dev \ - && pipenv lock -r > requirements.txt \ - && pipenv run python setup.py bdist_wheel - -# ---------------------------------------------------------------------------- -FROM ubuntu:bionic - -ARG DEBIAN_FRONTEND=noninteractive - -COPY --from=pipenv /app/dist/*.whl . +COPY Pipfile Pipfile.lock /app/ RUN set -xe \ - && apt-get update -q \ - && apt-get install -y -q \ - python3-minimal \ - python3-wheel \ - python3-pip \ - gunicorn3 \ - postgresql-client \ - && python3 -m pip install *.whl \ - && apt-get remove -y python3-pip python3-wheel \ - && apt-get autoremove -y \ - && apt-get clean -y \ - && rm -f *.whl \ - && rm -rf /root/.cache \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /app \ - && useradd _gunicorn --no-create-home --user-group + && pip install pipenv + && apt-get update -q \ + && apt-get install -y -q \ + gcc python3-dev libssl-dev \ + curl postgresql-client git-core \ + gunicorn3 postgresql-client \ + && 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/* \ + && mkdir -p /app \ + && useradd _gunicorn --no-create-home --user-group +COPY . /app/ USER _gunicorn - -COPY ./static /app/static -COPY ./docker_run.sh /app/ -COPY ./wait-for-it.sh /app/ WORKDIR /app +ENV FLASK_APP=/app/pb/__init__.py -CMD ["gunicorn3", \ - "--bind", "0.0.0.0:8000", \ - "pb:app"] +# Don't run gunicorn until the DC/OS container actually starts. +# Otherwise, environment variables will not be availabele. +#CMD ["pipenv", "run", "gunicorn", \ +# "--bind", "0.0.0.0:8000", \ +# "-e", "SCRIPT_NAME=/api", \ +# "crc:app"] diff --git a/Pipfile b/Pipfile index 5f4a440..66b20b6 100644 --- a/Pipfile +++ b/Pipfile @@ -22,6 +22,7 @@ marshmallow-sqlalchemy = "*" wtforms-alchemy = "*" psycopg2-binary = "*" pyscss = "*" +gunicorn = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index c59122e..16083ea 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6d81da4e1f722cf965aeff5e24c86278b5df745f0eadaa2b401d54e7f836c654" + "sha256": "22303ab4362e0b95f21f7c949b2d43a4ec58b42add20699c159d2a7cc3eaf0be" }, "pipfile-spec": 6, "requires": { @@ -200,6 +200,14 @@ "markers": "platform_python_implementation == 'CPython'", "version": "==0.4.15" }, + "gunicorn": { + "hashes": [ + "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", + "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" + ], + "index": "pypi", + "version": "==20.0.4" + }, "idna": { "hashes": [ "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", @@ -217,7 +225,6 @@ }, "infinity": { "hashes": [ - "sha256:91069282767a8695b880feda218948aafc1b89fddddd9b1b156792d9de8f6234", "sha256:dc4aa138d7e366fc00d2e741e32c78a0fecd16b74f8daeb3f7408b459668005c" ], "version": "==1.4" From 0bb53c876f00ee1c5c08a2cdd6ad3034b837ec03 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 20:58:32 -0400 Subject: [PATCH 08/15] Starts gunicorn, passing port and base href path --- docker_run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker_run.sh b/docker_run.sh index 5886817..18ea7a1 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -18,4 +18,4 @@ if [ "$RESET_DB" = "true" ]; then pipenv run flask load-example-data fi -pipenv run python ./run.py +pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 run:app From b25b2ebf07a67c66140e98e285b50f0ccaec7a37 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 21:00:46 -0400 Subject: [PATCH 09/15] Forgot a backslash --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 841be2a..232d98c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app COPY Pipfile Pipfile.lock /app/ RUN set -xe \ - && pip install pipenv + && pip install pipenv \ && apt-get update -q \ && apt-get install -y -q \ gcc python3-dev libssl-dev \ From d54160600eee880f2107e681dcd1989ca1a4c568 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 21:09:45 -0400 Subject: [PATCH 10/15] Runs as root (for now) --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 232d98c..8d2d3b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN set -xe \ && useradd _gunicorn --no-create-home --user-group COPY . /app/ -USER _gunicorn WORKDIR /app ENV FLASK_APP=/app/pb/__init__.py From 07d0c43e8e2a28977ed17ecd0aa4c3e41eb39d9a Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Sun, 24 May 2020 23:05:57 -0400 Subject: [PATCH 11/15] Fixes all the paths and routing errors --- .gitignore | 2 + Dockerfile | 4 +- Pipfile | 1 + Pipfile.lock | 3 +- docker_run.sh | 2 +- pb/__init__.py | 119 ++++++++++++++++++------- {static => pb/static}/favicon.ico | Bin {static => pb/static}/scss/app.scss | 0 static/app.css | 133 ---------------------------- templates/form.html | 6 +- templates/index.html | 6 +- wsgi.py | 20 +++++ 12 files changed, 121 insertions(+), 175 deletions(-) rename {static => pb/static}/favicon.ico (100%) rename {static => pb/static}/scss/app.scss (100%) delete mode 100644 static/app.css create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore index 1a77a3e..8c8bf6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .idea __pycache__/ app.db +pb/static/.webassets-cache* +pb/static/*.css static/.webassets-cache* static/*.css diff --git a/Dockerfile b/Dockerfile index 8d2d3b0..a303842 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,8 @@ WORKDIR /app ENV FLASK_APP=/app/pb/__init__.py # Don't run gunicorn until the DC/OS container actually starts. -# Otherwise, environment variables will not be availabele. +# Otherwise, environment variables will not be available. #CMD ["pipenv", "run", "gunicorn", \ # "--bind", "0.0.0.0:8000", \ # "-e", "SCRIPT_NAME=/api", \ -# "crc:app"] +# "wsgi:app"] diff --git a/Pipfile b/Pipfile index 66b20b6..b9cb12f 100644 --- a/Pipfile +++ b/Pipfile @@ -23,6 +23,7 @@ wtforms-alchemy = "*" psycopg2-binary = "*" pyscss = "*" gunicorn = "*" +werkzeug = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 16083ea..ddfeccf 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "22303ab4362e0b95f21f7c949b2d43a4ec58b42add20699c159d2a7cc3eaf0be" + "sha256": "42147b649c5838de2eba97ebf9360b65d4bdbb8b3ae94ba23c813660a9490ed3" }, "pipfile-spec": 6, "requires": { @@ -518,6 +518,7 @@ "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" ], + "index": "pypi", "version": "==1.0.1" }, "wtforms": { diff --git a/docker_run.sh b/docker_run.sh index 18ea7a1..4239672 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -18,4 +18,4 @@ if [ "$RESET_DB" = "true" ]; then pipenv run flask load-example-data fi -pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 run:app +pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 wsgi:app diff --git a/pb/__init__.py b/pb/__init__.py index 8056a2c..9ba0701 100644 --- a/pb/__init__.py +++ b/pb/__init__.py @@ -39,8 +39,7 @@ def get_study_details(studyid): def get_form(id, requirement_code): return -conn = connexion.App('Protocol Builder', specification_dir='pb') - +conn = connexion.FlaskApp('Protocol Builder', specification_dir='pb') app = conn.app app.config.from_object('config.default') @@ -53,13 +52,43 @@ else: app.config.root_path = app.instance_path app.config.from_pyfile('config.py', silent=True) -conn.add_api('api.yml', base_path='/pb') +conn.add_api('api.yml', base_path='/v2.0') db = SQLAlchemy(app) migrate = Migrate(app, db) ma = Marshmallow(app) + +# Set the path of the static directory +APP_ROOT = os.path.dirname(os.path.abspath(__file__)) +APP_STATIC = os.path.join(APP_ROOT, 'static') +BASE_HREF = app.config['APPLICATION_ROOT'].strip('/') +app.static_folder = APP_STATIC +app.static_url_path = app.config['APPLICATION_ROOT'] + 'static' + +print('app.static_folder', app.static_folder) +print('app.static_url_path', app.static_url_path) + +# remove old static map +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 + +# register new; the same view function is used +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') +scss = Bundle( + 'scss/app.scss', + filters='pyscss', + output='app.css' +) assets.register('app_scss', scss) # Loads all the descriptions from the API so we can display them in the editor. @@ -88,7 +117,7 @@ def site_map(): # Filter out rules we can't navigate to in a browser # and rules that require parameters if "GET" in rule.methods and has_no_empty_params(rule): - url = url_for(rule.endpoint, **(rule.defaults or {})) + url = app.confg['APPLICATION_ROOT'].strip('/') + url_for(rule.endpoint, **(rule.defaults or {})) links.append((url, rule.endpoint)) return json.dumps({"links": links}) @@ -106,25 +135,33 @@ def index(): # display results studies = db.session.query(Study).order_by(Study.DATE_MODIFIED.desc()).all() table = StudyTable(studies) - return render_template('index.html', table=table, APPLICATION_ROOT=app.config['APPLICATION_ROOT']) + return render_template( + 'index.html', + table=table, + base_href=BASE_HREF + ) @app.route('/new_study', methods=['GET', 'POST']) def new_study(): form = StudyForm(request.form) - action = "/new_study" + action = BASE_HREF + "/new_study" title = "New Study" if request.method == 'POST': study = Study() study.study_details = StudyDetails() _update_study(study, form) flash('Study created successfully!') - return redirect('/') + return redirect_home() - return render_template('form.html', form=form, - action=action, - title=title, - description_map=description_map) + return render_template( + 'form.html', + form=form, + action=action, + title=title, + description_map=description_map, + base_href=BASE_HREF + ) @app.route('/study/', methods=['GET', 'POST']) @@ -132,7 +169,7 @@ def edit_study(study_id): study = db.session.query(Study).filter(Study.STUDYID == study_id).first() form = StudyForm(request.form, obj=study) if request.method == 'GET': - action = "/study/" + study_id + action = BASE_HREF + "/study/" + study_id title = "Edit Study #" + study_id if study.requirements: form.requirements.data = list(map(lambda r: r.AUXDOCID, list(study.requirements))) @@ -141,17 +178,21 @@ def edit_study(study_id): if request.method == 'POST': _update_study(study, form) flash('Study updated successfully!') - return redirect('/') - return render_template('form.html', form=form, - action=action, - title=title, - description_map={}) + return redirect_home() + return render_template( + 'form.html', + form=form, + action=action, + title=title, + description_map={}, + base_href=BASE_HREF + ) @app.route('/investigator/', methods=['GET', 'POST']) def new_investigator(study_id): form = InvestigatorForm(request.form) - action = "/investigator/" + study_id + action = BASE_HREF + "/investigator/" + study_id title = "Add Investigator to Study " + study_id if request.method == 'POST': investigator = Investigator(STUDYID=study_id) @@ -160,19 +201,23 @@ def new_investigator(study_id): db.session.add(investigator) db.session.commit() flash('Investigator created successfully!') - return redirect('/') + return redirect_home() - return render_template('form.html', form=form, - action=action, - title=title, - description_map={}) + return render_template( + 'form.html', + form=form, + action=action, + title=title, + description_map={}, + base_href=BASE_HREF + ) @app.route('/del_investigator/', methods=['GET']) def del_investigator(inv_id): db.session.query(Investigator).filter(Investigator.id == inv_id).delete() db.session.commit() - return redirect('/') + return redirect_home() @app.route('/del_study/', methods=['GET']) @@ -182,7 +227,7 @@ def del_study(study_id): db.session.query(StudyDetails).filter(StudyDetails.STUDYID == study_id).delete() db.session.query(Study).filter(Study.STUDYID == study_id).delete() db.session.commit() - return redirect('/') + return redirect_home() def _update_study(study, form): @@ -217,7 +262,7 @@ def study_details(study_id): study_details = StudyDetails(STUDYID=study_id) form = StudyDetailsForm(request.form, obj=study_details) if request.method == 'GET': - action = "/study_details/" + study_id + action = BASE_HREF + "/study_details/" + study_id title = "Edit Study Details for Study #" + study_id details = "Numeric fields can be 1 for true, 0 or false, or Null if not applicable." if request.method == 'POST': @@ -225,12 +270,20 @@ def study_details(study_id): db.session.add(study_details) db.session.commit() flash('Study updated successfully!') - return redirect('/') - return render_template('form.html', form=form, - action=action, - title=title, - details=details, - description_map=description_map) + return redirect_home() + return render_template( + 'form.html', + form=form, + action=action, + title=title, + details=details, + description_map=description_map, + base_href=BASE_HREF + ) + + +def redirect_home(): + return redirect('/' + BASE_HREF) if __name__ == '__main__': diff --git a/static/favicon.ico b/pb/static/favicon.ico similarity index 100% rename from static/favicon.ico rename to pb/static/favicon.ico diff --git a/static/scss/app.scss b/pb/static/scss/app.scss similarity index 100% rename from static/scss/app.scss rename to pb/static/scss/app.scss diff --git a/static/app.css b/static/app.css deleted file mode 100644 index aaa9fcd..0000000 --- a/static/app.css +++ /dev/null @@ -1,133 +0,0 @@ -.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; - width: 100%; - margin-bottom: 40px; - padding: 2em; } - .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: 40px; } - .form-field .form-field-label { - font-weight: bold; } - .form-field .form-field-input input { - width: 100%; } - .form-field .form-field-help { - font-style: italic; } - .form-field .form-field-error { - color: #DF1E43; } diff --git a/templates/form.html b/templates/form.html index d1dff9a..0d0f195 100644 --- a/templates/form.html +++ b/templates/form.html @@ -3,11 +3,13 @@ Protocol Builder Mock Configuration + {% assets 'app_scss' %} - + {% endassets %} +

{{ title }}

@@ -27,7 +29,7 @@ {% endfor %} - Cancel + Cancel diff --git a/templates/index.html b/templates/index.html index 6760d61..a5e2264 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,13 +2,13 @@ Protocol Builder Mock - + {% assets 'app_scss' %} - + {% endassets %} - +

Protocol Builder Mock

diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d0d6780 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,20 @@ +from werkzeug.exceptions import NotFound +from werkzeug.middleware.dispatcher import DispatcherMiddleware +from werkzeug.middleware.proxy_fix import ProxyFix + +from pb import app + +if __name__ == "__main__": + def no_app(environ, start_response): + return NotFound()(environ, start_response) + + + # Remove trailing slash, but add leading slash + base_url = '/' + app.config['APPLICATION_ROOT'].strip('/') + + app.wsgi_app = DispatcherMiddleware(no_app, {app.config['APPLICATION_ROOT']: app.wsgi_app}) + app.wsgi_app = ProxyFix(app.wsgi_app) + + flask_port = app.config['FLASK_PORT'] + + app.run(host='0.0.0.0', port=flask_port) From 319af2c04e067eb33a72d010bb5f564aa4db18b7 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 25 May 2020 00:47:59 -0400 Subject: [PATCH 12/15] Sets path to flask app in docker_run --- Dockerfile | 8 -------- docker_run.sh | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index a303842..8e6885a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,11 +20,3 @@ RUN set -xe \ COPY . /app/ WORKDIR /app -ENV FLASK_APP=/app/pb/__init__.py - -# Don't run gunicorn until the DC/OS container actually starts. -# Otherwise, environment variables will not be available. -#CMD ["pipenv", "run", "gunicorn", \ -# "--bind", "0.0.0.0:8000", \ -# "-e", "SCRIPT_NAME=/api", \ -# "wsgi:app"] diff --git a/docker_run.sh b/docker_run.sh index 4239672..97412b3 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -1,7 +1,7 @@ #!/bin/bash # run migrations -export FLASK_APP=./app.py +export FLASK_APP=/app/pb/__init__.py if [ "$DOWNGRADE_DB" = "true" ]; then echo 'Downgrading database...' From f0e0b7cd683fd7325148e7a2da62519cf9f4f338 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 25 May 2020 11:41:39 -0400 Subject: [PATCH 13/15] Fixes root path bug --- docker_run.sh | 6 +++++- pb/__init__.py | 2 +- templates/form.html | 2 +- tests/test_sanity.py | 17 ++++++++++------- wsgi.py | 7 +++++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/docker_run.sh b/docker_run.sh index 97412b3..499a6a3 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -18,4 +18,8 @@ if [ "$RESET_DB" = "true" ]; then pipenv run flask load-example-data fi -pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 wsgi:app +if [ "$APPLICATION_ROOT" = "/" ]; then + pipenv run gunicorn -e --bind 0.0.0.0:$PORT0 wsgi:app +else + pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 wsgi:app +fi diff --git a/pb/__init__.py b/pb/__init__.py index 9ba0701..6730fa2 100644 --- a/pb/__init__.py +++ b/pb/__init__.py @@ -283,7 +283,7 @@ def study_details(study_id): def redirect_home(): - return redirect('/' + BASE_HREF) + return redirect(url_for('index')) if __name__ == '__main__': diff --git a/templates/form.html b/templates/form.html index 0d0f195..6cab261 100644 --- a/templates/form.html +++ b/templates/form.html @@ -29,7 +29,7 @@ {% endfor %} - Cancel + Cancel diff --git a/tests/test_sanity.py b/tests/test_sanity.py index 29e32d0..dba1e10 100644 --- a/tests/test_sanity.py +++ b/tests/test_sanity.py @@ -3,6 +3,8 @@ import os os.environ["TESTING"] = "true" import unittest +import random +import string from pb import app, db from pb.forms import StudyForm from pb.models import Study, RequiredDocument @@ -32,7 +34,8 @@ class Sanity_Check_Test(unittest.TestCase): def test_add_and_edit_study(self): """Add and edit a study""" - study = Study(TITLE="My Test Document", NETBADGEID="dhf8r") + study_title = "My Test Document" + ''.join(random.choices(string.digits, k=8)) + study = Study(TITLE=study_title, NETBADGEID="dhf8r") form = StudyForm(formdata=None, obj=study) num_reqs = len(form.requirements.choices) self.assertGreater(num_reqs, 0) @@ -40,9 +43,9 @@ class Sanity_Check_Test(unittest.TestCase): for r in form.requirements: form.data['requirements'].append(r.data) - r = self.app.post('/new_study', data=form.data, follow_redirects=True) - assert r.status_code == 200 - added_study = Study.query.filter(Study.TITLE == "My Test Document").first() + r = self.app.post('/new_study', data=form.data, follow_redirects=False) + assert r.status_code == 302 + added_study = Study.query.filter(Study.TITLE == study_title).first() assert added_study num_studies_before = Study.query.count() @@ -50,14 +53,14 @@ class Sanity_Check_Test(unittest.TestCase): self.assertEqual(num_reqs, num_docs_before) """Edit an existing study""" - added_study.title = "New Title" + added_study.title = "New Title" + ''.join(random.choices(string.digits, k=8)) form_2 = StudyForm(formdata=None, obj=added_study) for r in form_2.requirements: form_2.data['requirements'].append(r.data) - r_2 = self.app.post('/study/%i' % added_study.STUDYID, data=form_2.data, follow_redirects=True) - assert r_2.status_code == 200 + r_2 = self.app.post('/study/%i' % added_study.STUDYID, data=form_2.data, follow_redirects=False) + assert r_2.status_code == 302 num_studies_after = Study.query.count() edited_study = Study.query.filter(Study.STUDYID == added_study.STUDYID).first() assert edited_study diff --git a/wsgi.py b/wsgi.py index d0d6780..d662ed8 100644 --- a/wsgi.py +++ b/wsgi.py @@ -8,11 +8,14 @@ if __name__ == "__main__": def no_app(environ, start_response): return NotFound()(environ, start_response) - # Remove trailing slash, but add leading slash base_url = '/' + app.config['APPLICATION_ROOT'].strip('/') + routes = {'/': app.wsgi_app} - app.wsgi_app = DispatcherMiddleware(no_app, {app.config['APPLICATION_ROOT']: app.wsgi_app}) + if base_url != '/': + routes[base_url] = app.wsgi_app + + app.wsgi_app = DispatcherMiddleware(no_app, routes) app.wsgi_app = ProxyFix(app.wsgi_app) flask_port = app.config['FLASK_PORT'] From 0bc881d02ec231f1a3c0760def485dd06b2c8c1f Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 25 May 2020 12:26:07 -0400 Subject: [PATCH 14/15] Ugh. Typo. --- docker_run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker_run.sh b/docker_run.sh index 499a6a3..8d6e338 100755 --- a/docker_run.sh +++ b/docker_run.sh @@ -19,7 +19,7 @@ if [ "$RESET_DB" = "true" ]; then fi if [ "$APPLICATION_ROOT" = "/" ]; then - pipenv run gunicorn -e --bind 0.0.0.0:$PORT0 wsgi:app + pipenv run gunicorn --bind 0.0.0.0:$PORT0 wsgi:app else pipenv run gunicorn -e SCRIPT_NAME="$APPLICATION_ROOT" --bind 0.0.0.0:$PORT0 wsgi:app fi From 6fb5842fe137c8753b027c32b4e5f394cd7f2d05 Mon Sep 17 00:00:00 2001 From: Aaron Louie Date: Mon, 25 May 2020 15:21:29 -0400 Subject: [PATCH 15/15] Adds CORS --- Pipfile | 1 + Pipfile.lock | 16 ++++++++++++---- config/default.py | 3 ++- pb/__init__.py | 14 ++++++++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Pipfile b/Pipfile index b9cb12f..4b98838 100644 --- a/Pipfile +++ b/Pipfile @@ -24,6 +24,7 @@ psycopg2-binary = "*" pyscss = "*" gunicorn = "*" werkzeug = "*" +flask-cors = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index ddfeccf..9398ded 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "42147b649c5838de2eba97ebf9360b65d4bdbb8b3ae94ba23c813660a9490ed3" + "sha256": "4897f5ad1de5dcc7a407c45a670a3e5cf332d56fa138bfe1805441aa18c195cc" }, "pipfile-spec": 6, "requires": { @@ -105,6 +105,14 @@ ], "version": "==1.0.0" }, + "flask-cors": { + "hashes": [ + "sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16", + "sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a" + ], + "index": "pypi", + "version": "==3.0.8" + }, "flask-marshmallow": { "hashes": [ "sha256:6e6aec171b8e092e0eafaf035ff5b8637bf3a58ab46f568c4c1bab02f2a3c196", @@ -123,11 +131,11 @@ }, "flask-sqlalchemy": { "hashes": [ - "sha256:0078d8663330dc05a74bc72b3b6ddc441b9a744e2f56fe60af1a5bfc81334327", - "sha256:6974785d913666587949f7c2946f7001e4fa2cb2d19f4e69ead02e4b8f50b33d" + "sha256:2298f6b874c2a2f1f048eaf21ce5d984e36a04ca849b0ac473050a67c8dae76f", + "sha256:6cd9f71a97ef18ca5ae7d8bd316a32b82814efe7b088096ba68fddfd8a17cbe7" ], "index": "pypi", - "version": "==2.4.1" + "version": "==2.4.2" }, "flask-table": { "hashes": [ diff --git a/config/default.py b/config/default.py index 3c140fe..891a9ba 100644 --- a/config/default.py +++ b/config/default.py @@ -6,7 +6,7 @@ basedir = os.path.abspath(os.path.dirname(__file__)) NAME = "CR Connect Protocol Builder Mock" FLASK_PORT = environ.get('PORT0') or environ.get('FLASK_PORT', default="5001") -CORS_ENABLED = False +CORS_ALLOW_ORIGINS = re.split(r',\s*', environ.get('CORS_ALLOW_ORIGINS', default="localhost:5000")) DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true" TESTING = environ.get('TESTING', default="false") == "true" @@ -26,6 +26,7 @@ SECRET_KEY = environ.get('SECRET_KEY', default='a really really really really lo print('=== USING DEFAULT CONFIG: ===') print('DB_HOST = ', DB_HOST) +print('CORS_ALLOW_ORIGINS = ', CORS_ALLOW_ORIGINS) print('DEVELOPMENT = ', DEVELOPMENT) print('TESTING = ', TESTING) print('APPLICATION_ROOT = ', APPLICATION_ROOT) diff --git a/pb/__init__.py b/pb/__init__.py index 6730fa2..cba7017 100644 --- a/pb/__init__.py +++ b/pb/__init__.py @@ -1,10 +1,11 @@ import datetime import os import re +import yaml from datetime import date import connexion -import yaml +from flask_cors import CORS from flask import url_for, json, redirect, render_template, request, flash from flask_assets import Environment, Bundle from flask_sqlalchemy import SQLAlchemy @@ -39,8 +40,8 @@ def get_study_details(studyid): def get_form(id, requirement_code): return -conn = connexion.FlaskApp('Protocol Builder', specification_dir='pb') -app = conn.app +connexion_app = connexion.FlaskApp('Protocol Builder', specification_dir='pb') +app = connexion_app.app app.config.from_object('config.default') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False @@ -52,7 +53,12 @@ else: app.config.root_path = app.instance_path app.config.from_pyfile('config.py', silent=True) -conn.add_api('api.yml', base_path='/v2.0') +connexion_app.add_api('api.yml', base_path='/v2.0') + +# Convert list of allowed origins to list of regexes +origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config['CORS_ALLOW_ORIGINS']] +cors = CORS(connexion_app.app, origins=origins_re) + db = SQLAlchemy(app) migrate = Migrate(app, db) ma = Marshmallow(app)