Merge branch 'rrt/dev' into dev

This commit is contained in:
Aaron Louie 2020-06-03 00:02:52 -04:00
commit 6bd2b65500
14 changed files with 315 additions and 152 deletions

View File

@ -854,6 +854,19 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Approval" $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: components:
securitySchemes: securitySchemes:
jwt: jwt:

View File

@ -1,19 +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 flask import g
from crc import app, db, session from crc import app, db, session
from crc.api.common import ApiError, ApiErrorSchema 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.approval_service import ApprovalService
from crc.services.ldap_service import LdapService
from crc.services.workflow_processor import WorkflowProcessor
def get_approvals(everything=False): def get_approvals(everything=False):
if everything: if everything:
db_approvals = ApprovalService.get_all_approvals() approvals = ApprovalService.get_all_approvals()
else: else:
db_approvals = ApprovalService.get_approvals_per_user(g.user.uid) approvals = ApprovalService.get_approvals_per_user(g.user.uid)
approvals = [Approval.from_model(approval_model) for approval_model in db_approvals]
results = ApprovalSchema(many=True).dump(approvals) results = ApprovalSchema(many=True).dump(approvals)
return results return results
@ -25,6 +36,57 @@ def get_approvals_for_study(study_id=None):
return results 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()
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()
personnel = extract_personnel(workflow.bpmn_workflow_json)
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({
"pi_uid": pi_details.uid,
"pi": pi_details.display_name,
"name": person.display_name,
"email": person.email_address
})
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_personnel(data):
data = json.loads(data)
last_task = find_task(data['last_task']['__uuid__'], data['task_tree'])
last_task_personnel = pickle.loads(b64decode(last_task['data']['personnel']['__bytes__']))
return last_task_personnel
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): def update_approval(approval_id, body):
if approval_id is None: if approval_id is None:
raise ApiError('unknown_approval', 'Please provide a valid Approval ID.') raise ApiError('unknown_approval', 'Please provide a valid Approval ID.')
@ -33,12 +95,14 @@ def update_approval(approval_id, body):
if approval_model is None: if approval_model is None:
raise ApiError('unknown_approval', 'The approval "' + str(approval_id) + '" is not recognized.') raise ApiError('unknown_approval', 'The approval "' + str(approval_id) + '" is not recognized.')
approval: Approval = ApprovalSchema().load(body)
if approval_model.approver_uid != g.user.uid: if approval_model.approver_uid != g.user.uid:
raise ApiError("not_your_approval", "You may not modify this approval. It belongs to another user.") raise ApiError("not_your_approval", "You may not modify this approval. It belongs to another user.")
approval.update_model(approval_model) approval_model.status = body['status']
approval_model.message = body['message']
approval_model.date_approved = datetime.now()
session.add(approval_model)
session.commit() session.commit()
result = ApprovalSchema().dump(approval) result = ApprovalSchema().dump(approval_model)
return result return result

View File

@ -4,7 +4,7 @@ from flask import g, request
from crc import 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 from crc.services.ldap_service import LdapService, LdapModel
""" """
.. module:: crc.api.user .. module:: crc.api.user
@ -78,7 +78,7 @@ def sso():
return response 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, """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.

View File

@ -1,17 +1,17 @@
import enum import enum
import marshmallow import marshmallow
from ldap3.core.exceptions import LDAPSocketOpenError from marshmallow import INCLUDE, fields
from marshmallow import INCLUDE
from sqlalchemy import func from sqlalchemy import func
from crc import db, ma from crc import db, ma, app
from crc.api.common import ApiError from crc.api.common import ApiError
from crc.models.file import FileDataModel from crc.models.file import FileDataModel
from crc.models.ldap import LdapSchema
from crc.models.study import StudyModel from crc.models.study import StudyModel
from crc.models.workflow import WorkflowModel from crc.models.workflow import WorkflowModel
from crc.services.ldap_service import LdapService
from crc.services.file_service import FileService from crc.services.file_service import FileService
from crc.services.ldap_service import LdapService
class ApprovalStatus(enum.Enum): class ApprovalStatus(enum.Enum):
@ -33,13 +33,14 @@ class ApprovalModel(db.Model):
__tablename__ = 'approval' __tablename__ = 'approval'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
study_id = db.Column(db.Integer, db.ForeignKey(StudyModel.id), nullable=False) 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_id = db.Column(db.Integer, db.ForeignKey(WorkflowModel.id), nullable=False)
workflow = db.relationship(WorkflowModel) workflow = db.relationship(WorkflowModel)
approver_uid = db.Column(db.String) # Not linked to user model, as they may not have logged in yet. approver_uid = db.Column(db.String) # Not linked to user model, as they may not have logged in yet.
status = db.Column(db.String) status = db.Column(db.String)
message = db.Column(db.String, default='') message = db.Column(db.String, default='')
date_created = db.Column(db.DateTime(timezone=True), default=func.now()) 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. version = db.Column(db.Integer) # Incremented integer, so 1,2,3 as requests are made.
approval_files = db.relationship(ApprovalFile, back_populates="approval", approval_files = db.relationship(ApprovalFile, back_populates="approval",
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",
@ -63,40 +64,22 @@ class Approval(object):
instance.status = model.status instance.status = model.status
instance.message = model.message instance.message = model.message
instance.date_created = model.date_created instance.date_created = model.date_created
instance.date_approved = model.date_approved
instance.version = model.version instance.version = model.version
instance.title = '' instance.title = ''
instance.related_approvals = []
if model.study: if model.study:
instance.title = model.study.title instance.title = model.study.title
instance.approver = {} ldap_service = LdapService()
try: try:
ldap_service = LdapService() instance.approver = ldap_service.user_info(model.approver_uid)
user_info = ldap_service.user_info(model.approver_uid) instance.primary_investigator = ldap_service.user_info(model.study.primary_investigator_id)
instance.approver['uid'] = model.approver_uid except ApiError as ae:
instance.approver['display_name'] = user_info.display_name app.logger.error("Ldap lookup failed for approval record %i" % model.id)
instance.approver['title'] = user_info.title
instance.approver['department'] = user_info.department
except (ApiError, LDAPSocketOpenError) as exception:
user_info = None
instance.approver['display_name'] = 'Unknown'
instance.approver['department'] = 'currently not available'
instance.primary_investigator = {}
try:
ldap_service = LdapService()
user_info = ldap_service.user_info(model.study.primary_investigator_id)
instance.primary_investigator['uid'] = model.approver_uid
instance.primary_investigator['display_name'] = user_info.display_name
instance.primary_investigator['title'] = user_info.title
instance.primary_investigator['department'] = user_info.department
except (ApiError, LDAPSocketOpenError) as exception:
user_info = None
instance.primary_investigator['display_name'] = 'Primary Investigator details'
instance.primary_investigator['department'] = 'currently not available'
doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id']) doc_dictionary = FileService.get_reference_data(FileService.DOCUMENT_LIST, 'code', ['id'])
instance.associated_files = [] instance.associated_files = []
for approval_file in model.approval_files: for approval_file in model.approval_files:
try: try:
@ -125,11 +108,17 @@ class Approval(object):
class ApprovalSchema(ma.Schema): 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: class Meta:
model = Approval model = Approval
fields = ["id", "study_id", "workflow_id", "version", "title", fields = ["id", "study_id", "workflow_id", "version", "title",
"status", "message", "approver", "primary_investigator", "status", "message", "approver", "primary_investigator",
"associated_files", "date_created"] "associated_files", "date_created", "date_approved",
"related_approvals"]
unknown = INCLUDE unknown = INCLUDE
@marshmallow.post_load @marshmallow.post_load
@ -137,30 +126,4 @@ class ApprovalSchema(ma.Schema):
"""Loads the basic approval data for updates to the database""" """Loads the basic approval data for updates to the database"""
return Approval(**data) 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

@ -5,7 +5,8 @@ from sqlalchemy import desc
from crc import db, session from crc import db, session
from crc.api.common import ApiError 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.models.workflow import WorkflowModel
from crc.services.file_service import FileService from crc.services.file_service import FileService
@ -14,22 +15,57 @@ class ApprovalService(object):
"""Provides common tools for working with an Approval""" """Provides common tools for working with an Approval"""
@staticmethod @staticmethod
def get_approvals_per_user(approver_uid): def __one_approval_from_study(study, approver_uid = None):
"""Returns a list of all approvals for the given user (approver)""" """Returns one approval, with all additional approvals as 'related_approvals',
db_approvals = session.query(ApprovalModel).filter_by(approver_uid=approver_uid).all() the main approval can be pinned to an approver with an optional argument.
return db_approvals Will return null if no approvals exist on the study."""
main_approval = None
related_approvals = []
approvals = db.session.query(ApprovalModel).filter(ApprovalModel.study_id == study.id).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 @staticmethod
def get_approvals_for_study(study_id): def get_approvals_per_user(approver_uid):
"""Returns a list of all approvals for the given study""" """Returns a list of approval objects (not db models) for the given
db_approvals = session.query(ApprovalModel).filter_by(study_id=study_id).all() approver. """
return db_approvals 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 @staticmethod
def get_all_approvals(): def get_all_approvals():
"""Returns a list of all approvlas""" """Returns a list of all approval objects (not db models), one record
db_approvals = session.query(ApprovalModel).all() per study, with any associated approvals grouped under the first approval."""
return db_approvals studies = db.session.query(StudyModel).all()
approvals = []
for study in studies:
approval = ApprovalService.__one_approval_from_study(study)
if approval:
approvals.append(approval)
return approvals
@staticmethod
def get_approvals_for_study(study_id):
"""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 [Approval.from_model(approval_model) for approval_model in db_approvals]
@staticmethod @staticmethod
def update_approval(approval_id, approver_uid, status): def update_approval(approval_id, approver_uid, status):

