Merge branch 'rrt/dev' into rrt/production
This commit is contained in:
commit
00a1902c71
1
Pipfile
1
Pipfile
|
@ -38,6 +38,7 @@ xlrd = "*"
|
|||
ldap3 = "*"
|
||||
gunicorn = "*"
|
||||
werkzeug = "*"
|
||||
sentry-sdk = {extras = ["flask"],version = "==0.14.4"}
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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('/'))
|
||||
|
||||
|
|
|
@ -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'])
|
||||
|
|
19
crc/api.yml
19
crc/api.yml
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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"
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ...
|
||||
# ]
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
|
@ -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)}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 & 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 & 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. & 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>
|
||||
|
|
|
@ -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 ###
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"lockfileVersion": 1
|
||||
}
|
|
@ -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)
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue