Merge pull request #76 from sartography/feature/rrp-endpoints

Feature/rrp endpoints
This commit is contained in:
Dan Funk 2020-05-22 19:38:26 -04:00 committed by GitHub
commit 49c322177b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 560 additions and 246 deletions

View File

@ -1,7 +1,7 @@
language: python
python:
- "3.7"
- "3.6.9"
services:
- postgresql

View File

@ -1,27 +1,24 @@
FROM python:3.7
FROM python:3.6.9-slim
ENV PATH=/root/.local/bin:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
WORKDIR /app
# install node and yarn
RUN apt-get update
RUN apt-get -y install postgresql-client
COPY Pipfile Pipfile.lock /app/
# config project dir
RUN mkdir /crc-workflow
WORKDIR /crc-workflow
RUN pip install pipenv && \
apt-get update && \
apt-get install -y --no-install-recommends \
gcc python3-dev libssl-dev \
curl postgresql-client git-core && \
pipenv install --dev && \
apt-get remove -y gcc python3-dev libssl-dev && \
apt-get purge -y --auto-remove && \
rm -rf /var/lib/apt/lists/ *
# install python requirements
RUN pip install pipenv
ADD Pipfile /crc-workflow/
ADD Pipfile.lock /crc-workflow/
RUN pipenv install --dev
COPY . /app/
# include rejoiner code (gets overriden by local changes)
COPY . /crc-workflow/
# run webserver by default
ENV FLASK_APP=./crc/__init__.py
CMD ["pipenv", "run", "python", "./run.py"]
ENV FLASK_APP=/app/crc/__init__.py
CMD ["pipenv", "run", "flask", "db", "upgrade"]
CMD ["pipenv", "run", "python", "/app/run.py"]
# expose ports
EXPOSE 5000

View File

@ -24,18 +24,17 @@ pyjwt = "*"
requests = "*"
xlsxwriter = "*"
webtest = "*"
spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "bug/the_horror"}
spiffworkflow = {editable = true,git = "https://github.com/sartography/SpiffWorkflow.git",ref = "master"}
alembic = "*"
coverage = "*"
sphinx = "*"
recommonmark = "*"
psycopg2-binary = "*"
docxtpl = "*"
flask-sso = "*"
python-dateutil = "*"
pandas = "*"
xlrd = "*"
ldap3 = "*"
[requires]
python_version = "3.7"
python_version = "3.6.9"

31
Pipfile.lock generated
View File

@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
"sha256": "bd289126c41b0f5f2761f0415d85e1110a584256460374a9ce4cda07c0033ddd"
"sha256": "1ca737db75750ea4351c15b4b0b26155d90bc5522705ed293a0c2773600b6a0a"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.7"
"python_version": "3.6.9"
},
"sources": [
{
@ -96,12 +96,6 @@
],
"version": "==3.6.3.0"
},
"blinker": {
"hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
],
"version": "==1.4"
},
"celery": {
"hashes": [
"sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f",
@ -307,13 +301,6 @@
],
"version": "==2.4.1"
},
"flask-sso": {
"hashes": [
"sha256:541a8a2387c6eac4325c53f8f7f863a03173b37aa558a37a430010d7fc1a3633"
],
"index": "pypi",
"version": "==0.4.0"
},
"future": {
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
@ -711,10 +698,10 @@
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"version": "==1.14.0"
"version": "==1.15.0"
},
"snowballstemmer": {
"hashes": [
@ -783,7 +770,7 @@
"spiffworkflow": {
"editable": true,
"git": "https://github.com/sartography/SpiffWorkflow.git",
"ref": "7dc54f1205de7006bdda6d966dc957e558f3c7f3"
"ref": "cb098ee6d55b85bf7795997f4ad5f78c27d15381"
},
"sqlalchemy": {
"hashes": [
@ -955,10 +942,10 @@
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
"version": "==1.14.0"
"version": "==1.15.0"
},
"wcwidth": {
"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")
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.
PB_ENABLED = environ.get('PB_ENABLED', default=True)
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_INVESTIGATORS_URL = environ.get('PB_INVESTIGATORS_URL', default=PB_BASE_URL + "investigators?studyid=%i")

View File

@ -6,6 +6,7 @@ DEVELOPMENT = True
TESTING = True
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."
PB_ENABLED = False
print('### USING TESTING CONFIG: ###')
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_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_sso import SSO
logging.basicConfig(level=logging.INFO)
@ -31,7 +30,6 @@ session = db.session
migrate = Migrate(app, db)
ma = Marshmallow(app)
sso = SSO(app=app)
from crc import models
from crc import api

View File

@ -110,6 +110,19 @@ paths:
application/json:
schema:
$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}:
parameters:
- name: study_id
@ -156,26 +169,6 @@ paths:
application/json:
schema:
$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:
get:
operationId: crc.api.workflow.all_specifications
@ -798,6 +791,54 @@ paths:
type: array
items:
$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:
securitySchemes:
jwt:
@ -1205,7 +1246,10 @@ components:
readOnly: true
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 sqlalchemy.exc import IntegrityError
from crc import session
from crc.api.common import ApiError, ApiErrorSchema
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.study_service import StudyService
def add_study(body):
"""This should never get called, and is subject to deprication. Studies
should be added through the protocol builder only."""
"""Or any study like object. """
study: Study = StudySchema().load(body)
study_model = StudyModel(**study.model_args())
session.add(study_model)
@ -59,30 +55,17 @@ def delete_study(study_id):
def all_studies():
"""Returns all the studies associated with the current user. Assures we are
in sync with values read in from the protocol builder. """
StudyService.synch_all_studies_with_protocol_builder(g.user)
"""Returns all the studies associated with the current user. """
StudyService.synch_with_protocol_builder_if_enabled(g.user)
studies = StudyService.get_studies_for_user(g.user)
results = StudySchema(many=True).dump(studies)
return results
def post_update_study_from_protocol_builder(study_id):
"""Update a single study based on data received from
the protocol builder."""
db_study = session.query(StudyModel).filter_by(study_id=study_id).all()
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
def all_studies_and_files():
"""Returns all studies with submitted files"""
studies = StudyService.get_studies_with_files()
results = StudyFilesSchema(many=True).dump(studies)
return results

View File

@ -1,12 +1,12 @@
import json
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.models.user import UserModel, UserModelSchema
from crc.services.ldap_service import LdapService, LdapUserInfo
"""
.. module:: crc.api.user
@ -32,53 +32,76 @@ def verify_token(token):
def get_current_user():
return UserModelSchema().dump(g.user)
@app.route('/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
def sso_login(user_info):
app.logger.info("Login from Shibboleth happening. " + json.dump(user_info))
# TODO: Get redirect URL from Shibboleth request header
_handle_login(user_info)
if not uid:
raise ApiError("invalid_sso_credentials", "'Uid' nor 'X-Remote-Uid' were present in the headers: %s"
% str(request.headers))
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,
then returns the frontend auth callback URL, with auth token appended.
Args:
user_info (dict of {
uid: 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]
user_info - an ldap user_info object.
redirect_url: Optional[str]
Returns:
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 == uid).first()
user = db.session.query(UserModel).filter(UserModel.uid == user_info.uid).first()
if user is None:
# Add new user
user = UserModelSchema().load(user_info, session=db.session)
else:
# Update existing user data
user = UserModelSchema().load(user_info, session=db.session, instance=user, partial=True)
user = UserModel()
# Build display_name if not set
if 'display_name' not in user_info or len(user_info['display_name']) == 0:
display_name_list = []
for prop in ['first_name', 'last_name']:
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)
user.uid = user_info.uid
user.display_name = user_info.display_name
user.email_address = user_info.email_address
user.affiliation = user_info.affiliation
user.title = user_info.title
db.session.add(user)
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.
auth_token = user.encode_auth_token().decode()
if redirect_url is not None:
app.logger.info("SSO_LOGIN: REDIRECTING TO: " + redirect_url)
return redirect('%s/%s' % (redirect_url, auth_token))
else:
app.logger.info("SSO_LOGIN: NO REDIRECT, JUST RETURNING AUTH TOKEN.")
return auth_token
def backdoor(
uid=None,
affiliation=None,
@ -122,11 +149,9 @@ def backdoor(
ApiError. If on production, returns a 404 error.
"""
if not 'PRODUCTION' in app.config or not app.config['PRODUCTION']:
user_info = {}
for key in UserModel.__dict__.keys():
if key in connexion.request.args:
user_info[key] = connexion.request.args[key]
return _handle_login(user_info, redirect_url)
ldap_info = LdapUserInfo()
ldap_info.uid = connexion.request.args["uid"]
ldap_info.email_address = connexion.request.args["email_address"]
return _handle_login(ldap_info, redirect_url)
else:
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')
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.dialects.postgresql import UUID
from crc import db
from crc import db, ma
class FileType(enum.Enum):
@ -139,3 +139,9 @@ class LookupDataSchema(SQLAlchemyAutoSchema):
include_relationships = False
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.api.common import ApiErrorSchema
from crc.models.file import FileModel, SimpleFileSchema
from crc.models.protocol_builder import ProtocolBuilderStatus, ProtocolBuilderStudy
from crc.models.workflow import WorkflowSpecCategoryModel, WorkflowState, WorkflowStatus, WorkflowSpecModel, \
WorkflowModel
@ -39,6 +40,10 @@ class StudyModel(db.Model):
if self.on_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):
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):
"""Can load the basic study data for updates to the database, but categories are write only"""
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)
title = db.Column(db.String, nullable=True)
# Add Department and School
def encode_auth_token(self):
"""

View File

@ -73,10 +73,12 @@ class WorkflowModel(db.Model):
bpmn_workflow_json = db.Column(db.JSON)
status = db.Column(db.Enum(WorkflowStatus))
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 = db.relationship("WorkflowSpecModel")
spec_version = db.Column(db.String)
total_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):
def __init__(self, entry):
self.display_name = entry.displayName.value
self.given_name = ", ".join(entry.givenName)
self.email = entry.mail.value
self.telephone_number = ", ".join(entry.telephoneNumber)
self.title = ", ".join(entry.title)
self.department = ", ".join(entry.uvaDisplayDepartment)
self.affiliation = ", ".join(entry.uvaPersonIAMAffiliation)
self.sponsor_type = ", ".join(entry.uvaPersonSponsoredType)
self.uid = entry.uid.value
def __init__(self):
self.display_name = ''
self.given_name = ''
self.email_address = ''
self.telephone_number = ''
self.title = ''
self.department = ''
self.affiliation = ''
self.sponsor_type = ''
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):
search_base = "ou=People,o=University of Virginia,c=US"
@ -50,7 +63,7 @@ class LdapService(object):
if len(self.conn.entries) < 1:
raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid)
entry = self.conn.entries[0]
return(LdapUserInfo(entry))
return LdapUserInfo.from_entry(entry)
def search_users(self, query, limit):
search_string = LdapService.uid_search_string % query
@ -64,6 +77,6 @@ class LdapService(object):
for entry in self.conn.entries:
if count > limit:
break
results.append(LdapUserInfo(entry))
results.append(LdapUserInfo.from_entry(entry))
count += 1
return results

View File

@ -10,6 +10,7 @@ from crc.models.protocol_builder import ProtocolBuilderStudy, ProtocolBuilderStu
class ProtocolBuilderService(object):
ENABLED = app.config['PB_ENABLED']
STUDY_URL = app.config['PB_USER_STUDIES_URL']
INVESTIGATOR_URL = app.config['PB_INVESTIGATORS_URL']
REQUIRED_DOCS_URL = app.config['PB_REQUIRED_DOCS_URL']
@ -17,6 +18,7 @@ class ProtocolBuilderService(object):
@staticmethod
def get_studies(user_id) -> {}:
ProtocolBuilderService.__enabled_or_raise()
if not isinstance(user_id, str):
raise ApiError("invalid_user_id", "This user id is invalid: " + str(user_id))
response = requests.get(ProtocolBuilderService.STUDY_URL % user_id)
@ -30,40 +32,32 @@ class ProtocolBuilderService(object):
@staticmethod
def get_investigators(study_id) -> {}:
ProtocolBuilderService.check_args(study_id)
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))
return ProtocolBuilderService.__make_request(study_id, ProtocolBuilderService.INVESTIGATOR_URL)
@staticmethod
def get_required_docs(study_id) -> Optional[List[ProtocolBuilderRequiredDocument]]:
ProtocolBuilderService.check_args(study_id)
response = requests.get(ProtocolBuilderService.REQUIRED_DOCS_URL % study_id)
return ProtocolBuilderService.__make_request(study_id, ProtocolBuilderService.REQUIRED_DOCS_URL)
@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:
return json.loads(response.text)
else:
raise ApiError("protocol_builder_error",
"Received an invalid response from the protocol builder (status %s): %s" %
(response.status_code, response.text))
"Received an invalid response from the protocol builder (status %s): %s when calling "
"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))
return studies
@staticmethod
def get_studies_with_files():
"""Returns a list of all studies"""
db_studies = session.query(StudyModel).all()
return db_studies
@staticmethod
def get_study(study_id, study_model: StudyModel = None):
"""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
that is available.."""
# Get PB required docs
try:
pb_docs = ProtocolBuilderService.get_required_docs(study_id=study_id)
except requests.exceptions.ConnectionError as ce:
app.logger.error("Failed to connect to the Protocol Builder - %s" % str(ce))
# Get PB required docs, if Protocol Builder Service is enabled.
if ProtocolBuilderService.ENABLED:
try:
pb_docs = ProtocolBuilderService.get_required_docs(study_id=study_id)
except requests.exceptions.ConnectionError as ce:
app.logger.error("Failed to connect to the Protocol Builder - %s" % str(ce))
pb_docs = []
else:
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'])
documents = {}
for code, doc in doc_dictionary.items():
pb_data = next((item for item in pb_docs if int(item['AUXDOCID']) == int(doc['id'])), None)
doc['required'] = False
if pb_data:
doc['required'] = True
if ProtocolBuilderService.ENABLED:
pb_data = next((item for item in pb_docs if int(item['AUXDOCID']) == int(doc['id'])), None)
doc['required'] = False
if pb_data:
doc['required'] = True
doc['study_id'] = study_id
doc['code'] = code
@ -153,7 +165,6 @@ class StudyService(object):
doc['status'] = workflow.status.value
documents[code] = doc
return documents
@ -201,9 +212,13 @@ class StudyService(object):
@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
in sync with the studies available in protocol builder. """
if not ProtocolBuilderService.ENABLED:
return
# Get studies matching this user from Protocol Builder
pb_studies: List[ProtocolBuilderStudy] = ProtocolBuilderService.get_studies(user.uid)

15
crconnect.wsgi Normal file
View File

@ -0,0 +1,15 @@
python_home = '/usr/local/envs/crcpython3'
import os
import sys
# Calculate path to site-packages directory.
python_version = '.'.join(map(str, sys.version_info[:2]))
site_packages = python_home + '/lib/python%s/site-packages' % python_version
# Add the site-packages directory.
site.addsitedir(site_packages)
from crc import app as application

90
deploy/requirements.txt Normal file
View File

@ -0,0 +1,90 @@
alabaster==0.7.12
alembic==1.4.2
amqp==2.5.2
aniso8601==8.0.0
attrs==19.3.0
babel==2.8.0
bcrypt==3.1.7
beautifulsoup4==4.9.1
billiard==3.6.3.0
blinker==1.4
celery==4.4.2
certifi==2020.4.5.1
cffi==1.14.0
chardet==3.0.4
click==7.1.2
clickclick==1.2.2
commonmark==0.9.1
configparser==5.0.0
connexion==2.7.0
coverage==5.1
docutils==0.16
docxtpl==0.9.2
et-xmlfile==1.0.1
flask==1.1.2
flask-bcrypt==0.7.1
flask-cors==3.0.8
flask-marshmallow==0.12.0
flask-migrate==2.5.3
flask-restful==0.3.8
flask-sqlalchemy==2.4.1
flask-sso==0.4.0
future==0.18.2
httpretty==1.0.2
idna==2.9
imagesize==1.2.0
importlib-metadata==1.6.0
inflection==0.4.0
itsdangerous==1.1.0
jdcal==1.4.1
jinja2==2.11.2
jsonschema==3.2.0
kombu==4.6.8
ldap3==2.7
lxml==4.5.1
mako==1.1.2
markupsafe==1.1.1
marshmallow==3.6.0
marshmallow-enum==1.5.1
marshmallow-sqlalchemy==0.23.0
numpy==1.18.4
openapi-spec-validator==0.2.8
openpyxl==3.0.3
packaging==20.4
pandas==1.0.3
psycopg2-binary==2.8.5
pyasn1==0.4.8
pycparser==2.20
pygments==2.6.1
pyjwt==1.7.1
pyparsing==2.4.7
pyrsistent==0.16.0
python-dateutil==2.8.1
python-docx==0.8.10
python-editor==1.0.4
pytz==2020.1
pyyaml==5.3.1
recommonmark==0.6.0
requests==2.23.0
six==1.14.0
snowballstemmer==2.0.0
soupsieve==2.0.1
sphinx==3.0.3
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==1.0.3
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.4
spiffworkflow
sqlalchemy==1.3.17
swagger-ui-bundle==0.0.6
urllib3==1.25.9
vine==1.3.0
waitress==1.4.3
webob==1.8.6
webtest==2.0.35
werkzeug==1.0.1
xlrd==1.2.0
xlsxwriter==1.2.8
zipp==3.1.0