View File

@ -1,40 +1,15 @@
import os import os
from attr import asdict
from ldap3.core.exceptions import LDAPExceptionError from ldap3.core.exceptions import LDAPExceptionError
from crc import app from crc import app, db
from ldap3 import Connection, Server, MOCK_SYNC from ldap3 import Connection, Server, MOCK_SYNC
from crc.api.common import ApiError 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): class LdapService(object):
search_base = "ou=People,o=University of Virginia,c=US" search_base = "ou=People,o=University of Virginia,c=US"
attributes = ['uid', 'cn', 'sn', 'displayName', 'givenName', 'mail', 'objectClass', 'UvaDisplayDepartment', attributes = ['uid', 'cn', 'sn', 'displayName', 'givenName', 'mail', 'objectClass', 'UvaDisplayDepartment',
@ -63,12 +38,16 @@ class LdapService(object):
self.conn.unbind() self.conn.unbind()
def user_info(self, uva_uid): def user_info(self, uva_uid):
search_string = LdapService.uid_search_string % uva_uid user_info = db.session.query(LdapModel).filter(LdapModel.uid == uva_uid).first()
self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes) if not user_info:
if len(self.conn.entries) < 1: search_string = LdapService.uid_search_string % uva_uid
raise ApiError("missing_ldap_record", "Unable to locate a user with id %s in LDAP" % uva_uid) self.conn.search(LdapService.search_base, search_string, attributes=LdapService.attributes)
entry = self.conn.entries[0] if len(self.conn.entries) < 1:
return LdapUserInfo.from_entry(entry) 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): def search_users(self, query, limit):
if len(query.strip()) < 3: if len(query.strip()) < 3:
@ -95,7 +74,7 @@ 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.from_entry(entry)) results.append(LdapSchema().dump(LdapModel.from_entry(entry)))
count += 1 count += 1
except LDAPExceptionError as le: except LDAPExceptionError as le:
app.logger.info("Failed to execute ldap search. %s", str(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.""" we return a lookup data model."""
user_list = [] user_list = []
for user in users: for user in users:
user_list.append( {"value": user.uid, user_list.append( {"value": user['uid'],
"label": user.display_name + " (" + user.uid + ")", "label": user['display_name'] + " (" + user['uid'] + ")",
"data": user.__dict__ "data": user
}) })
return user_list return user_list

View File

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

View File

@ -107,11 +107,13 @@ class WorkflowService(object):
if not hasattr(task.task_spec, 'form'): return 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: for field in task_api.form.fields:
if required_only and (not field.has_validation(Task.VALIDATION_REQUIRED) or if required_only and (not field.has_validation(Task.VALIDATION_REQUIRED) or
field.get_validation(Task.VALIDATION_REQUIRED).lower().strip() != "true"): field.get_validation(Task.VALIDATION_REQUIRED).lower().strip() != "true"):
continue # Don't include any fields that aren't specifically marked as required. 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): if field.has_property(Task.PROP_OPTIONS_REPEAT):
group = field.get_property(Task.PROP_OPTIONS_REPEAT) group = field.get_property(Task.PROP_OPTIONS_REPEAT)
if group not in form_data: if group not in form_data:

View File

@ -5,10 +5,7 @@
<bpmn:outgoing>SequenceFlow_05ja25w</bpmn:outgoing> <bpmn:outgoing>SequenceFlow_05ja25w</bpmn:outgoing>
</bpmn:startEvent> </bpmn:startEvent>
<bpmn:manualTask id="ManualTask_Instructions" name="Read RRP Instructions"> <bpmn:manualTask id="ManualTask_Instructions" name="Read RRP Instructions">
<bpmn:documentation>## **Beta Stage: All data entered will be destroyed before public launch** <bpmn:documentation>### UNIVERSITY OF VIRGINIA RESEARCH
### UNIVERSITY OF VIRGINIA RESEARCH
[From Research Ramp-up Guidance](https://research.virginia.edu/research-ramp-guidance) [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="repeat" value="shared" />
<camunda:property id="description" value="Enter the number of square feet in this space." /> <camunda:property id="description" value="Enter the number of square feet in this space." />
</camunda:properties> </camunda:properties>
<camunda:validation>
<camunda:constraint name="required" config="true" />
</camunda:validation>
</camunda:formField> </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:properties>
<camunda:property id="repeat" value="shared" /> <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." /> <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:validation>
<camunda:constraint name="min" config="1" /> <camunda:constraint name="min" config="1" />
<camunda:constraint name="max" config="100" /> <camunda:constraint name="max" config="100" />
<camunda:constraint name="required" config="true" />
</camunda:validation> </camunda:validation>
</camunda:formField> </camunda:formField>
<camunda:formField id="SharedSpaceMaxPersonnel" label="Maximum Number of Personnel Occupying Space" type="long"> <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:constraint name="required" config="true" />
</camunda:validation> </camunda:validation>
</camunda:formField> </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:properties>
<camunda:property id="repeat" value="exclusive" /> <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." /> <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:validation>
<camunda:constraint name="min" config="1" /> <camunda:constraint name="min" config="1" />
<camunda:constraint name="max" config="100" /> <camunda:constraint name="max" config="100" />
<camunda:constraint name="required" config="true" />
</camunda:validation> </camunda:validation>
</camunda:formField> </camunda:formField>
<camunda:formField id="ExclusiveSpaceMaxPersonnel" label="Maximum Number of Personnel Occupying Space" type="long"> <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>
<camunda:formField id="ExclusiveSpaceBuilding" label="Room No. &#38; Building Name" type="autocomplete"> <camunda:formField id="ExclusiveSpaceBuilding" label="Room No. &#38; Building Name" type="autocomplete">
<camunda:properties> <camunda:properties>
<camunda:property id="repeat" value="Exclusive" /> <camunda:property id="repeat" value="exclusive" />
<camunda:property id="read_only" value="true" /> <camunda:property id="read_only" value="true" />
<camunda:property id="spreadsheet.name" value="Buildinglist.xls" /> <camunda:property id="spreadsheet.name" value="Buildinglist.xls" />
<camunda:property id="spreadsheet.value.column" value="Value" /> <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>
<camunda:formField id="ExclusiveSpaceRoomID" label="Exclusive Space Room ID" type="string"> <camunda:formField id="ExclusiveSpaceRoomID" label="Exclusive Space Room ID" type="string">
<camunda:properties> <camunda:properties>
<camunda:property id="repeat" value="Exclusive" /> <camunda:property id="repeat" value="exclusive" />
<camunda:property id="read_only" value="true" /> <camunda:property id="read_only" value="true" />
</camunda:properties> </camunda:properties>
</camunda:formField> </camunda:formField>
<camunda:formField id="ExclusiveSpaceType" label="Space Room Type" type="enum"> <camunda:formField id="ExclusiveSpaceType" label="Space Room Type" type="enum">
<camunda:properties> <camunda:properties>
<camunda:property id="read_only" value="true" /> <camunda:property id="read_only" value="true" />
<camunda:property id="repeat" value="Exclusive" /> <camunda:property id="repeat" value="exclusive" />
</camunda:properties> </camunda:properties>
<camunda:value id="Lab" name="Lab" /> <camunda:value id="Lab" name="Lab" />
<camunda:value id="Office" name="Office" /> <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:properties>
<camunda:property id="ldap.lookup" value="true" /> <camunda:property id="ldap.lookup" value="true" />
<camunda:property id="placeholder" value="wxy0z or Smith" /> <camunda:property id="placeholder" value="wxy0z or Smith" />
<camunda:property id="repeat" value="Exclusive" /> <camunda:property id="repeat" value="exclusive" />
</camunda:properties> </camunda:properties>
<camunda:validation> <camunda:validation>
<camunda:constraint name="required" config="true" /> <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.name" value="Buildinglist.xls" />
<camunda:property id="spreadsheet.value.column" value="Value" /> <camunda:property id="spreadsheet.value.column" value="Value" />
<camunda:property id="spreadsheet.label.column" value="Building Name" /> <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="repeat_title" value="Any missing Area Monitors for Shared Spaces must be entered" />
<camunda:property id="read_only" value="true" /> <camunda:property id="read_only" value="true" />
<camunda:property id="repeat_hide_expression" value="model.isAllSharedAreaMonitors" /> <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:formField id="SharedSpaceRoomID" label="Shared Space Room ID" type="string">
<camunda:properties> <camunda:properties>
<camunda:property id="read_only" value="true" /> <camunda:property id="read_only" value="true" />
<camunda:property id="repeat" value="Shared" /> <camunda:property id="repeat" value="shared" />
</camunda:properties> </camunda:properties>
</camunda:formField> </camunda:formField>
<camunda:formField id="SharedSpaceAMComputingID" label="Area Monitor" type="autocomplete"> <camunda:formField id="SharedSpaceAMComputingID" label="Area Monitor" type="autocomplete">
<camunda:properties> <camunda:properties>
<camunda:property id="ldap.lookup" value="true" /> <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:property id="placeholder" value="wxy0z or Smith" />
</camunda:properties> </camunda:properties>
<camunda:validation> <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"> <bpmndi:BPMNShape id="Activity_1xgrlzr_di" bpmnElement="Activity_SharedSpaceInfo">
<dc:Bounds x="850" y="77" width="100" height="80" /> <dc:Bounds x="850" y="77" width="100" height="80" />
</bpmndi:BPMNShape> </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"> <bpmndi:BPMNShape id="Activity_0ysw6zo_di" bpmnElement="Activity_nonUVASpaces">
<dc:Bounds x="1160" y="77" width="100" height="80" /> <dc:Bounds x="1160" y="77" width="100" height="80" />
</bpmndi:BPMNShape> </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"> <bpmndi:BPMNShape id="Activity_1i34vt6_di" bpmnElement="Activity_14xt8is">
<dc:Bounds x="3040" y="77" width="100" height="80" /> <dc:Bounds x="3040" y="77" width="100" height="80" />
</bpmndi:BPMNShape> </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:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>
</bpmn:definitions> </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 ###

View File

@ -1,8 +1,10 @@
import json import json
from tests.base_test import BaseTest 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 from crc.models.approval import ApprovalModel, ApprovalSchema, ApprovalStatus
from crc.models.protocol_builder import ProtocolBuilderStatus
from crc.models.study import StudyModel
class TestApprovals(BaseTest): class TestApprovals(BaseTest):
@ -11,11 +13,16 @@ class TestApprovals(BaseTest):
self.load_example_data() self.load_example_data()
self.study = self.create_study() self.study = self.create_study()
self.workflow = self.create_workflow('random_fact') 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 # TODO: Move to base_test as a helper
self.approval = ApprovalModel( self.approval = ApprovalModel(
study=self.study, study=self.study,
workflow=self.workflow, workflow=self.workflow,
approver_uid='arc93', approver_uid='lb3dp',
status=ApprovalStatus.PENDING.value, status=ApprovalStatus.PENDING.value,
version=1 version=1
) )
@ -30,6 +37,16 @@ class TestApprovals(BaseTest):
) )
session.add(self.approval_2) 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() session.commit()
def test_list_approvals_per_approver(self): def test_list_approvals_per_approver(self):
@ -40,16 +57,16 @@ class TestApprovals(BaseTest):
response = json.loads(rv.get_data(as_text=True)) response = json.loads(rv.get_data(as_text=True))
# Stored approvals are 2 # Stored approvals are 3
approvals_count = ApprovalModel.query.count() approvals_count = ApprovalModel.query.count()
self.assertEqual(approvals_count, 2) self.assertEqual(approvals_count, 3)
# but Dan's approvals should be only 1 # but Dan's approvals should be only 1
self.assertEqual(len(response), 1) self.assertEqual(len(response), 1)
# Confirm approver UID matches returned payload # Confirm approver UID matches returned payload
approval = ApprovalSchema().load(response[0]) approval = response[0]
self.assertEqual(approval.approver['uid'], approver_uid) self.assertEqual(approval['approver']['uid'], approver_uid)
def test_list_approvals_per_admin(self): def test_list_approvals_per_admin(self):
"""All approvals will be returned""" """All approvals will be returned"""
@ -58,7 +75,8 @@ class TestApprovals(BaseTest):
response = json.loads(rv.get_data(as_text=True)) 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() approvals_count = ApprovalModel.query.count()
response_count = len(response) response_count = len(response)
self.assertEqual(2, response_count) self.assertEqual(2, response_count)
@ -68,9 +86,10 @@ class TestApprovals(BaseTest):
response = json.loads(rv.get_data(as_text=True)) response = json.loads(rv.get_data(as_text=True))
response_count = len(response) response_count = len(response)
self.assertEqual(1, response_count) self.assertEqual(1, response_count)
self.assertEqual(1, len(response[0]['related_approvals'])) # this approval has a related approval.
def test_update_approval_fails_if_not_the_approver(self): def test_update_approval_fails_if_not_the_approver(self):
approval = session.query(ApprovalModel).filter_by(approver_uid='arc93').first() approval = session.query(ApprovalModel).filter_by(approver_uid='lb3dp').first()
data = {'id': approval.id, data = {'id': approval.id,
"approver_uid": "dhf8r", "approver_uid": "dhf8r",
'message': "Approved. I like the cut of your jib.", 'message': "Approved. I like the cut of your jib.",
@ -125,3 +144,11 @@ class TestApprovals(BaseTest):
# Updated record should now have the data sent to the endpoint # Updated record should now have the data sent to the endpoint
self.assertEqual(approval.message, data['message']) self.assertEqual(approval.message, data['message'])
self.assertEqual(approval.status, ApprovalStatus.DECLINED.value) 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.api.common import ApiError
from crc.services.ldap_service import LdapService from crc.services.ldap_service import LdapService
from tests.base_test import BaseTest
from ldap3 import Server, Connection, ALL, MOCK_SYNC
class TestLdapService(BaseTest): class TestLdapService(BaseTest):