Merge branch 'rrt/dev' into rrt/production

This commit is contained in:
Dan Funk 2020-06-03 07:59:05 -04:00
commit 00a1902c71
22 changed files with 463 additions and 214 deletions

View File

@ -38,6 +38,7 @@ xlrd = "*"
ldap3 = "*"
gunicorn = "*"
werkzeug = "*"
sentry-sdk = {extras = ["flask"],version = "==0.14.4"}
[requires]
python_version = "3.7"

49
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "979f996148ee181e3e0af2a3777aa1d00d0fd5d943d49df65963e694b8a88871"
"sha256": "54d9d51360f54762138a3acc7696badd1d711e7b1dde9e2d82aa706e40c17102"
},
"pipfile-spec": 6,
"requires": {
@ -32,10 +32,10 @@
},
"amqp": {
"hashes": [
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
"sha256:24dbaff8ce4f30566bb88976b398e8c4e77637171af3af6f1b9650f48890e60b",
"sha256:bb68f8d2bced8f93ccfd07d96c689b716b3227720add971be980accfc2952139"
],
"version": "==2.5.2"
"version": "==2.6.0"
},
"aniso8601": {
"hashes": [
@ -96,12 +96,18 @@
],
"version": "==3.6.3.0"
},
"blinker": {
"hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
],
"version": "==1.4"
},
"celery": {
"hashes": [
"sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f",
"sha256:5b4b37e276033fe47575107a2775469f0b721646a08c96ec2c61531e4fe45f2a"
"sha256:5147662e23dc6bc39c17a2cbc9a148debe08ecfb128b0eded14a0d9c81fc5742",
"sha256:df2937b7536a2a9b18024776a3a46fd281721813636c03a5177fa02fe66078f6"
],
"version": "==4.4.2"
"version": "==4.4.3"
},
"certifi": {
"hashes": [
@ -381,10 +387,10 @@
},
"kombu": {
"hashes": [
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
"sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505",
"sha256:aece08f48706743aaa1b9d607fee300559481eafcc5ee56451aa0ef867a3be07"
],
"version": "==4.6.8"
"version": "==4.6.9"
},
"ldap3": {
"hashes": [
@ -704,6 +710,17 @@
"index": "pypi",
"version": "==2.23.0"
},
"sentry-sdk": {
"extras": [
"flask"
],
"hashes": [
"sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c",
"sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c"
],
"index": "pypi",
"version": "==0.14.4"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
@ -838,10 +855,10 @@
},
"waitress": {
"hashes": [
"sha256:045b3efc3d97c93362173ab1dfc159b52cfa22b46c3334ffc805dbdbf0e4309e",
"sha256:77ff3f3226931a1d7d8624c5371de07c8e90c7e5d80c5cc660d72659aaf23f38"
"sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261",
"sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"
],
"version": "==1.4.3"
"version": "==1.4.4"
},
"webob": {
"hashes": [
@ -966,10 +983,10 @@
},
"wcwidth": {
"hashes": [
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
"sha256:3de2e41158cb650b91f9654cbf9a3e053cee0719c9df4ddc11e4b568669e9829",
"sha256:b651b6b081476420e4e9ae61239ac4c1b49d0c5ace42b2e81dc2ff49ed50c566"
],
"version": "==0.1.9"
"version": "==0.2.2"
},
"zipp": {
"hashes": [

View File

@ -13,6 +13,9 @@ DEVELOPMENT = environ.get('DEVELOPMENT', default="true") == "true"
TESTING = environ.get('TESTING', default="false") == "true"
PRODUCTION = (environ.get('PRODUCTION', default="false") == "true") or (not DEVELOPMENT and not TESTING)
# Sentry flag
ENABLE_SENTRY = environ.get('ENABLE_SENTRY', default="false") == "true"
# Add trailing slash to base path
APPLICATION_ROOT = re.sub(r'//', '/', '/%s/' % environ.get('APPLICATION_ROOT', default="/").strip('/'))

View File

@ -1,11 +1,13 @@
import logging
import os
import sentry_sdk
import connexion
from flask_cors import CORS
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sentry_sdk.integrations.flask import FlaskIntegration
logging.basicConfig(level=logging.INFO)
@ -40,6 +42,12 @@ connexion_app.add_api('api.yml', base_path='/v1.0')
origins_re = [r"^https?:\/\/%s(.*)" % o.replace('.', '\.') for o in app.config['CORS_ALLOW_ORIGINS']]
cors = CORS(connexion_app.app, origins=origins_re)
if app.config['ENABLE_SENTRY']:
sentry_sdk.init(
dsn="https://25342ca4e2d443c6a5c49707d68e9f40@o401361.ingest.sentry.io/5260915",
integrations=[FlaskIntegration()]
)
print('=== USING THESE CONFIG SETTINGS: ===')
print('DB_HOST = ', )
print('CORS_ALLOW_ORIGINS = ', app.config['CORS_ALLOW_ORIGINS'])

View File

@ -808,12 +808,12 @@ paths:
$ref: "#/components/schemas/Script"
/approval:
parameters:
- name: approver_uid
- name: everything
in: query
required: false
description: Restrict results to a given approver uid, maybe we restrict the use of this at somepoint.
description: If set to true, returns all the approvals known to the system.
schema:
type: string
type: boolean
get:
operationId: crc.api.approval.get_approvals
summary: Provides a list of workflows approvals
@ -854,6 +854,19 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Approval"
/approval/csv:
get:
operationId: crc.api.approval.get_csv
summary: Provides a list of all users for all approved studies
tags:
- Approvals
responses:
'200':
description: An array of approvals
content:
application/json:
schema:
type: object
components:
securitySchemes:
jwt:

View File

@ -1,17 +1,30 @@
import json
import pickle
from base64 import b64decode
from datetime import datetime
from SpiffWorkflow import Workflow
from SpiffWorkflow.serializer.dict import DictionarySerializer
from SpiffWorkflow.serializer.json import JSONSerializer
from flask import g
from crc import app, db, session
from crc.api.common import ApiError, ApiErrorSchema
from crc.models.approval import Approval, ApprovalModel, ApprovalSchema
from crc.models.approval import Approval, ApprovalModel, ApprovalSchema, ApprovalStatus
from crc.models.ldap import LdapSchema
from crc.models.study import Study
from crc.models.workflow import WorkflowModel
from crc.services.approval_service import ApprovalService
from crc.services.ldap_service import LdapService
from crc.services.workflow_processor import WorkflowProcessor
def get_approvals(approver_uid=None):
if not approver_uid:
db_approvals = ApprovalService.get_all_approvals()
def get_approvals(everything=False):
if everything:
approvals = ApprovalService.get_all_approvals()
else:
db_approvals = ApprovalService.get_approvals_per_user(approver_uid)
approvals = [Approval.from_model(approval_model) for approval_model in db_approvals]
approvals = ApprovalService.get_approvals_per_user(g.user.uid)
results = ApprovalSchema(many=True).dump(approvals)
return results
@ -23,6 +36,63 @@ def get_approvals_for_study(study_id=None):
return results
# ----- Being decent into madness ---- #
def get_csv():
"""A huge bit of a one-off for RRT, but 3 weeks of midnight work can convince a
man to do just about anything"""
approvals = ApprovalService.get_all_approvals(ignore_cancelled=True)
output = []
errors = []
ldapService = LdapService()
for approval in approvals:
try:
if approval.status != ApprovalStatus.APPROVED.value:
continue
for related_approval in approval.related_approvals:
if related_approval.status != ApprovalStatus.APPROVED.value:
continue
workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == approval.workflow_id).first()
data = json.loads(workflow.bpmn_workflow_json)
last_task = find_task(data['last_task']['__uuid__'], data['task_tree'])
personnel = extract_value(last_task, 'personnel')
training_val = extract_value(last_task, 'RequiredTraining')
review_complete = 'AllRequiredTraining' in training_val
pi_uid = workflow.study.primary_investigator_id
pi_details = ldapService.user_info(pi_uid)
details = []
details.append(pi_details)
for person in personnel:
uid = person['PersonnelComputingID']['value']
details.append(ldapService.user_info(uid))
for person in details:
output.append({
"study_id": approval.study_id,
"pi_uid": pi_details.uid,
"pi": pi_details.display_name,
"name": person.display_name,
"email": person.email_address,
"review_complete": review_complete,
})
except Exception as e:
errors.append("Error pulling data for workflow #%i: %s" % (approval.workflow_id, str(e)))
return {"results": output, "errors": errors }
def extract_value(task, key):
if key in task['data']:
return pickle.loads(b64decode(task['data'][key]['__bytes__']))
else:
return ""
def find_task(uuid, task):
if task['id']['__uuid__'] == uuid:
return task
for child in task['children']:
task = find_task(uuid, child)
if task:
return task
# ----- come back to the world of the living ---- #
def update_approval(approval_id, body):
if approval_id is None:
raise ApiError('unknown_approval', 'Please provide a valid Approval ID.')
@ -31,9 +101,14 @@ def update_approval(approval_id, body):
if approval_model is None:
raise ApiError('unknown_approval', 'The approval "' + str(approval_id) + '" is not recognized.')
approval: Approval = ApprovalSchema().load(body)
approval.update_model(approval_model)
if approval_model.approver_uid != g.user.uid:
raise ApiError("not_your_approval", "You may not modify this approval. It belongs to another user.")
approval_model.status = body['status']
approval_model.message = body['message']
approval_model.date_approved = datetime.now()
session.add(approval_model)
session.commit()
result = ApprovalSchema().dump(approval)
result = ApprovalSchema().dump(approval_model)
return result

View File

@ -1,13 +1,10 @@
import json
import connexion
import flask
from flask import redirect, g, request
from flask import g, request
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
from crc.services.ldap_service import LdapService, LdapModel
"""
.. module:: crc.api.user
@ -81,7 +78,7 @@ def sso():
return response
def _handle_login(user_info: LdapUserInfo, redirect_url=app.config['FRONTEND_AUTH_CALLBACK']):
def _handle_login(user_info: LdapModel, 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.

View File

@ -1,17 +1,17 @@
import enum
import marshmallow
from ldap3.core.exceptions import LDAPSocketOpenError
from marshmallow import INCLUDE
from marshmallow import INCLUDE, fields
from sqlalchemy import func
from crc import db, ma
from crc import db, ma, app
from crc.api.common import ApiError
from crc.models.file import FileDataModel
from crc.models.ldap import LdapSchema
from crc.models.study import StudyModel
from crc.models.workflow import WorkflowModel
from crc.services.ldap_service import LdapService
from crc.services.file_service import FileService
from crc.services.ldap_service import LdapService
class ApprovalStatus(enum.Enum):
@ -33,13 +33,14 @@ 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')
study = db.relationship(StudyModel)
workflow_id = db.Column(db.Integer, db.ForeignKey(WorkflowModel.id), nullable=False)
workflow = db.relationship(WorkflowModel)
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, default='')
date_created = db.Column(db.DateTime(timezone=True), default=func.now())
date_approved = db.Column(db.DateTime(timezone=True), default=None)
version = db.Column(db.Integer) # Incremented integer, so 1,2,3 as requests are made.
approval_files = db.relationship(ApprovalFile, back_populates="approval",
cascade="all, delete, delete-orphan",
@ -63,31 +64,22 @@ class Approval(object):
instance.status = model.status
instance.message = model.message
instance.date_created = model.date_created
instance.date_approved = model.date_approved
instance.version = model.version
instance.title = ''
instance.related_approvals = []
if model.study:
instance.title = model.study.title
principal_investigator_id = model.study.primary_investigator_id
instance.approver = {}
ldap_service = LdapService()
try:
ldap_service = LdapService()
user_info = ldap_service.user_info(principal_investigator_id)
except (ApiError, LDAPSocketOpenError) as exception:
user_info = None
instance.approver['display_name'] = 'Primary Investigator details'
instance.approver['department'] = 'currently not available'
instance.approver = ldap_service.user_info(model.approver_uid)
instance.primary_investigator = ldap_service.user_info(model.study.primary_investigator_id)
except ApiError as ae:
app.logger.error("Ldap lookup failed for approval record %i" % model.id)
if user_info:
# TODO: Rename approver to primary investigator
instance.approver['uid'] = model.approver_uid
instance.approver['display_name'] = user_info.display_name
instance.approver['title'] = user_info.title
instance.approver['department'] = user_info.department
# TODO: Organize it properly, move it to services
doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id'])
instance.associated_files = []
for approval_file in model.approval_files:
try:
@ -98,12 +90,13 @@ class Approval(object):
associated_file['id'] = approval_file.file_data.file_model.id
if extra_info:
irb_doc_code = approval_file.file_data.file_model.irb_doc_code
associated_file['name'] = '_'.join((irb_doc_code, approval_file.file_data.file_model.name))
associated_file['name'] = '_'.join((extra_info['category1'],
approval_file.file_data.file_model.name))
associated_file['description'] = extra_info['description']
else:
associated_file['name'] = approval_file.file_data.file_model.name
associated_file['description'] = 'No description available'
associated_file['name'] = '(' + principal_investigator_id + ')' + associated_file['name']
associated_file['name'] = '(' + model.study.primary_investigator_id + ')' + associated_file['name']
associated_file['content_type'] = approval_file.file_data.file_model.content_type
instance.associated_files.append(associated_file)
@ -115,10 +108,17 @@ class Approval(object):
class ApprovalSchema(ma.Schema):
approver = fields.Nested(LdapSchema, dump_only=True)
primary_investigator = fields.Nested(LdapSchema, dump_only=True)
related_approvals = fields.List(fields.Nested('ApprovalSchema', allow_none=True, dump_only=True))
class Meta:
model = Approval
fields = ["id", "study_id", "workflow_id", "version", "title",
"version", "status", "message", "approver", "associated_files"]
"status", "message", "approver", "primary_investigator",
"associated_files", "date_created", "date_approved",
"related_approvals"]
unknown = INCLUDE
@marshmallow.post_load
@ -126,30 +126,4 @@ class ApprovalSchema(ma.Schema):
"""Loads the basic approval data for updates to the database"""
return Approval(**data)
# 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"
# }
# ]
# }
# ...
# ]

39
crc/models/ldap.py Normal file
View File

@ -0,0 +1,39 @@
from flask_marshmallow.sqla import SQLAlchemyAutoSchema
from marshmallow import EXCLUDE
from sqlalchemy import func, inspect
from crc import db
class LdapModel(db.Model):
uid = db.Column(db.String, primary_key=True)
display_name = db.Column(db.String)
given_name = db.Column(db.String)
email_address = db.Column(db.String)
telephone_number = db.Column(db.String)
title = db.Column(db.String)
department = db.Column(db.String)
affiliation = db.Column(db.String)
sponsor_type = db.Column(db.String)
date_cached = db.Column(db.DateTime(timezone=True), default=func.now())
@classmethod
def from_entry(cls, entry):
return LdapModel(uid=entry.uid.value,
display_name=entry.displayName.value,
given_name=", ".join(entry.givenName),
email_address=entry.mail.value,
telephone_number=entry.telephoneNumber.value,
title=", ".join(entry.title),
department=", ".join(entry.uvaDisplayDepartment),
affiliation=", ".join(entry.uvaPersonIAMAffiliation),
sponsor_type=", ".join(entry.uvaPersonSponsoredType))
class LdapSchema(SQLAlchemyAutoSchema):
class Meta:
model = LdapModel
load_instance = True
include_relationships = True
include_fk = True # Includes foreign keys
unknown = EXCLUDE

View File

@ -26,7 +26,8 @@ RequestApproval approver1 "dhf8r"
ApprovalService.add_approval(study_id, workflow_id, args)
elif isinstance(uids, list):
for id in uids:
ApprovalService.add_approval(study_id, workflow_id, id)
if id: ## Assure it's not empty or null
ApprovalService.add_approval(study_id, workflow_id, id)
def get_uids(self, task, args):
if len(args) < 1:

View File

@ -5,7 +5,8 @@ from sqlalchemy import desc
from crc import db, session
from crc.api.common import ApiError
from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile
from crc.models.approval import ApprovalModel, ApprovalStatus, ApprovalFile, Approval
from crc.models.study import StudyModel
from crc.models.workflow import WorkflowModel
from crc.services.file_service import FileService
@ -13,23 +14,63 @@ from crc.services.file_service import FileService
class ApprovalService(object):
"""Provides common tools for working with an Approval"""
@staticmethod
def __one_approval_from_study(study, approver_uid = None, ignore_cancelled=False):
"""Returns one approval, with all additional approvals as 'related_approvals',
the main approval can be pinned to an approver with an optional argument.
Will return null if no approvals exist on the study."""
main_approval = None
related_approvals = []
query = db.session.query(ApprovalModel).\
filter(ApprovalModel.study_id == study.id)
if ignore_cancelled:
query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value)
approvals = query.all()
for approval_model in approvals:
if approval_model.approver_uid == approver_uid:
main_approval = Approval.from_model(approval_model)
else:
related_approvals.append(Approval.from_model(approval_model))
if not main_approval and len(related_approvals) > 0:
main_approval = related_approvals[0]
related_approvals = related_approvals[1:]
if len(related_approvals) > 0:
main_approval.related_approvals = related_approvals
return main_approval
@staticmethod
def get_approvals_per_user(approver_uid):
"""Returns a list of all approvals for the given user (approver)"""
db_approvals = session.query(ApprovalModel).filter_by(approver_uid=approver_uid).all()
return db_approvals
"""Returns a list of approval objects (not db models) for the given
approver. """
studies = db.session.query(StudyModel).join(ApprovalModel).\
filter(ApprovalModel.approver_uid == approver_uid).all()
approvals = []
for study in studies:
approval = ApprovalService.__one_approval_from_study(study, approver_uid)
if approval:
approvals.append(approval)
return approvals
@staticmethod
def get_all_approvals(ignore_cancelled=False):
"""Returns a list of all approval objects (not db models), one record
per study, with any associated approvals grouped under the first approval."""
studies = db.session.query(StudyModel).all()
approvals = []
for study in studies:
approval = ApprovalService.__one_approval_from_study(study, ignore_cancelled=ignore_cancelled)
if approval:
approvals.append(approval)
return approvals
@staticmethod
def get_approvals_for_study(study_id):
"""Returns a list of all approvals for the given study"""
"""Returns an array of Approval objects for the study, it does not
compute the related approvals."""
db_approvals = session.query(ApprovalModel).filter_by(study_id=study_id).all()
return db_approvals
return [Approval.from_model(approval_model) for approval_model in db_approvals]
@staticmethod
def get_all_approvals():
"""Returns a list of all approvlas"""
db_approvals = session.query(ApprovalModel).all()
return db_approvals
@staticmethod
def update_approval(approval_id, approver_uid, status):

View File

@ -1,40 +1,15 @@
import os
from attr import asdict
from ldap3.core.exceptions import LDAPExceptionError
from crc import app
from crc import app, db
from ldap3 import Connection, Server, MOCK_SYNC
from crc.api.common import ApiError
from crc.models.ldap import LdapModel, LdapSchema
class LdapUserInfo(object):
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"
attributes = ['uid', 'cn', 'sn', 'displayName', 'givenName', 'mail', 'objectClass', 'UvaDisplayDepartment',
@ -63,12 +38,16 @@ class LdapService(object):
self.conn.unbind()
def user_info(self, uva_uid):
search_string = LdapService.uid_search_string % uva_uid
self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes)
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.from_entry(entry)
user_info = db.session.query(LdapModel).filter(LdapModel.uid == uva_uid).first()
if not user_info:
search_string = LdapService.uid_search_string % uva_uid
self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes)
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]
user_info = LdapModel.from_entry(entry)
db.session.add(user_info)
return user_info
def search_users(self, query, limit):
if len(query.strip()) < 3:
@ -95,7 +74,7 @@ class LdapService(object):
for entry in self.conn.entries:
if count > limit:
break
results.append(LdapUserInfo.from_entry(entry))
results.append(LdapSchema().dump(LdapModel.from_entry(entry)))
count += 1
except LDAPExceptionError as le:
app.logger.info("Failed to execute ldap search. %s", str(le))

View File

@ -196,8 +196,8 @@ class LookupService(object):
we return a lookup data model."""
user_list = []
for user in users:
user_list.append( {"value": user.uid,
"label": user.display_name + " (" + user.uid + ")",
"data": user.__dict__
user_list.append( {"value": user['uid'],
"label": user['display_name'] + " (" + user['uid'] + ")",
"data": user
})
return user_list

View File

@ -10,6 +10,7 @@ from ldap3.core.exceptions import LDAPSocketOpenError
from crc import db, session, app
from crc.api.common import ApiError
from crc.models.file import FileModel, FileModelSchema, File
from crc.models.ldap import LdapSchema
from crc.models.protocol_builder import ProtocolBuilderStudy, ProtocolBuilderStatus
from crc.models.stats import TaskEventModel
from crc.models.study import StudyModel, Study, Category, WorkflowMetadata
@ -56,9 +57,7 @@ class StudyService(object):
study = Study.from_model(study_model)
study.categories = StudyService.get_categories()
workflow_metas = StudyService.__get_workflow_metas(study_id)
approvals = ApprovalService.get_approvals_for_study(study.id)
study.approvals = [Approval.from_model(approval_model) for approval_model in approvals]
study.approvals = ApprovalService.get_approvals_for_study(study.id)
files = FileService.get_files_for_study(study.id)
files = (File.from_models(model, FileService.get_file_data(model.id),
FileService.get_doc_dictionary()) for model in files)
@ -208,7 +207,7 @@ class StudyService(object):
def get_ldap_dict_if_available(user_id):
try:
ldap_service = LdapService()
return ldap_service.user_info(user_id).__dict__
return LdapSchema().dump(ldap_service.user_info(user_id))
except ApiError as ae:
app.logger.info(str(ae))
return {"error": str(ae)}

View File

@ -107,11 +107,13 @@ class WorkflowService(object):
if not hasattr(task.task_spec, 'form'): return
form_data = {}
form_data = task.data # Just like with the front end, we start with what was already there, and modify it.
for field in task_api.form.fields:
if required_only and (not field.has_validation(Task.VALIDATION_REQUIRED) or
field.get_validation(Task.VALIDATION_REQUIRED).lower().strip() != "true"):
continue # Don't include any fields that aren't specifically marked as required.
if field.has_property("read_only") and field.get_property("read_only").lower().strip() == "true":
continue # Don't mess about with read only fields.
if field.has_property(Task.PROP_OPTIONS_REPEAT):
group = field.get_property(Task.PROP_OPTIONS_REPEAT)
if group not in form_data:

View File

@ -5,10 +5,7 @@
<bpmn:outgoing>SequenceFlow_05ja25w</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:manualTask id="ManualTask_Instructions" name="Read RRP Instructions">
<bpmn:documentation>## **Beta Stage: All data entered will be destroyed before public launch**
### UNIVERSITY OF VIRGINIA RESEARCH
<bpmn:documentation>### UNIVERSITY OF VIRGINIA RESEARCH
[From Research Ramp-up Guidance](https://research.virginia.edu/research-ramp-guidance)
@ -319,8 +316,11 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a
<camunda:property id="repeat" value="shared" />
<camunda:property id="description" value="Enter the number of square feet in this space." />
</camunda:properties>
<camunda:validation>
<camunda:constraint name="required" config="true" />
</camunda:validation>
</camunda:formField>
<camunda:formField id="SharedSpacePercentUsable" label="Percent of Space Usable By Personnel" type="long">
<camunda:formField id="SharedSpacePercentUsable" label="Percent of Space Usable By Personnel" type="long" defaultValue="100">
<camunda:properties>
<camunda:property id="repeat" value="shared" />
<camunda:property id="description" value="If known, enter a number between 1 &#38; 100 which indicates the approximate percent of the total space personnel will work in and move around in during the workday." />
@ -328,6 +328,7 @@ When your Research Ramp-up Plan is complete and ready to submit for review and a
<camunda:validation>
<camunda:constraint name="min" config="1" />
<camunda:constraint name="max" config="100" />
<camunda:constraint name="required" config="true" />
</camunda:validation>
</camunda:formField>
<camunda:formField id="SharedSpaceMaxPersonnel" label="Maximum Number of Personnel Occupying Space" type="long">
@ -423,7 +424,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp
<camunda:constraint name="required" config="true" />
</camunda:validation>
</camunda:formField>
<camunda:formField id="ExclusiveSpacePercentUsable" label="Percent of Space Usable By Personnel" type="long">
<camunda:formField id="ExclusiveSpacePercentUsable" label="Percent of Space Usable By Personnel" type="long" defaultValue="100">
<camunda:properties>
<camunda:property id="repeat" value="exclusive" />
<camunda:property id="description" value="If known, enter a number between 1 &#38; 100 which indicates the approximate percent of the total space personnel will work in and move around in during the workday." />
@ -431,6 +432,7 @@ Submit one entry for each space the PI is the exclusive investigator. If all sp
<camunda:validation>
<camunda:constraint name="min" config="1" />
<camunda:constraint name="max" config="100" />
<camunda:constraint name="required" config="true" />
</camunda:validation>
</camunda:formField>
<camunda:formField id="ExclusiveSpaceMaxPersonnel" label="Maximum Number of Personnel Occupying Space" type="long">
@ -644,7 +646,7 @@ If a rejection notification is received, go back to the first step that needs to
</camunda:formField>
<camunda:formField id="ExclusiveSpaceBuilding" label="Room No. &#38; Building Name" type="autocomplete">
<camunda:properties>
<camunda:property id="repeat" value="Exclusive" />
<camunda:property id="repeat" value="exclusive" />
<camunda:property id="read_only" value="true" />
<camunda:property id="spreadsheet.name" value="Buildinglist.xls" />
<camunda:property id="spreadsheet.value.column" value="Value" />
@ -656,14 +658,14 @@ If a rejection notification is received, go back to the first step that needs to
</camunda:formField>
<camunda:formField id="ExclusiveSpaceRoomID" label="Exclusive Space Room ID" type="string">
<camunda:properties>
<camunda:property id="repeat" value="Exclusive" />
<camunda:property id="repeat" value="exclusive" />
<camunda:property id="read_only" value="true" />
</camunda:properties>
</camunda:formField>
<camunda:formField id="ExclusiveSpaceType" label="Space Room Type" type="enum">
<camunda:properties>
<camunda:property id="read_only" value="true" />
<camunda:property id="repeat" value="Exclusive" />
<camunda:property id="repeat" value="exclusive" />
</camunda:properties>
<camunda:value id="Lab" name="Lab" />
<camunda:value id="Office" name="Office" />
@ -672,7 +674,7 @@ If a rejection notification is received, go back to the first step that needs to
<camunda:properties>
<camunda:property id="ldap.lookup" value="true" />
<camunda:property id="placeholder" value="wxy0z or Smith" />
<camunda:property id="repeat" value="Exclusive" />
<camunda:property id="repeat" value="exclusive" />
</camunda:properties>
<camunda:validation>
<camunda:constraint name="required" config="true" />
@ -683,7 +685,7 @@ If a rejection notification is received, go back to the first step that needs to
<camunda:property id="spreadsheet.name" value="Buildinglist.xls" />
<camunda:property id="spreadsheet.value.column" value="Value" />
<camunda:property id="spreadsheet.label.column" value="Building Name" />
<camunda:property id="repeat" value="Shared" />
<camunda:property id="repeat" value="shared" />
<camunda:property id="repeat_title" value="Any missing Area Monitors for Shared Spaces must be entered" />
<camunda:property id="read_only" value="true" />
<camunda:property id="repeat_hide_expression" value="model.isAllSharedAreaMonitors" />
@ -693,13 +695,13 @@ If a rejection notification is received, go back to the first step that needs to
<camunda:formField id="SharedSpaceRoomID" label="Shared Space Room ID" type="string">
<camunda:properties>
<camunda:property id="read_only" value="true" />
<camunda:property id="repeat" value="Shared" />
<camunda:property id="repeat" value="shared" />
</camunda:properties>
</camunda:formField>
<camunda:formField id="SharedSpaceAMComputingID" label="Area Monitor" type="autocomplete">
<camunda:properties>
<camunda:property id="ldap.lookup" value="true" />
<camunda:property id="repeat" value="Shared" />
<camunda:property id="repeat" value="shared" />
<camunda:property id="placeholder" value="wxy0z or Smith" />
</camunda:properties>
<camunda:validation>
@ -940,6 +942,9 @@ This step is internal to the system and do not require and user interaction</bpm
<bpmndi:BPMNShape id="Activity_1xgrlzr_di" bpmnElement="Activity_SharedSpaceInfo">
<dc:Bounds x="850" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1jefdme_di" bpmnElement="Activity_ExclusiveSpace">
<dc:Bounds x="690" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0ysw6zo_di" bpmnElement="Activity_nonUVASpaces">
<dc:Bounds x="1160" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
@ -988,9 +993,6 @@ This step is internal to the system and do not require and user interaction</bpm
<bpmndi:BPMNShape id="Activity_1i34vt6_di" bpmnElement="Activity_14xt8is">
<dc:Bounds x="3040" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1jefdme_di" bpmnElement="Activity_ExclusiveSpace">
<dc:Bounds x="690" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,42 @@
"""empty message
Revision ID: 13424d5a6de8
Revises: 5064b72284b7
Create Date: 2020-06-02 18:17:29.990159
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '13424d5a6de8'
down_revision = '5064b72284b7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('ldap_model',
sa.Column('uid', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=True),
sa.Column('given_name', sa.String(), nullable=True),
sa.Column('email_address', sa.String(), nullable=True),
sa.Column('telephone_number', sa.String(), nullable=True),
sa.Column('title', sa.String(), nullable=True),
sa.Column('department', sa.String(), nullable=True),
sa.Column('affiliation', sa.String(), nullable=True),
sa.Column('sponsor_type', sa.String(), nullable=True),
sa.Column('date_cached', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('uid')
)
op.add_column('approval', sa.Column('date_approved', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('approval', 'date_approved')
op.drop_table('ldap_model')
# ### end Alembic commands ###

3
package-lock.json generated Normal file
View File

@ -0,0 +1,3 @@
{
"lockfileVersion": 1
}

View File

@ -1,37 +1,10 @@
import json
from tests.base_test import BaseTest
from crc import app, db, session
from crc import session, db
from crc.models.approval import ApprovalModel, ApprovalSchema, ApprovalStatus
APPROVAL_PAYLOAD = {
'id': None,
'approver': {
'uid': 'bgb22',
'display_name': 'Billy Bob (bgb22)',
'title': 'E42:He\'s a hoopy frood',
'department': 'E0:EN-Eng Study of Parallel Universes'
},
'title': 'El Study',
'status': 'DECLINED',
'version': 1,
'message': 'Incorrect documents',
'associated_files': [
{
'id': 42,
'name': 'File 1',
'content_type': 'document'
},
{
'id': 43,
'name': 'File 2',
'content_type': 'document'
}
],
'workflow_id': 1,
'study_id': 1
}
from crc.models.protocol_builder import ProtocolBuilderStatus
from crc.models.study import StudyModel
class TestApprovals(BaseTest):
@ -40,11 +13,16 @@ class TestApprovals(BaseTest):
self.load_example_data()
self.study = self.create_study()
self.workflow = self.create_workflow('random_fact')
self.unrelated_study = StudyModel(title="second study",
protocol_builder_status=ProtocolBuilderStatus.ACTIVE,
user_uid="dhf8r", primary_investigator_id="dhf8r")
self.unrelated_workflow = self.create_workflow('random_fact', study=self.unrelated_study)
# TODO: Move to base_test as a helper
self.approval = ApprovalModel(
study=self.study,
workflow=self.workflow,
approver_uid='arc93',
approver_uid='lb3dp',
status=ApprovalStatus.PENDING.value,
version=1
)
@ -59,55 +37,118 @@ class TestApprovals(BaseTest):
)
session.add(self.approval_2)
# A third study, unrelated to the first.
self.approval_3 = ApprovalModel(
study=self.unrelated_study,
workflow=self.unrelated_workflow,
approver_uid='lb3dp',
status=ApprovalStatus.PENDING.value,
version=1
)
session.add(self.approval_3)
session.commit()
def test_list_approvals_per_approver(self):
"""Only approvals associated with approver should be returned"""
approver_uid = self.approval_2.approver_uid
rv = self.app.get(f'/v1.0/approval?approver_uid={approver_uid}', headers=self.logged_in_headers())
rv = self.app.get(f'/v1.0/approval', headers=self.logged_in_headers())
self.assert_success(rv)
response = json.loads(rv.get_data(as_text=True))
# Stored approvals are 2
# Stored approvals are 3
approvals_count = ApprovalModel.query.count()
self.assertEqual(approvals_count, 2)
self.assertEqual(approvals_count, 3)
# but Dan's approvals should be only 1
self.assertEqual(len(response), 1)
# Confirm approver UID matches returned payload
approval = ApprovalSchema().load(response[0])
self.assertEqual(approval.approver['uid'], approver_uid)
approval = response[0]
self.assertEqual(approval['approver']['uid'], approver_uid)
def test_list_approvals_per_admin(self):
"""All approvals will be returned"""
rv = self.app.get('/v1.0/approval', headers=self.logged_in_headers())
rv = self.app.get('/v1.0/approval?everything=true', headers=self.logged_in_headers())
self.assert_success(rv)
response = json.loads(rv.get_data(as_text=True))
# Returned approvals should match what's in the db
# Returned approvals should match what's in the db, we should get one approval back
# per study (2 studies), and that approval should have one related approval.
approvals_count = ApprovalModel.query.count()
response_count = len(response)
self.assertEqual(approvals_count, response_count)
self.assertEqual(2, response_count)
def test_update_approval(self):
"""Approval status will be updated"""
approval_id = self.approval.id
data = dict(APPROVAL_PAYLOAD)
data['id'] = approval_id
rv = self.app.get('/v1.0/approval', headers=self.logged_in_headers())
self.assert_success(rv)
response = json.loads(rv.get_data(as_text=True))
response_count = len(response)
self.assertEqual(1, response_count)
self.assertEqual(1, len(response[0]['related_approvals'])) # this approval has a related approval.
self.assertEqual(self.approval.status, ApprovalStatus.PENDING.value)
def test_update_approval_fails_if_not_the_approver(self):
approval = session.query(ApprovalModel).filter_by(approver_uid='lb3dp').first()
data = {'id': approval.id,
"approver_uid": "dhf8r",
'message': "Approved. I like the cut of your jib.",
'status': ApprovalStatus.APPROVED.value}
rv = self.app.put(f'/v1.0/approval/{approval_id}',
self.assertEqual(approval.status, ApprovalStatus.PENDING.value)
rv = self.app.put(f'/v1.0/approval/{approval.id}',
content_type="application/json",
headers=self.logged_in_headers(),
headers=self.logged_in_headers(), # As dhf8r
data=json.dumps(data))
self.assert_failure(rv)
def test_accept_approval(self):
approval = session.query(ApprovalModel).filter_by(approver_uid='dhf8r').first()
data = {'id': approval.id,
"approver_uid": "dhf8r",
'message': "Approved. I like the cut of your jib.",
'status': ApprovalStatus.APPROVED.value}
self.assertEqual(approval.status, ApprovalStatus.PENDING.value)
rv = self.app.put(f'/v1.0/approval/{approval.id}',
content_type="application/json",
headers=self.logged_in_headers(), # As dhf8r
data=json.dumps(data))
self.assert_success(rv)
session.refresh(self.approval)
session.refresh(approval)
# Updated record should now have the data sent to the endpoint
self.assertEqual(self.approval.message, data['message'])
self.assertEqual(self.approval.status, ApprovalStatus.DECLINED.value)
self.assertEqual(approval.message, data['message'])
self.assertEqual(approval.status, ApprovalStatus.APPROVED.value)
def test_decline_approval(self):
approval = session.query(ApprovalModel).filter_by(approver_uid='dhf8r').first()
data = {'id': approval.id,
"approver_uid": "dhf8r",
'message': "Approved. I find the cut of your jib lacking.",
'status': ApprovalStatus.DECLINED.value}
self.assertEqual(approval.status, ApprovalStatus.PENDING.value)
rv = self.app.put(f'/v1.0/approval/{approval.id}',
content_type="application/json",
headers=self.logged_in_headers(), # As dhf8r
data=json.dumps(data))
self.assert_success(rv)
session.refresh(approval)
# Updated record should now have the data sent to the endpoint
self.assertEqual(approval.message, data['message'])
self.assertEqual(approval.status, ApprovalStatus.DECLINED.value)
def test_csv_export(self):
approvals = db.session.query(ApprovalModel).all()
for app in approvals:
app.status = ApprovalStatus.APPROVED.value
db.session.commit()
rv = self.app.get(f'/v1.0/approval/csv', headers=self.logged_in_headers())
self.assert_success(rv)

View File

@ -1,10 +1,7 @@
import os
from tests.base_test import BaseTest
from crc import app
from crc.api.common import ApiError
from crc.services.ldap_service import LdapService
from tests.base_test import BaseTest
from ldap3 import Server, Connection, ALL, MOCK_SYNC
class TestLdapService(BaseTest):

View File

@ -1,6 +1,6 @@
from crc.services.file_service import FileService
from tests.base_test import BaseTest
from crc.services.file_service import FileService
from crc.scripts.request_approval import RequestApproval
from crc.services.workflow_processor import WorkflowProcessor
from crc.api.common import ApiError
@ -26,6 +26,22 @@ class TestRequestApprovalScript(BaseTest):
script.do_task(task, workflow.study_id, workflow.id, "study.approval1", "study.approval2")
self.assertEquals(2, db.session.query(ApprovalModel).count())
def test_do_task_with_blank_second_approver(self):
self.load_example_data()
self.create_reference_document()
workflow = self.create_workflow('empty_workflow')
processor = WorkflowProcessor(workflow)
task = processor.next_task()
task.data = {"study": {"approval1": "dhf8r", 'approval2':''}}
FileService.add_workflow_file(workflow_id=workflow.id,
irb_doc_code="UVACompl_PRCAppr",
name="anything.png", content_type="text",
binary_data=b'1234')
script = RequestApproval()
script.do_task(task, workflow.study_id, workflow.id, "study.approval1", "study.approval2")
self.assertEquals(1, db.session.query(ApprovalModel).count())
def test_do_task_with_incorrect_argument(self):
"""This script should raise an error if it can't figure out the approvers."""
self.load_example_data()

View File

@ -10,7 +10,6 @@ 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