Merge branch 'rrt/dev' into rrt/testing

This commit is contained in:
Aaron Louie 2020-05-23 15:18:35 -04:00
commit 1d2f074312
32 changed files with 488 additions and 244 deletions

View File

@ -17,7 +17,6 @@ RUN pip install pipenv && \
COPY . /app/ COPY . /app/
ENV FLASK_APP=/app/crc/__init__.py ENV FLASK_APP=/app/crc/__init__.py
CMD ["pipenv", "run", "flask", "db", "upgrade"]
CMD ["pipenv", "run", "python", "/app/run.py"] CMD ["pipenv", "run", "python", "/app/run.py"]
# expose ports # expose ports

View File

@ -31,7 +31,6 @@ sphinx = "*"
recommonmark = "*" recommonmark = "*"
psycopg2-binary = "*" psycopg2-binary = "*"
docxtpl = "*" docxtpl = "*"
flask-sso = "*"
python-dateutil = "*" python-dateutil = "*"
pandas = "*" pandas = "*"
xlrd = "*" xlrd = "*"

31
Pipfile.lock generated
View File

@ -1,11 +1,11 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "26d23456010d3e5a559386d412cef3beacd92d5a4e474f2afdb0737ea0f20f04" "sha256": "1ca737db75750ea4351c15b4b0b26155d90bc5522705ed293a0c2773600b6a0a"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
"python_version": "3.7" "python_version": "3.6.9"
}, },
"sources": [ "sources": [
{ {
@ -96,12 +96,6 @@
], ],
"version": "==3.6.3.0" "version": "==3.6.3.0"
}, },
"blinker": {
"hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
],
"version": "==1.4"
},
"celery": { "celery": {
"hashes": [ "hashes": [
"sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f", "sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f",
@ -307,13 +301,6 @@
], ],
"version": "==2.4.1" "version": "==2.4.1"
}, },
"flask-sso": {
"hashes": [
"sha256:541a8a2387c6eac4325c53f8f7f863a03173b37aa558a37a430010d7fc1a3633"
],
"index": "pypi",
"version": "==0.4.0"
},
"future": { "future": {
"hashes": [ "hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
@ -711,10 +698,10 @@
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"version": "==1.14.0" "version": "==1.15.0"
}, },
"snowballstemmer": { "snowballstemmer": {
"hashes": [ "hashes": [
@ -783,7 +770,7 @@
"spiffworkflow": { "spiffworkflow": {
"editable": true, "editable": true,
"git": "https://github.com/sartography/SpiffWorkflow.git", "git": "https://github.com/sartography/SpiffWorkflow.git",
"ref": "2c9698894f7e526a91bf3ca8c4b9fc9b6b01e807" "ref": "cb098ee6d55b85bf7795997f4ad5f78c27d15381"
}, },
"sqlalchemy": { "sqlalchemy": {
"hashes": [ "hashes": [
@ -955,10 +942,10 @@
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"version": "==1.14.0" "version": "==1.15.0"
}, },
"wcwidth": { "wcwidth": {
"hashes": [ "hashes": [

View File

@ -27,20 +27,8 @@ TOKEN_AUTH_SECRET_KEY = environ.get('TOKEN_AUTH_SECRET_KEY', default="Shhhh!!! T
FRONTEND_AUTH_CALLBACK = environ.get('FRONTEND_AUTH_CALLBACK', default="http://localhost:4200/session") FRONTEND_AUTH_CALLBACK = environ.get('FRONTEND_AUTH_CALLBACK', default="http://localhost:4200/session")
SWAGGER_AUTH_KEY = environ.get('SWAGGER_AUTH_KEY', default="SWAGGER") SWAGGER_AUTH_KEY = environ.get('SWAGGER_AUTH_KEY', default="SWAGGER")
#: Default attribute map for single signon.
SSO_LOGIN_URL = '/login'
SSO_ATTRIBUTE_MAP = {
'eppn': (False, 'eppn'), # dhf8r@virginia.edu
'uid': (True, 'uid'), # dhf8r
'givenName': (False, 'first_name'), # Daniel
'mail': (False, 'email_address'), # dhf8r@Virginia.EDU
'sn': (False, 'last_name'), # Funk
'affiliation': (False, 'affiliation'), # 'staff@virginia.edu;member@virginia.edu'
'displayName': (False, 'display_name'), # Daniel Harold Funk
'title': (False, 'title') # SOFTWARE ENGINEER V
}
# %s/%i placeholders expected for uva_id and study_id in various calls. # %s/%i placeholders expected for uva_id and study_id in various calls.
PB_ENABLED = environ.get('PB_ENABLED', default=True)
PB_BASE_URL = environ.get('PB_BASE_URL', default="http://localhost:5001/pb/") PB_BASE_URL = environ.get('PB_BASE_URL', default="http://localhost:5001/pb/")
PB_USER_STUDIES_URL = environ.get('PB_USER_STUDIES_URL', default=PB_BASE_URL + "user_studies?uva_id=%s") PB_USER_STUDIES_URL = environ.get('PB_USER_STUDIES_URL', default=PB_BASE_URL + "user_studies?uva_id=%s")
PB_INVESTIGATORS_URL = environ.get('PB_INVESTIGATORS_URL', default=PB_BASE_URL + "investigators?studyid=%i") PB_INVESTIGATORS_URL = environ.get('PB_INVESTIGATORS_URL', default=PB_BASE_URL + "investigators?studyid=%i")

View File

@ -6,6 +6,7 @@ DEVELOPMENT = True
TESTING = True TESTING = True
SQLALCHEMY_DATABASE_URI = "postgresql://crc_user:crc_pass@localhost:5432/crc_test" SQLALCHEMY_DATABASE_URI = "postgresql://crc_user:crc_pass@localhost:5432/crc_test"
TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod." TOKEN_AUTH_SECRET_KEY = "Shhhh!!! This is secret! And better darn well not show up in prod."
PB_ENABLED = False
print('### USING TESTING CONFIG: ###') print('### USING TESTING CONFIG: ###')
print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI) print('SQLALCHEMY_DATABASE_URI = ', SQLALCHEMY_DATABASE_URI)

View File

@ -6,7 +6,6 @@ from flask_cors import CORS
from flask_marshmallow import Marshmallow from flask_marshmallow import Marshmallow
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_sso import SSO
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -31,7 +30,6 @@ session = db.session
migrate = Migrate(app, db) migrate = Migrate(app, db)
ma = Marshmallow(app) ma = Marshmallow(app)
sso = SSO(app=app)
from crc import models from crc import models
from crc import api from crc import api

View File

@ -110,6 +110,19 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Study" $ref: "#/components/schemas/Study"
/study-files:
get:
operationId: crc.api.study.all_studies_and_files
summary: Provides a list of studies with submitted files
tags:
- Studies
responses:
'200':
description: An array of studies, with submitted files, ordered by the last modified date.
content:
application/json:
schema:
$ref: "#/components/schemas/Study"
/study/{study_id}: /study/{study_id}:
parameters: parameters:
- name: study_id - name: study_id
@ -156,26 +169,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Study" $ref: "#/components/schemas/Study"
/study-update/{study_id}:
post:
operationId: crc.api.study.post_update_study_from_protocol_builder
summary: If the study is up-to-date with Protocol Builder, returns a 304 Not Modified. If out of date, return a 202 Accepted and study state changes to updating.
tags:
- Study Status
parameters:
- name: study_id
in: path
required: true
description: The id of the study that should be checked for updates.
schema:
type: integer
format: int32
responses:
'304':
description: Study is currently up to date and does not need to be reloaded from Protocol Builder
'202':
description: Request accepted, will preform an update. Study state set to "updating"
/workflow-specification: /workflow-specification:
get: get:
operationId: crc.api.workflow.all_specifications operationId: crc.api.workflow.all_specifications
@ -755,13 +748,6 @@ paths:
schema: schema:
type: string type: string
/render_docx: /render_docx:
parameters:
- name: data
in: query
required: true
description: The json data to use in populating the template
schema:
type: string
put: put:
operationId: crc.api.tools.render_docx operationId: crc.api.tools.render_docx
security: [] # Disable security for this endpoint only. security: [] # Disable security for this endpoint only.
@ -777,6 +763,9 @@ paths:
file: file:
type: string type: string
format: binary format: binary
data:
type: string
format: json
responses: responses:
'200': '200':
description: Returns the generated document. description: Returns the generated document.
@ -802,6 +791,54 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/Script" $ref: "#/components/schemas/Script"
/approval:
parameters:
- name: approver_uid
in: query
required: false
description: Restrict results to a given approver uid, maybe we restrict the use of this at somepoint.
schema:
type: string
get:
operationId: crc.api.approval.get_approvals
summary: Provides a list of workflows approvals
tags:
- Approvals
responses:
'200':
description: An array of approvals
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Approval"
/approval/{approval_id}:
parameters:
- name: approval_id
in: path
required: true
description: The id of the approval in question.
schema:
type: integer
format: int32
put:
operationId: crc.api.approval.update_approval
summary: Updates an approval with the given parameters
tags:
- Approvals
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Approval'
responses:
'200':
description: Study updated successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/Approval"
components: components:
securitySchemes: securitySchemes:
jwt: jwt:
@ -1209,7 +1246,10 @@ components:
readOnly: true readOnly: true
task: task:
$ref: "#/components/schemas/Task" $ref: "#/components/schemas/Task"
Approval:
properties:
id:
type: number
format: integer
example: 5

10
crc/api/approval.py Normal file
View File

@ -0,0 +1,10 @@
from crc.models.approval import ApprovalModel, Approval
def get_approvals(approver_uid = None):
approval_model = ApprovalModel()
approval = Approval.from_model(approval_model)
return {}
def update_approval(approval_id):
return {}

View File

@ -1,20 +1,16 @@
from typing import List
from connexion import NoContent
from flask import g from flask import g
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from crc import session from crc import session
from crc.api.common import ApiError, ApiErrorSchema from crc.api.common import ApiError, ApiErrorSchema
from crc.models.protocol_builder import ProtocolBuilderStatus, ProtocolBuilderStudy from crc.models.protocol_builder import ProtocolBuilderStatus, ProtocolBuilderStudy
from crc.models.study import StudySchema, StudyModel, Study from crc.models.study import StudySchema, StudyFilesSchema, StudyModel, Study
from crc.services.protocol_builder import ProtocolBuilderService from crc.services.protocol_builder import ProtocolBuilderService
from crc.services.study_service import StudyService from crc.services.study_service import StudyService
def add_study(body): def add_study(body):
"""This should never get called, and is subject to deprication. Studies """Or any study like object. """
should be added through the protocol builder only."""
study: Study = StudySchema().load(body) study: Study = StudySchema().load(body)
study_model = StudyModel(**study.model_args()) study_model = StudyModel(**study.model_args())
session.add(study_model) session.add(study_model)
@ -59,30 +55,17 @@ def delete_study(study_id):
def all_studies(): def all_studies():
"""Returns all the studies associated with the current user. Assures we are """Returns all the studies associated with the current user. """
in sync with values read in from the protocol builder. """ StudyService.synch_with_protocol_builder_if_enabled(g.user)
StudyService.synch_all_studies_with_protocol_builder(g.user)
studies = StudyService.get_studies_for_user(g.user) studies = StudyService.get_studies_for_user(g.user)
results = StudySchema(many=True).dump(studies) results = StudySchema(many=True).dump(studies)
return results return results
def post_update_study_from_protocol_builder(study_id): def all_studies_and_files():
"""Update a single study based on data received from """Returns all studies with submitted files"""
the protocol builder.""" studies = StudyService.get_studies_with_files()
results = StudyFilesSchema(many=True).dump(studies)
db_study = session.query(StudyModel).filter_by(study_id=study_id).all() return results
pb_studies: List[ProtocolBuilderStudy] = ProtocolBuilderService.get_studies(g.user.uid)
pb_study = next((pbs for pbs in pb_studies if pbs.STUDYID == study_id), None)
if pb_study:
db_study.update_from_protocol_builder(pb_study)
else:
db_study.inactive = True
db_study.protocol_builder_status = ProtocolBuilderStatus.ABANDONED
return NoContent, 304

View File

@ -25,13 +25,14 @@ def render_markdown(data, template):
raise ApiError(code="invalid", message=str(e)) raise ApiError(code="invalid", message=str(e))
def render_docx(data): def render_docx():
""" """
Provides a quick way to verify that a Jinja docx template will work properly on a given json Provides a quick way to verify that a Jinja docx template will work properly on a given json
data structure. Useful for folks that are building these templates. data structure. Useful for folks that are building these templates.
""" """
try: try:
file = connexion.request.files['file'] file = connexion.request.files['file']
data = connexion.request.form['data']
target_stream = CompleteTemplate().make_template(file, json.loads(data)) target_stream = CompleteTemplate().make_template(file, json.loads(data))
return send_file( return send_file(
io.BytesIO(target_stream.read()), io.BytesIO(target_stream.read()),

View File

@ -1,12 +1,12 @@
import json import json
import connexion import connexion
from flask import redirect, g from flask import redirect, g, request
from crc import sso, app, db from crc import app, db
from crc.api.common import ApiError from crc.api.common import ApiError
from crc.models.user import UserModel, UserModelSchema from crc.models.user import UserModel, UserModelSchema
from crc.services.ldap_service import LdapService, LdapUserInfo
""" """
.. module:: crc.api.user .. module:: crc.api.user
@ -32,53 +32,76 @@ def verify_token(token):
def get_current_user(): def get_current_user():
return UserModelSchema().dump(g.user) return UserModelSchema().dump(g.user)
@app.route('/v1.0/login')
def sso_login():
# This what I see coming back:
# X-Remote-Cn: Daniel Harold Funk (dhf8r)
# X-Remote-Sn: Funk
# X-Remote-Givenname: Daniel
# X-Remote-Uid: dhf8r
# Eppn: dhf8r@virginia.edu
# Cn: Daniel Harold Funk (dhf8r)
# Sn: Funk
# Givenname: Daniel
# Uid: dhf8r
# X-Remote-User: dhf8r@virginia.edu
# X-Forwarded-For: 128.143.0.10
# X-Forwarded-Host: dev.crconnect.uvadcos.io
# X-Forwarded-Server: dev.crconnect.uvadcos.io
# Connection: Keep-Alive
uid = request.headers.get("Uid")
if not uid:
uid = request.headers.get("X-Remote-Uid")
@sso.login_handler if not uid:
def sso_login(user_info): raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s"
app.logger.info("Login from Shibboleth happening. " + json.dump(user_info)) % str(request.headers))
# TODO: Get redirect URL from Shibboleth request header
_handle_login(user_info) redirect = request.args.get('redirect')
app.logger.info("SSO_LOGIN: Full URL: " + request.url)
app.logger.info("SSO_LOGIN: User Id: " + uid)
app.logger.info("SSO_LOGIN: Will try to redirect to : " + str(redirect))
ldap_service = LdapService()
info = ldap_service.user_info(uid)
return _handle_login(info, redirect)
@app.route('/sso')
def sso():
response = ""
response += "<h1>Headers</h1>"
response += "<ul>"
for k,v in request.headers:
response += "<li><b>%s</b> %s</li>\n" % (k, v)
response += "<h1>Environment</h1>"
for k,v in request.environ:
response += "<li><b>%s</b> %s</li>\n" % (k, v)
return response
def _handle_login(user_info, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']): def _handle_login(user_info: LdapUserInfo, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']):
"""On successful login, adds user to database if the user is not already in the system, """On successful login, adds user to database if the user is not already in the system,
then returns the frontend auth callback URL, with auth token appended. then returns the frontend auth callback URL, with auth token appended.
Args: Args:
user_info (dict of { user_info - an ldap user_info object.
uid: str, redirect_url: Optional[str]
affiliation: Optional[str],
display_name: Optional[str],
email_address: Optional[str],
eppn: Optional[str],
first_name: Optional[str],
last_name: Optional[str],
title: Optional[str],
}): Dictionary of user attributes
redirect_url: Optional[str]
Returns: Returns:
Response. 302 - Redirects to the frontend auth callback URL, with auth token appended. Response. 302 - Redirects to the frontend auth callback URL, with auth token appended.
""" """
uid = user_info['uid'] user = db.session.query(UserModel).filter(UserModel.uid == user_info.uid).first()
user = db.session.query(UserModel).filter(UserModel.uid == uid).first()
if user is None: if user is None:
# Add new user # Add new user
user = UserModelSchema().load(user_info, session=db.session) user = UserModel()
else:
# Update existing user data
user = UserModelSchema().load(user_info, session=db.session, instance=user, partial=True)
# Build display_name if not set user.uid = user_info.uid
if 'display_name' not in user_info or len(user_info['display_name']) == 0: user.display_name = user_info.display_name
display_name_list = [] user.email_address = user_info.email_address
user.affiliation = user_info.affiliation
for prop in ['first_name', 'last_name']: user.title = user_info.title
if prop in user_info and len(user_info[prop]) > 0:
display_name_list.append(user_info[prop])
user.display_name = ' '.join(display_name_list)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -86,10 +109,14 @@ def _handle_login(user_info, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']):
# Return the frontend auth callback URL, with auth token appended. # Return the frontend auth callback URL, with auth token appended.
auth_token = user.encode_auth_token().decode() auth_token = user.encode_auth_token().decode()
if redirect_url is not None: if redirect_url is not None:
app.logger.info("SSO_LOGIN: REDIRECTING TO: " + redirect_url)
return redirect('%s/%s' % (redirect_url, auth_token)) return redirect('%s/%s' % (redirect_url, auth_token))
else: else:
app.logger.info("SSO_LOGIN: NO REDIRECT, JUST RETURNING AUTH TOKEN.")
return auth_token return auth_token
def backdoor( def backdoor(
uid=None, uid=None,
affiliation=None, affiliation=None,
@ -122,11 +149,9 @@ def backdoor(
ApiError. If on production, returns a 404 error. ApiError. If on production, returns a 404 error.
""" """
if not 'PRODUCTION' in app.config or not app.config['PRODUCTION']: if not 'PRODUCTION' in app.config or not app.config['PRODUCTION']:
user_info = {} ldap_info = LdapUserInfo()
for key in UserModel.__dict__.keys(): ldap_info.uid = connexion.request.args["uid"]
if key in connexion.request.args: ldap_info.email_address = connexion.request.args["email_address"]
user_info[key] = connexion.request.args[key] return _handle_login(ldap_info, redirect_url)
return _handle_login(user_info, redirect_url)
else: else:
raise ApiError('404', 'unknown') raise ApiError('404', 'unknown')

77
crc/models/approval.py Normal file
View File

@ -0,0 +1,77 @@
import enum
from marshmallow import INCLUDE
from crc import db, ma
from crc.models.study import StudyModel
from crc.models.workflow import WorkflowModel
class ApprovalStatus(enum.Enum):
WAITING = "WAITING" # no one has done jack.
APPROVED = "APPROVED" # approved by the reviewer
DECLINED = "DECLINED" # rejected by the reviewer
CANCELED = "CANCELED" # The document was replaced with a new version and this review is no longer needed.
class ApprovalModel(db.Model):
__tablename__ = 'approval'
id = db.Column(db.Integer, primary_key=True)
study_id = db.Column(db.Integer, db.ForeignKey(StudyModel.id), nullable=False)
study = db.relationship(StudyModel, backref='approval', cascade='all,delete')
workflow_id = db.Column(db.Integer, db.ForeignKey(WorkflowModel.id), nullable=False)
workflow_version = db.Column(db.String)
approver_uid = db.Column(db.String) # Not linked to user model, as they may not have logged in yet.
status = db.Column(db.String)
message = db.Column(db.String)
class Approval(object):
@classmethod
def from_model(cls, model: ApprovalModel):
instance = cls()
instance.id = model.id
instance.workflow_version = model.workflow_version
instance.approver_uid = model.approver_uid
instance.status = model.status
instance.study_id = model.study_id
if model.study:
instance.title = model.study.title
class ApprovalSchema(ma.Schema):
class Meta:
model = Approval
fields = ["id", "workflow_version", "approver_uid", "status",
"study_id", "title"]
unknown = INCLUDE
# Carlos: Here is the data structure I was trying to imagine.
# If I were to continue down my current traing of thought, I'd create
# another class called just "Approval" that can take an ApprovalModel from the
# database and construct a data structure like this one, that can
# be provided to the API at an /approvals endpoint with GET and PUT
# dat = { "approvals": [
# {"id": 1,
# "study_id": 20,
# "workflow_id": 454,
# "study_title": "Dan Funk (dhf8r)", # Really it's just the name of the Principal Investigator
# "workflow_version": "21",
# "approver": { # Pulled from ldap
# "uid": "bgb22",
# "display_name": "Billy Bob (bgb22)",
# "title": "E42:He's a hoopy frood",
# "department": "E0:EN-Eng Study of Parallel Universes",
# },
# "files": [
# {
# "id": 124,
# "name": "ResearchRestart.docx",
# "content_type": "docx-something-whatever"
# }
# ]
# }
# ...
# ]

View File

@ -6,7 +6,7 @@ from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
from sqlalchemy import func, Index from sqlalchemy import func, Index
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from crc import db from crc import db, ma
class FileType(enum.Enum): class FileType(enum.Enum):
@ -139,3 +139,9 @@ class LookupDataSchema(SQLAlchemyAutoSchema):
include_relationships = False include_relationships = False
include_fk = False # Includes foreign keys include_fk = False # Includes foreign keys
class SimpleFileSchema(ma.Schema):
class Meta:
model = FileModel
fields = ["name"]

View File

@ -5,6 +5,7 @@ from sqlalchemy import func
from crc import db, ma from crc import db, ma
from crc.api.common import ApiErrorSchema from crc.api.common import ApiErrorSchema
from crc.models.file import FileModel, SimpleFileSchema
from crc.models.protocol_builder import ProtocolBuilderStatus, ProtocolBuilderStudy from crc.models.protocol_builder import ProtocolBuilderStatus, ProtocolBuilderStudy
from crc.models.workflow import WorkflowSpecCategoryModel, WorkflowState, WorkflowStatus, WorkflowSpecModel, \ from crc.models.workflow import WorkflowSpecCategoryModel, WorkflowState, WorkflowStatus, WorkflowSpecModel, \
WorkflowModel WorkflowModel
@ -39,6 +40,10 @@ class StudyModel(db.Model):
if self.on_hold: if self.on_hold:
self.protocol_builder_status = ProtocolBuilderStatus.HOLD self.protocol_builder_status = ProtocolBuilderStatus.HOLD
def files(self):
_files = FileModel.query.filter_by(workflow_id=self.workflow[0].id)
return _files
class WorkflowMetadata(object): class WorkflowMetadata(object):
def __init__(self, id, name, display_name, description, spec_version, category_id, state: WorkflowState, status: WorkflowStatus, def __init__(self, id, name, display_name, description, spec_version, category_id, state: WorkflowState, status: WorkflowStatus,
@ -154,3 +159,16 @@ class StudySchema(ma.Schema):
def make_study(self, data, **kwargs): def make_study(self, data, **kwargs):
"""Can load the basic study data for updates to the database, but categories are write only""" """Can load the basic study data for updates to the database, but categories are write only"""
return Study(**data) return Study(**data)
class StudyFilesSchema(ma.Schema):
# files = fields.List(fields.Nested(SimpleFileSchema), dump_only=True)
files = fields.Method('_files')
class Meta:
model = Study
additional = ["id", "title", "last_updated", "primary_investigator_id"]
def _files(self, obj):
return [file.name for file in obj.files()]

View File

@ -19,6 +19,8 @@ class UserModel(db.Model):
last_name = db.Column(db.String, nullable=True) last_name = db.Column(db.String, nullable=True)
title = db.Column(db.String, nullable=True) title = db.Column(db.String, nullable=True)
# Add Department and School
def encode_auth_token(self): def encode_auth_token(self):
""" """

View File

@ -73,10 +73,12 @@ class WorkflowModel(db.Model):
bpmn_workflow_json = db.Column(db.JSON) bpmn_workflow_json = db.Column(db.JSON)
status = db.Column(db.Enum(WorkflowStatus)) status = db.Column(db.Enum(WorkflowStatus))
study_id = db.Column(db.Integer, db.ForeignKey('study.id')) study_id = db.Column(db.Integer, db.ForeignKey('study.id'))
study = db.relationship("StudyModel", backref='workflow')
workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id')) workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id'))
workflow_spec = db.relationship("WorkflowSpecModel") workflow_spec = db.relationship("WorkflowSpecModel")
spec_version = db.Column(db.String) spec_version = db.Column(db.String)
total_tasks = db.Column(db.Integer, default=0) total_tasks = db.Column(db.Integer, default=0)
completed_tasks = db.Column(db.Integer, default=0) completed_tasks = db.Column(db.Integer, default=0)
# task_history = db.Column(db.ARRAY(db.String), default=[]) # The history stack of user completed tasks. last_updated = db.Column(db.DateTime)
last_updated = db.Column(db.DateTime) # todo: Add a version that represents the files associated with this workflow
# version = "32"

View File

@ -8,17 +8,30 @@ from crc.api.common import ApiError
class LdapUserInfo(object): class LdapUserInfo(object):
def __init__(self, entry): def __init__(self):
self.display_name = entry.displayName.value self.display_name = ''
self.given_name = ", ".join(entry.givenName) self.given_name = ''
self.email = entry.mail.value self.email_address = ''
self.telephone_number = ", ".join(entry.telephoneNumber) self.telephone_number = ''
self.title = ", ".join(entry.title) self.title = ''
self.department = ", ".join(entry.uvaDisplayDepartment) self.department = ''
self.affiliation = ", ".join(entry.uvaPersonIAMAffiliation) self.affiliation = ''
self.sponsor_type = ", ".join(entry.uvaPersonSponsoredType) self.sponsor_type = ''
self.uid = entry.uid.value self.uid = ''
@classmethod
def from_entry(cls, entry):
instance = cls()
instance.display_name = entry.displayName.value
instance.given_name = ", ".join(entry.givenName)
instance.email_address = entry.mail.value
instance.telephone_number = ", ".join(entry.telephoneNumber)
instance.title = ", ".join(entry.title)
instance.department = ", ".join(entry.uvaDisplayDepartment)
instance.affiliation = ", ".join(entry.uvaPersonIAMAffiliation)
instance.sponsor_type = ", ".join(entry.uvaPersonSponsoredType)
instance.uid = entry.uid.value
return instance
class LdapService(object): class LdapService(object):
search_base = "ou=People,o=University of Virginia,c=US" search_base = "ou=People,o=University of Virginia,c=US"
@ -50,7 +63,7 @@ class LdapService(object):
if len(self.conn.entries) < 1: if len(self.conn.entries) < 1:
raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid) raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid)
entry = self.conn.entries[0] entry = self.conn.entries[0]
return(LdapUserInfo(entry)) return LdapUserInfo.from_entry(entry)
def search_users(self, query, limit): def search_users(self, query, limit):
search_string = LdapService.uid_search_string % query search_string = LdapService.uid_search_string % query
@ -64,6 +77,6 @@ class LdapService(object):
for entry in self.conn.entries: for entry in self.conn.entries:
if count > limit: if count > limit:
break break
results.append(LdapUserInfo(entry)) results.append(LdapUserInfo.from_entry(entry))
count += 1 count += 1
return results return results

View File

@ -10,6 +10,7 @@ from crc.models.protocol_builder import ProtocolBuilderStudy, ProtocolBuilderStu
class ProtocolBuilderService(object): class ProtocolBuilderService(object):
ENABLED = app.config['PB_ENABLED']
STUDY_URL = app.config['PB_USER_STUDIES_URL'] STUDY_URL = app.config['PB_USER_STUDIES_URL']
INVESTIGATOR_URL = app.config['PB_INVESTIGATORS_URL'] INVESTIGATOR_URL = app.config['PB_INVESTIGATORS_URL']
REQUIRED_DOCS_URL = app.config['PB_REQUIRED_DOCS_URL'] REQUIRED_DOCS_URL = app.config['PB_REQUIRED_DOCS_URL']
@ -17,6 +18,7 @@ class ProtocolBuilderService(object):
@staticmethod @staticmethod
def get_studies(user_id) -> {}: def get_studies(user_id) -> {}:
ProtocolBuilderService.__enabled_or_raise()
if not isinstance(user_id, str): if not isinstance(user_id, str):
raise ApiError("invalid_user_id", "This user id is invalid: " + str(user_id)) raise ApiError("invalid_user_id", "This user id is invalid: " + str(user_id))
response = requests.get(ProtocolBuilderService.STUDY_URL % user_id) response = requests.get(ProtocolBuilderService.STUDY_URL % user_id)
@ -30,40 +32,32 @@ class ProtocolBuilderService(object):
@staticmethod @staticmethod
def get_investigators(study_id) -> {}: def get_investigators(study_id) -> {}:
ProtocolBuilderService.check_args(study_id) return ProtocolBuilderService.__make_request(study_id, ProtocolBuilderService.INVESTIGATOR_URL)
response = requests.get(ProtocolBuilderService.INVESTIGATOR_URL % study_id)
if response.ok and response.text:
pb_studies = json.loads(response.text)
return pb_studies
else:
raise ApiError("protocol_builder_error",
"Received an invalid response from the protocol builder (status %s): %s" %
(response.status_code, response.text))
@staticmethod @staticmethod
def get_required_docs(study_id) -> Optional[List[ProtocolBuilderRequiredDocument]]: def get_required_docs(study_id) -> Optional[List[ProtocolBuilderRequiredDocument]]:
ProtocolBuilderService.check_args(study_id) return ProtocolBuilderService.__make_request(study_id, ProtocolBuilderService.REQUIRED_DOCS_URL)
response = requests.get(ProtocolBuilderService.REQUIRED_DOCS_URL % study_id)
@staticmethod
def get_study_details(study_id) -> {}:
return ProtocolBuilderService.__make_request(study_id, ProtocolBuilderService.STUDY_DETAILS_URL)
@staticmethod
def __enabled_or_raise():
if not ProtocolBuilderService.ENABLED:
raise ApiError("protocol_builder_disabled", "The Protocol Builder Service is currently disabled.")
@staticmethod
def __make_request(study_id, url):
ProtocolBuilderService.__enabled_or_raise()
if not isinstance(study_id, int):
raise ApiError("invalid_study_id", "This study id is invalid: " + str(study_id))
response = requests.get(url % study_id)
if response.ok and response.text: if response.ok and response.text:
return json.loads(response.text) return json.loads(response.text)
else: else:
raise ApiError("protocol_builder_error", raise ApiError("protocol_builder_error",
"Received an invalid response from the protocol builder (status %s): %s" % "Received an invalid response from the protocol builder (status %s): %s when calling "
(response.status_code, response.text)) "url '%s'." %
(response.status_code, response.text, url))
@staticmethod
def get_study_details(study_id) -> {}:
ProtocolBuilderService.check_args(study_id)
response = requests.get(ProtocolBuilderService.STUDY_DETAILS_URL % study_id)
if response.ok and response.text:
pb_study_details = json.loads(response.text)
return pb_study_details
else:
raise ApiError("protocol_builder_error",
"Received an invalid response from the protocol builder (status %s): %s" %
(response.status_code, response.text))
@staticmethod
def check_args(study_id):
if not isinstance(study_id, int):
raise ApiError("invalid_study_id", "This study id is invalid: " + str(study_id))

View File

@ -32,6 +32,12 @@ class StudyService(object):
studies.append(StudyService.get_study(study_model.id, study_model)) studies.append(StudyService.get_study(study_model.id, study_model))
return studies return studies
@staticmethod
def get_studies_with_files():
"""Returns a list of all studies"""
db_studies = session.query(StudyModel).all()
return db_studies
@staticmethod @staticmethod
def get_study(study_id, study_model: StudyModel = None): def get_study(study_id, study_model: StudyModel = None):
"""Returns a study model that contains all the workflows organized by category. """Returns a study model that contains all the workflows organized by category.
@ -110,23 +116,29 @@ class StudyService(object):
"""Returns a list of documents related to the study, and any file information """Returns a list of documents related to the study, and any file information
that is available..""" that is available.."""
# Get PB required docs # Get PB required docs, if Protocol Builder Service is enabled.
try: if ProtocolBuilderService.ENABLED:
pb_docs = ProtocolBuilderService.get_required_docs(study_id=study_id) try:
except requests.exceptions.ConnectionError as ce: pb_docs = ProtocolBuilderService.get_required_docs(study_id=study_id)
app.logger.error("Failed to connect to the Protocol Builder - %s" % str(ce)) except requests.exceptions.ConnectionError as ce:
app.logger.error("Failed to connect to the Protocol Builder - %s" % str(ce))
pb_docs = []
else:
pb_docs = [] pb_docs = []
# Loop through all known document types, get the counts for those files, and use pb_docs to mark those required. # Loop through all known document types, get the counts for those files,
# and use pb_docs to mark those as required.
doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id'])
documents = {} documents = {}
for code, doc in doc_dictionary.items(): for code, doc in doc_dictionary.items():
pb_data = next((item for item in pb_docs if int(item['AUXDOCID']) == int(doc['id'])), None) if ProtocolBuilderService.ENABLED:
doc['required'] = False pb_data = next((item for item in pb_docs if int(item['AUXDOCID']) == int(doc['id'])), None)
if pb_data: doc['required'] = False
doc['required'] = True if pb_data:
doc['required'] = True
doc['study_id'] = study_id doc['study_id'] = study_id
doc['code'] = code doc['code'] = code
@ -153,7 +165,6 @@ class StudyService(object):
doc['status'] = workflow.status.value doc['status'] = workflow.status.value
documents[code] = doc documents[code] = doc
return documents return documents
@ -201,9 +212,13 @@ class StudyService(object):
@staticmethod @staticmethod
def synch_all_studies_with_protocol_builder(user): def synch_with_protocol_builder_if_enabled(user):
"""Assures that the studies we have locally for the given user are """Assures that the studies we have locally for the given user are
in sync with the studies available in protocol builder. """ in sync with the studies available in protocol builder. """
if not ProtocolBuilderService.ENABLED:
return
# Get studies matching this user from Protocol Builder # Get studies matching this user from Protocol Builder
pb_studies: List[ProtocolBuilderStudy] = ProtocolBuilderService.get_studies(user.uid) pb_studies: List[ProtocolBuilderStudy] = ProtocolBuilderService.get_studies(user.uid)

View File

@ -3,17 +3,15 @@
# run migrations # run migrations
export FLASK_APP=./crc/__init__.py export FLASK_APP=./crc/__init__.py
for entry in ./instance/* ; do
echo "$entry"
cat $entry
done
if [ "$DOWNGRADE_DB" = "true" ]; then if [ "$DOWNGRADE_DB" = "true" ]; then
echo 'Downgrading...' echo 'Downgrading database...'
pipenv run flask db downgrade pipenv run flask db downgrade
fi fi
pipenv run flask db upgrade if [ "$UPGRADE_DB" = "true" ]; then
echo 'Upgrading database...'
pipenv run flask db upgrade
fi
if [ "$RESET_DB" = "true" ]; then if [ "$RESET_DB" = "true" ]; then
echo 'Resetting database...' echo 'Resetting database...'

View File

@ -14,7 +14,8 @@ class ExampleDataLoader:
session.flush() # Clear out any transactions before deleting it all to avoid spurious errors. session.flush() # Clear out any transactions before deleting it all to avoid spurious errors.
for table in reversed(db.metadata.sorted_tables): for table in reversed(db.metadata.sorted_tables):
session.execute(table.delete()) session.execute(table.delete())
session.flush() session.commit()
session.flush()
def load_all(self): def load_all(self):

View File

@ -0,0 +1,39 @@
"""empty message
Revision ID: 55c6cd407d89
Revises: cc4bccc5e5a8
Create Date: 2020-05-22 22:02:46.650170
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '55c6cd407d89'
down_revision = 'cc4bccc5e5a8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('approval',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('study_id', sa.Integer(), nullable=False),
sa.Column('workflow_id', sa.Integer(), nullable=False),
sa.Column('workflow_version', sa.String(), nullable=True),
sa.Column('approver_uid', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('message', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['study_id'], ['study.id'], ),
sa.ForeignKeyConstraint(['workflow_id'], ['workflow.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('approval')
# ### end Alembic commands ###

View File

@ -1,7 +1,9 @@
# Set environment variable to testing before loading. # Set environment variable to testing before loading.
# IMPORTANT - Environment must be loaded before app, models, etc.... # IMPORTANT - Environment must be loaded before app, models, etc....
import json
import os import os
os.environ["TESTING"] = "true"
import json
import unittest import unittest
import urllib.parse import urllib.parse
import datetime import datetime
@ -10,10 +12,6 @@ from crc.models.protocol_builder import ProtocolBuilderStatus
from crc.models.study import StudyModel from crc.models.study import StudyModel
from crc.services.file_service import FileService from crc.services.file_service import FileService
from crc.services.study_service import StudyService from crc.services.study_service import StudyService
from crc.services.workflow_processor import WorkflowProcessor
os.environ["TESTING"] = "true"
from crc.models.file import FileModel, FileDataModel, CONTENT_TYPES from crc.models.file import FileModel, FileDataModel, CONTENT_TYPES
from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel
from crc.models.user import UserModel from crc.models.user import UserModel
@ -32,6 +30,10 @@ class BaseTest(unittest.TestCase):
efficiently when we have a database in place. efficiently when we have a database in place.
""" """
if not app.config['TESTING']:
raise (Exception("INVALID TEST CONFIGURATION. This is almost always in import order issue."
"The first class to import in each test should be the base_test.py file."))
auths = {} auths = {}
test_uid = "dhf8r" test_uid = "dhf8r"

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_1v1rp1q" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.7.3">
<bpmn:process id="empty_workflow" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>SequenceFlow_0lvudp8</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="SequenceFlow_0lvudp8" sourceRef="StartEvent_1" targetRef="EndEvent_0q4qzl9" />
<bpmn:endEvent id="EndEvent_0q4qzl9">
<bpmn:incoming>SequenceFlow_0lvudp8</bpmn:incoming>
</bpmn:endEvent>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="empty_workflow">
<bpmndi:BPMNEdge id="SequenceFlow_0lvudp8_di" bpmnElement="SequenceFlow_0lvudp8">
<di:waypoint x="215" y="117" />
<di:waypoint x="432" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="EndEvent_0q4qzl9_di" bpmnElement="EndEvent_0q4qzl9">
<dc:Bounds x="432" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

14
tests/test_approvals.py Normal file
View File

@ -0,0 +1,14 @@
from tests.base_test import BaseTest
class TestApprovals(BaseTest):
def test_list_approvals(self):
rv = self.app.get('/v1.0/approval', headers=self.logged_in_headers())
self.assert_success(rv)
def test_update_approval(self):
rv = self.app.put('/v1.0/approval/1',
headers=self.logged_in_headers(),
data={})
self.assert_success(rv)

View File

@ -12,7 +12,7 @@ class TestAuthentication(BaseTest):
self.assertTrue(isinstance(auth_token, bytes)) self.assertTrue(isinstance(auth_token, bytes))
self.assertEqual("dhf8r", user.decode_auth_token(auth_token).get("sub")) self.assertEqual("dhf8r", user.decode_auth_token(auth_token).get("sub"))
def test_auth_creates_user(self): def test_backdoor_auth_creates_user(self):
new_uid = 'czn1z'; new_uid = 'czn1z';
self.load_example_data() self.load_example_data()
user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first() user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first()
@ -37,6 +37,23 @@ class TestAuthentication(BaseTest):
self.assertTrue(rv_2.status_code == 302) self.assertTrue(rv_2.status_code == 302)
self.assertTrue(str.startswith(rv_2.location, redirect_url)) self.assertTrue(str.startswith(rv_2.location, redirect_url))
def test_normal_auth_creates_user(self):
new_uid = 'lb3dp' # This user is in the test ldap system.
self.load_example_data()
user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first()
self.assertIsNone(user)
redirect_url = 'http://worlds.best.website/admin'
headers = dict(Uid=new_uid)
rv = self.app.get('v1.0/login', follow_redirects=False, headers=headers)
self.assert_success(rv)
user = db.session.query(UserModel).filter(UserModel.uid == new_uid).first()
self.assertIsNotNone(user)
self.assertEquals(new_uid, user.uid)
self.assertEquals("Laura Barnes", user.display_name)
self.assertEquals("lb3dp@virginia.edu", user.email_address)
self.assertEquals("E0:Associate Professor of Systems and Information Engineering", user.title)
def test_current_user_status(self): def test_current_user_status(self):
self.load_example_data() self.load_example_data()
rv = self.app.get('/v1.0/user') rv = self.app.get('/v1.0/user')

View File

@ -21,7 +21,7 @@ class TestLdapService(BaseTest):
self.assertEqual("lb3dp", user_info.uid) self.assertEqual("lb3dp", user_info.uid)
self.assertEqual("Laura Barnes", user_info.display_name) self.assertEqual("Laura Barnes", user_info.display_name)
self.assertEqual("Laura", user_info.given_name) self.assertEqual("Laura", user_info.given_name)
self.assertEqual("lb3dp@virginia.edu", user_info.email) self.assertEqual("lb3dp@virginia.edu", user_info.email_address)
self.assertEqual("+1 (434) 924-1723", user_info.telephone_number) self.assertEqual("+1 (434) 924-1723", user_info.telephone_number)
self.assertEqual("E0:Associate Professor of Systems and Information Engineering", user_info.title) self.assertEqual("E0:Associate Professor of Systems and Information Engineering", user_info.title)
self.assertEqual("E0:EN-Eng Sys and Environment", user_info.department) self.assertEqual("E0:EN-Eng Sys and Environment", user_info.department)

View File

@ -1,7 +1,7 @@
from unittest.mock import patch from unittest.mock import patch
from crc.services.protocol_builder import ProtocolBuilderService
from tests.base_test import BaseTest from tests.base_test import BaseTest
from crc.services.protocol_builder import ProtocolBuilderService
class TestProtocolBuilder(BaseTest): class TestProtocolBuilder(BaseTest):
@ -10,6 +10,7 @@ class TestProtocolBuilder(BaseTest):
@patch('crc.services.protocol_builder.requests.get') @patch('crc.services.protocol_builder.requests.get')
def test_get_studies(self, mock_get): def test_get_studies(self, mock_get):
ProtocolBuilderService.ENABLED = True
mock_get.return_value.ok = True mock_get.return_value.ok = True
mock_get.return_value.text = self.protocol_builder_response('user_studies.json') mock_get.return_value.text = self.protocol_builder_response('user_studies.json')
response = ProtocolBuilderService.get_studies(self.test_uid) response = ProtocolBuilderService.get_studies(self.test_uid)
@ -17,6 +18,7 @@ class TestProtocolBuilder(BaseTest):
@patch('crc.services.protocol_builder.requests.get') @patch('crc.services.protocol_builder.requests.get')
def test_get_investigators(self, mock_get): def test_get_investigators(self, mock_get):
ProtocolBuilderService.ENABLED = True
mock_get.return_value.ok = True mock_get.return_value.ok = True
mock_get.return_value.text = self.protocol_builder_response('investigators.json') mock_get.return_value.text = self.protocol_builder_response('investigators.json')
response = ProtocolBuilderService.get_investigators(self.test_study_id) response = ProtocolBuilderService.get_investigators(self.test_study_id)
@ -28,6 +30,7 @@ class TestProtocolBuilder(BaseTest):
@patch('crc.services.protocol_builder.requests.get') @patch('crc.services.protocol_builder.requests.get')
def test_get_required_docs(self, mock_get): def test_get_required_docs(self, mock_get):
ProtocolBuilderService.ENABLED = True
mock_get.return_value.ok = True mock_get.return_value.ok = True
mock_get.return_value.text = self.protocol_builder_response('required_docs.json') mock_get.return_value.text = self.protocol_builder_response('required_docs.json')
response = ProtocolBuilderService.get_required_docs(self.test_study_id) response = ProtocolBuilderService.get_required_docs(self.test_study_id)
@ -37,6 +40,7 @@ class TestProtocolBuilder(BaseTest):
@patch('crc.services.protocol_builder.requests.get') @patch('crc.services.protocol_builder.requests.get')
def test_get_details(self, mock_get): def test_get_details(self, mock_get):
ProtocolBuilderService.ENABLED = True
mock_get.return_value.ok = True mock_get.return_value.ok = True
mock_get.return_value.text = self.protocol_builder_response('study_details.json') mock_get.return_value.text = self.protocol_builder_response('study_details.json')
response = ProtocolBuilderService.get_study_details(self.test_study_id) response = ProtocolBuilderService.get_study_details(self.test_study_id)

View File

@ -1,4 +1,5 @@
import json import json
from tests.base_test import BaseTest
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
@ -8,7 +9,7 @@ from crc.models.protocol_builder import ProtocolBuilderStatus, \
from crc.models.stats import TaskEventModel from crc.models.stats import TaskEventModel
from crc.models.study import StudyModel, StudySchema from crc.models.study import StudyModel, StudySchema
from crc.models.workflow import WorkflowSpecModel, WorkflowModel, WorkflowSpecCategoryModel from crc.models.workflow import WorkflowSpecModel, WorkflowModel, WorkflowSpecCategoryModel
from tests.base_test import BaseTest from crc.services.protocol_builder import ProtocolBuilderService
class TestStudyApi(BaseTest): class TestStudyApi(BaseTest):
@ -38,24 +39,12 @@ class TestStudyApi(BaseTest):
study = session.query(StudyModel).first() study = session.query(StudyModel).first()
self.assertIsNotNone(study) self.assertIsNotNone(study)
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_investigators') # mock_studies def test_get_study(self):
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_required_docs') # mock_docs
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_study_details') # mock_details
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_studies') # mock_studies
def test_get_study(self, mock_studies, mock_details, mock_docs, mock_investigators):
"""Generic test, but pretty detailed, in that the study should return a categorized list of workflows """Generic test, but pretty detailed, in that the study should return a categorized list of workflows
This starts with out loading the example data, to show that all the bases are covered from ground 0.""" This starts with out loading the example data, to show that all the bases are covered from ground 0."""
# Mock Protocol Builder responses """NOTE: The protocol builder is not enabled or mocked out. As the master workflow (which is empty),
studies_response = self.protocol_builder_response('user_studies.json') and the test workflow do not need it, and it is disabled in the configuration."""
mock_studies.return_value = ProtocolBuilderStudySchema(many=True).loads(studies_response)
details_response = self.protocol_builder_response('study_details.json')
mock_details.return_value = json.loads(details_response)
docs_response = self.protocol_builder_response('required_docs.json')
mock_docs.return_value = json.loads(docs_response)
investigators_response = self.protocol_builder_response('investigators.json')
mock_investigators.return_value = json.loads(investigators_response)
new_study = self.add_test_study() new_study = self.add_test_study()
new_study = session.query(StudyModel).filter_by(id=new_study["id"]).first() new_study = session.query(StudyModel).filter_by(id=new_study["id"]).first()
# Add a category # Add a category
@ -65,7 +54,7 @@ class TestStudyApi(BaseTest):
# Create a workflow specification # Create a workflow specification
self.create_workflow("random_fact", study=new_study, category_id=new_category.id) self.create_workflow("random_fact", study=new_study, category_id=new_category.id)
# Assure there is a master specification, and it has the lookup files it needs. # Assure there is a master specification, and it has the lookup files it needs.
spec = self.load_test_spec("top_level_workflow", master_spec=True) spec = self.load_test_spec("empty_workflow", master_spec=True)
self.create_reference_document() self.create_reference_document()
api_response = self.app.get('/v1.0/study/%i' % new_study.id, api_response = self.app.get('/v1.0/study/%i' % new_study.id,
@ -126,6 +115,9 @@ class TestStudyApi(BaseTest):
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_study_details') # mock_details @patch('crc.services.protocol_builder.ProtocolBuilderService.get_study_details') # mock_details
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_studies') # mock_studies @patch('crc.services.protocol_builder.ProtocolBuilderService.get_studies') # mock_studies
def test_get_all_studies(self, mock_studies, mock_details, mock_docs, mock_investigators): def test_get_all_studies(self, mock_studies, mock_details, mock_docs, mock_investigators):
# Enable the protocol builder for these tests, as the master_workflow and other workflows
# depend on using the PB for data.
ProtocolBuilderService.ENABLED = True
self.load_example_data() self.load_example_data()
s = StudyModel( s = StudyModel(
id=54321, # This matches one of the ids from the study_details_json data. id=54321, # This matches one of the ids from the study_details_json data.
@ -208,6 +200,7 @@ class TestStudyApi(BaseTest):
self.assertEqual(study.sponsor, json_data['sponsor']) self.assertEqual(study.sponsor, json_data['sponsor'])
self.assertEqual(study.ind_number, json_data['ind_number']) self.assertEqual(study.ind_number, json_data['ind_number'])
def test_delete_study(self): def test_delete_study(self):
self.load_example_data() self.load_example_data()
study = session.query(StudyModel).first() study = session.query(StudyModel).first()

View File

@ -8,6 +8,7 @@ from crc.models.api_models import WorkflowApiSchema, MultiInstanceType, TaskSche
from crc.models.file import FileModelSchema from crc.models.file import FileModelSchema
from crc.models.stats import TaskEventModel from crc.models.stats import TaskEventModel
from crc.models.workflow import WorkflowStatus from crc.models.workflow import WorkflowStatus
from crc.services.protocol_builder import ProtocolBuilderService
from crc.services.workflow_service import WorkflowService from crc.services.workflow_service import WorkflowService
from tests.base_test import BaseTest from tests.base_test import BaseTest
@ -302,6 +303,9 @@ class TestTasksApi(BaseTest):
@patch('crc.services.protocol_builder.requests.get') @patch('crc.services.protocol_builder.requests.get')
def test_multi_instance_task(self, mock_get): def test_multi_instance_task(self, mock_get):
# Enable the protocol builder.
ProtocolBuilderService.ENABLED = True
# This depends on getting a list of investigators back from the protocol builder. # This depends on getting a list of investigators back from the protocol builder.
mock_get.return_value.ok = True mock_get.return_value.ok = True
mock_get.return_value.text = self.protocol_builder_response('investigators.json') mock_get.return_value.text = self.protocol_builder_response('investigators.json')

View File

@ -1,8 +1,8 @@
import json import json
import os import os
from crc import app
from tests.base_test import BaseTest from tests.base_test import BaseTest
from crc import app
class TestStudyApi(BaseTest): class TestStudyApi(BaseTest):
@ -22,11 +22,13 @@ class TestStudyApi(BaseTest):
{"option": "Address", "selected": False}, {"option": "Address", "selected": False},
{"option": "Phone", "selected": True, "stored": ["Send or Transmit outside of UVA"]}]} {"option": "Phone", "selected": True, "stored": ["Send or Transmit outside of UVA"]}]}
with open(filepath, 'rb') as f: with open(filepath, 'rb') as f:
file_data = {'file': (f, 'my_new_file.bpmn')} file_data = {'file': (f, 'my_new_file.bpmn'), 'data': json.dumps(template_data)}
rv = self.app.put('/v1.0/render_docx?data=%s' % json.dumps(template_data), rv = self.app.put('/v1.0/render_docx',
data=file_data, follow_redirects=True, data=file_data, follow_redirects=True,
content_type='multipart/form-data') content_type='multipart/form-data')
self.assert_success(rv) self.assert_success(rv)
self.assertIsNotNone(rv.data)
self.assertEquals('application/octet-stream', rv.content_type)
def test_list_scripts(self): def test_list_scripts(self):
rv = self.app.get('/v1.0/list_scripts') rv = self.app.get('/v1.0/list_scripts')

View File

@ -20,21 +20,7 @@ class TestWorkflowSpecValidation(BaseTest):
json_data = json.loads(rv.get_data(as_text=True)) json_data = json.loads(rv.get_data(as_text=True))
return ApiErrorSchema(many=True).load(json_data) return ApiErrorSchema(many=True).load(json_data)
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_investigators') # mock_studies def test_successful_validation_of_test_workflows(self):
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_required_docs') # mock_docs
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_study_details') # mock_details
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_studies') # mock_studies
def test_successful_validation_of_test_workflows(self, mock_studies, mock_details, mock_docs, mock_investigators):
# Mock Protocol Builder responses
studies_response = self.protocol_builder_response('user_studies.json')
mock_studies.return_value = ProtocolBuilderStudySchema(many=True).loads(studies_response)
details_response = self.protocol_builder_response('study_details.json')
mock_details.return_value = json.loads(details_response)
docs_response = self.protocol_builder_response('required_docs.json')
mock_docs.return_value = json.loads(docs_response)
investigators_response = self.protocol_builder_response('investigators.json')
mock_investigators.return_value = json.loads(investigators_response)
self.assertEqual(0, len(self.validate_workflow("parallel_tasks"))) self.assertEqual(0, len(self.validate_workflow("parallel_tasks")))
self.assertEqual(0, len(self.validate_workflow("decision_table"))) self.assertEqual(0, len(self.validate_workflow("decision_table")))