4
deploy/update_requirements.sh Executable file
View File

@ -0,0 +1,4 @@
jq -r '.default
| to_entries[]
| .key + .value.version' \
../Pipfile.lock > requirements.txt

View File

@ -1,7 +1,9 @@
# Set environment variable to testing before loading.
# IMPORTANT - Environment must be loaded before app, models, etc....
import json
import os
os.environ["TESTING"] = "true"
import json
import unittest
import urllib.parse
import datetime
@ -10,10 +12,6 @@ from crc.models.protocol_builder import ProtocolBuilderStatus
from crc.models.study import StudyModel
from crc.services.file_service import FileService
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.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel
from crc.models.user import UserModel
@ -32,6 +30,10 @@ class BaseTest(unittest.TestCase):
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 = {}
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.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';
self.load_example_data()
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(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('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):
self.load_example_data()
rv = self.app.get('/v1.0/user')

View File

@ -21,7 +21,7 @@ class TestLdapService(BaseTest):
self.assertEqual("lb3dp", user_info.uid)
self.assertEqual("Laura Barnes", user_info.display_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("E0:Associate Professor of Systems and Information Engineering", user_info.title)
self.assertEqual("E0:EN-Eng Sys and Environment", user_info.department)

View File

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

View File

@ -1,4 +1,5 @@
import json
from tests.base_test import BaseTest
from datetime import datetime, timezone
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.study import StudyModel, StudySchema
from crc.models.workflow import WorkflowSpecModel, WorkflowModel, WorkflowSpecCategoryModel
from tests.base_test import BaseTest
from crc.services.protocol_builder import ProtocolBuilderService
class TestStudyApi(BaseTest):
@ -38,24 +39,12 @@ class TestStudyApi(BaseTest):
study = session.query(StudyModel).first()
self.assertIsNotNone(study)
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_investigators') # mock_studies
@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):
def test_get_study(self):
"""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."""
# 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)
"""NOTE: The protocol builder is not enabled or mocked out. As the master workflow (which is empty),
and the test workflow do not need it, and it is disabled in the configuration."""
new_study = self.add_test_study()
new_study = session.query(StudyModel).filter_by(id=new_study["id"]).first()
# Add a category
@ -65,7 +54,7 @@ class TestStudyApi(BaseTest):
# Create a workflow specification
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.
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()
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_studies') # mock_studies
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()
s = StudyModel(
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.ind_number, json_data['ind_number'])
def test_delete_study(self):
self.load_example_data()
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.stats import TaskEventModel
from crc.models.workflow import WorkflowStatus
from crc.services.protocol_builder import ProtocolBuilderService
from crc.services.workflow_service import WorkflowService
from tests.base_test import BaseTest
@ -302,6 +303,9 @@ class TestTasksApi(BaseTest):
@patch('crc.services.protocol_builder.requests.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.
mock_get.return_value.ok = True
mock_get.return_value.text = self.protocol_builder_response('investigators.json')

View File

@ -20,21 +20,7 @@ class TestWorkflowSpecValidation(BaseTest):
json_data = json.loads(rv.get_data(as_text=True))
return ApiErrorSchema(many=True).load(json_data)
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_investigators') # mock_studies
@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)
def test_successful_validation_of_test_workflows(self):
self.assertEqual(0, len(self.validate_workflow("parallel_tasks")))
self.assertEqual(0, len(self.validate_workflow("decision_table")))