Merge branch 'dev' into 201-changes-to-in-progress

This commit is contained in:
Dan Funk 2021-02-22 18:17:59 -05:00 committed by GitHub
commit 349c3d657a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 123 additions and 1392 deletions

View File

@ -141,6 +141,26 @@ additional edits required.)
## Documentation
Additional Documentation is available on [ReadTheDocs](https://cr-connect-workflow.readthedocs.io/en/latest/#)
## Manual Synch
You can move all the BPMN diagrams from one system to another (upgrading and replacing as needed) This is how
we will transfer files from staging to production.
Eventually we will connect this into the front end code for the BPMN Editor, but for now, you can do so by:
1. Run flask clear-db to clear out your local database if desried (this isn't reuired, but will give you a clean slate
to get an exact replica of production/testing whatever)
2. Log into the Swagger UI for the system you want to move all files to (this could be a local development machine)
3. Set the API Token under authentication. This token must match what is on the testing server. This might
match what is in the default config, at least, that will work for staging.
4. Run the workflow_synch/pullall in swagger, using the url for the site you want to pull from:
something like "https://testing.crconnect.uvadcos.io/api"
5. Be patient. It may take a minute or more to pull everything down.
### Additional Reading
1. [BPMN](https://www.process.st/bpmn-tutorial/) Is the tool we are using to create diagrams

View File

@ -357,30 +357,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Study"
/study/{study_id}/approvals:
parameters:
- name: study_id
in: path
required: true
description: The id of the study for which workflows should be returned.
schema:
type: integer
format: int32
get:
operationId: crc.api.approval.get_approvals_for_study
summary: Returns approvals for a single study
tags:
- Studies
- Approvals
responses:
'200':
description: An array of approvals
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Approval"
/workflow-specification:
get:
operationId: crc.api.workflow.all_specifications
@ -1135,132 +1111,6 @@ paths:
text/plain:
schema:
type: string
/approval-counts:
parameters:
- name: as_user
in: query
required: false
description: If provided, returns the approval counts for that user.
schema:
type: string
get:
operationId: crc.api.approval.get_approval_counts
summary: Provides counts for approvals by status for the given user, or all users if no user is provided
tags:
- Approvals
responses:
'200':
description: An dictionary of Approval Statuses and the counts for each
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ApprovalCounts"
/all_approvals:
parameters:
- name: status
in: query
required: false
description: If set to true, returns all the approvals with any status. Defaults to false, leaving out canceled approvals.
schema:
type: boolean
get:
operationId: crc.api.approval.get_all_approvals
summary: Provides a list of all workflows approvals
tags:
- Approvals
responses:
'200':
description: An array of approvals
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Approval"
/approval:
parameters:
- name: status
in: query
required: false
description: If provided, returns just approvals for the given status.
schema:
type: string
- name: as_user
in: query
required: false
description: If provided, returns the approval results as they would appear for that user.
schema:
type: string
get:
operationId: crc.api.approval.get_approvals
summary: Provides a list of workflows approvals
tags:
- Approvals
responses:
'200':
description: An array of approvals
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Approval"
/approval/{approval_id}:
parameters:
- name: approval_id
in: path
required: true
description: The id of the approval in question.
schema:
type: integer
format: int32
put:
operationId: crc.api.approval.update_approval
summary: Updates an approval with the given parameters
tags:
- Approvals
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Approval'
responses:
'200':
description: Study updated successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/Approval"
/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
/health_attesting:
get:
operationId: crc.api.approval.get_health_attesting_csv
summary: Returns a CSV file with health attesting records
tags:
- Approvals
responses:
'200':
description: A CSV file
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Approval"
/datastore:
post:
operationId: crc.api.data_store.add_datastore

View File

@ -12,7 +12,6 @@ from jinja2 import Markup
from crc import db, app
from crc.api.user import verify_token, verify_token_admin
from crc.models.approval import ApprovalModel
from crc.models.file import FileModel, FileDataModel
from crc.models.task_event import TaskEventModel
from crc.models.study import StudyModel
@ -83,7 +82,6 @@ class TaskEventView(AdminModelView):
admin = Admin(app)
admin.add_view(StudyView(StudyModel, db.session))
admin.add_view(ApprovalView(ApprovalModel, db.session))
admin.add_view(UserView(UserModel, db.session))
admin.add_view(WorkflowView(WorkflowModel, db.session))
admin.add_view(FileView(FileModel, db.session))

View File

@ -1,137 +0,0 @@
import csv
import io
import json
import pickle
from base64 import b64decode
from datetime import datetime
from flask import g, make_response
from crc import db, session
from crc.api.common import ApiError
from crc.models.approval import Approval, ApprovalModel, ApprovalSchema, ApprovalStatus
from crc.models.workflow import WorkflowModel
from crc.services.approval_service import ApprovalService
from crc.services.ldap_service import LdapService
# Returns counts of approvals in each status group assigned to the given user.
# The goal is to return results as quickly as possible.
def get_approval_counts(as_user=None):
uid = as_user or g.user.uid
db_user_approvals = db.session.query(ApprovalModel)\
.filter_by(approver_uid=uid)\
.filter(ApprovalModel.status != ApprovalStatus.CANCELED.name)\
.all()
study_ids = [a.study_id for a in db_user_approvals]
db_other_approvals = db.session.query(ApprovalModel)\
.filter(ApprovalModel.study_id.in_(study_ids))\
.filter(ApprovalModel.approver_uid != uid)\
.filter(ApprovalModel.status != ApprovalStatus.CANCELED.name)\
.all()
# Make a dict of the other approvals where the key is the study id and the value is the approval
# TODO: This won't work if there are more than 2 approvals with the same study_id
other_approvals = {}
for approval in db_other_approvals:
other_approvals[approval.study_id] = approval
counts = {}
for name, value in ApprovalStatus.__members__.items():
counts[name] = 0
for approval in db_user_approvals:
# Check if another approval has the same study id
if approval.study_id in other_approvals:
other_approval = other_approvals[approval.study_id]
# Other approval takes precedence over this one
if other_approval.id < approval.id:
if other_approval.status == ApprovalStatus.PENDING.name:
counts[ApprovalStatus.AWAITING.name] += 1
elif other_approval.status == ApprovalStatus.DECLINED.name:
counts[ApprovalStatus.DECLINED.name] += 1
elif other_approval.status == ApprovalStatus.CANCELED.name:
counts[ApprovalStatus.CANCELED.name] += 1
elif other_approval.status == ApprovalStatus.APPROVED.name:
counts[approval.status] += 1
else:
counts[approval.status] += 1
else:
counts[approval.status] += 1
return counts
def get_all_approvals(status=None):
approvals = ApprovalService.get_all_approvals(include_cancelled=status is True)
results = ApprovalSchema(many=True).dump(approvals)
return results
def get_approvals(status=None, as_user=None):
#status = ApprovalStatus.PENDING.value
user = g.user.uid
if as_user:
user = as_user
approvals = ApprovalService.get_approvals_per_user(user, status,
include_cancelled=False)
results = ApprovalSchema(many=True).dump(approvals)
return results
def get_approvals_for_study(study_id=None):
db_approvals = ApprovalService.get_approvals_for_study(study_id)
approvals = [Approval.from_model(approval_model) for approval_model in db_approvals]
results = ApprovalSchema(many=True).dump(approvals)
return results
def get_health_attesting_csv():
records = ApprovalService.get_health_attesting_records()
si = io.StringIO()
cw = csv.writer(si)
cw.writerows(records)
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=health_attesting.csv"
output.headers["Content-type"] = "text/csv"
return output
# ----- Begin descent into madness ---- #
def get_csv():
"""A damn lie, it's a json file. A huge bit of a one-off for RRT, but 3 weeks of midnight work can convince a
man to do just about anything"""
content = ApprovalService.get_not_really_csv_content()
return content
# ----- 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.')
approval_model = session.query(ApprovalModel).get(approval_id)
if approval_model is None:
raise ApiError('unknown_approval', 'The approval "' + str(approval_id) + '" is not recognized.')
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()
# Called only to send emails
approver = body['approver']['uid']
ApprovalService.update_approval(approval_id, approver)
result = ApprovalSchema().dump(approval_model)
return result

View File

@ -1,118 +0,0 @@
import enum
import marshmallow
from marshmallow import INCLUDE, fields
from sqlalchemy import func
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.file_service import FileService
from crc.services.ldap_service import LdapService
class ApprovalStatus(enum.Enum):
PENDING = "PENDING" # no one has done jack.
APPROVED = "APPROVED" # approved by the reviewer
DECLINED = "DECLINED" # rejected by the reviewer
CANCELED = "CANCELED" # The document was replaced with a new version and this review is no longer needed.
# Used for overall status only, never set on a task.
AWAITING = "AWAITING" # awaiting another approval
class ApprovalFile(db.Model):
file_data_id = db.Column(db.Integer, db.ForeignKey(FileDataModel.id), primary_key=True)
approval_id = db.Column(db.Integer, db.ForeignKey("approval.id"), primary_key=True)
approval = db.relationship("ApprovalModel")
file_data = db.relationship(FileDataModel)
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)
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",
order_by=ApprovalFile.file_data_id)
class Approval(object):
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
@classmethod
def from_model(cls, model: ApprovalModel):
args = dict((k, v) for k, v in model.__dict__.items() if not k.startswith('_'))
instance = cls(**args)
instance.related_approvals = []
instance.title = model.study.title if model.study else ''
try:
instance.approver = LdapService.user_info(model.approver_uid)
instance.primary_investigator = LdapService.user_info(model.study.primary_investigator_id)
except ApiError as ae:
app.logger.error(f'Ldap lookup failed for approval record {model.id}', exc_info=True)
doc_dictionary = FileService.get_doc_dictionary()
instance.associated_files = []
for approval_file in model.approval_files:
try:
# fixme: This is slow because we are doing a ton of queries to find the irb code.
extra_info = doc_dictionary[approval_file.file_data.file_model.irb_doc_code]
except:
extra_info = None
associated_file = {}
associated_file['id'] = approval_file.file_data.file_model.id
if extra_info:
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'] = '(' + 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)
return instance
def update_model(self, approval_model: ApprovalModel):
approval_model.status = self.status
approval_model.message = self.message
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",
"status", "message", "approver", "primary_investigator",
"associated_files", "date_created", "date_approved",
"related_approvals"]
unknown = INCLUDE
@marshmallow.post_load
def make_approval(self, data, **kwargs):
"""Loads the basic approval data for updates to the database"""
return Approval(**data)

View File

@ -230,14 +230,13 @@ class StudySchema(ma.Schema):
sponsor = fields.String(allow_none=True)
ind_number = fields.String(allow_none=True)
files = fields.List(fields.Nested(FileSchema), dump_only=True)
approvals = fields.List(fields.Nested('ApprovalSchema'), dump_only=True)
enrollment_date = fields.Date(allow_none=True)
events_history = fields.List(fields.Nested('StudyEventSchema'), dump_only=True)
class Meta:
model = Study
additional = ["id", "title", "last_updated", "primary_investigator_id", "user_uid",
"sponsor", "ind_number", "approvals", "files", "enrollment_date",
"sponsor", "ind_number", "files", "enrollment_date",
"create_user_display", "last_activity_date","last_activity_user",
"events_history"]
unknown = INCLUDE

View File

@ -1,51 +0,0 @@
from crc.api.common import ApiError
from crc.scripts.script import Script
from crc.services.approval_service import ApprovalService
class RequestApproval(Script):
"""This still needs to be fully wired up as a Script task callable from the workflow
But the basic logic is here just to get the tests passing and logic sound. """
def get_description(self):
return """
Creates an approval request on this workflow, by the given approver_uid(s),"
Takes multiple arguments, which should point to data located in current task
or be quoted strings. The order is important. Approvals will be processed
in this order.
Example:
RequestApproval approver1 "dhf8r"
"""
def do_task_validate_only(self, task, study_id, workflow_id, *args, **kwargs):
self.get_uids(task, args)
def do_task(self, task, study_id, workflow_id, *args, **kwargs):
uids = self.get_uids(task, args)
if isinstance(uids, str):
ApprovalService.add_approval(study_id, workflow_id, args)
elif isinstance(uids, list):
for id in uids:
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:
raise ApiError(code="missing_argument",
message="The RequestApproval script requires at least one argument. The "
"the name of the variable in the task data that contains user"
"id to process. Multiple arguments are accepted.")
uids = []
for arg in args:
id = task.workflow.script_engine.evaluate_expression(task, arg)
uids.append(id)
if not isinstance(id, str):
raise ApiError(code="invalid_argument",
message="The RequestApproval script requires 1 argument. The "
"the name of the variable in the task data that contains user"
"ids to process. This must point to an array or a string, but "
"it currently points to a %s " % uids.__class__.__name__)
return uids

View File

@ -1,385 +0,0 @@
import json
import pickle
import sys
from base64 import b64decode
from datetime import datetime, timedelta
from sqlalchemy import desc, func
from crc import app, db, session
from crc.api.common import ApiError
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
from crc.services.ldap_service import LdapService
from crc.services.mails import (
send_ramp_up_submission_email,
send_ramp_up_approval_request_email,
send_ramp_up_approval_request_first_review_email,
send_ramp_up_approved_email,
send_ramp_up_denied_email,
send_ramp_up_denied_email_to_approver
)
class ApprovalService(object):
"""Provides common tools for working with an Approval"""
@staticmethod
def __one_approval_from_study(study, approver_uid = None, status=None,
include_cancelled=True):
"""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 not include_cancelled:
query=query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value)
approvals = query.all() # All non-cancelled approvals.
for approval_model in approvals:
if approval_model.approver_uid == approver_uid:
main_approval = approval_model
else:
related_approvals.append(approval_model)
# IF WE ARE JUST RETURNING ALL OF THE APPROVALS PER STUDY
if not main_approval and len(related_approvals) > 0:
main_approval = related_approvals[0]
related_approvals = related_approvals[1:]
if main_approval is not None: # May be null if the study has no approvals.
final_status = ApprovalService.__calculate_overall_approval_status(main_approval, related_approvals)
if status and final_status != status: return # Now that we are certain of the status, filter on it.
main_approval = Approval.from_model(main_approval)
main_approval.status = final_status
for ra in related_approvals:
main_approval.related_approvals.append(Approval.from_model(ra))
return main_approval
@staticmethod
def __calculate_overall_approval_status(approval, related):
# In the case of pending approvals, check to see if there is a related approval
# that proceeds this approval - and if it is declined, or still pending, then change
# the state of the approval to be Declined, or Waiting respectively.
if approval.status == ApprovalStatus.PENDING.value:
for ra in related:
if ra.id < approval.id:
if ra.status == ApprovalStatus.DECLINED.value or ra.status == ApprovalStatus.CANCELED.value:
return ra.status # If any prior approval id declined or cancelled so is this approval.
elif ra.status == ApprovalStatus.PENDING.value:
return ApprovalStatus.AWAITING.value # if any prior approval is pending, then this is waiting.
return approval.status
else:
return approval.status
@staticmethod
def get_approvals_per_user(approver_uid, status=None, include_cancelled=False):
"""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,
status, include_cancelled)
if approval:
approvals.append(approval)
return approvals
@staticmethod
def get_all_approvals(include_cancelled=True):
"""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, include_cancelled=include_cancelled)
if approval:
approvals.append(approval)
return approvals
@staticmethod
def get_approvals_for_study(study_id, include_cancelled=True):
"""Returns an array of Approval objects for the study, it does not
compute the related approvals."""
query = session.query(ApprovalModel).filter_by(study_id=study_id)
if not include_cancelled:
query = query.filter(ApprovalModel.status != ApprovalStatus.CANCELED.value)
db_approvals = query.all()
return [Approval.from_model(approval_model) for approval_model in db_approvals]
@staticmethod
def get_approval_details(approval):
"""Returns a list of packed approval details, obtained from
the task data sent during the workflow """
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
if approval.status != ApprovalStatus.APPROVED.value:
return {}
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')
pi_supervisor = extract_value(last_task, 'PISupervisor')['value']
review_complete = 'AllRequiredTraining' in training_val
pi_uid = workflow.study.primary_investigator_id
pi_details = LdapService.user_info(pi_uid)
details = {
'Supervisor': pi_supervisor,
'PI_Details': pi_details,
'Review': review_complete
}
details['person_details'] = []
details['person_details'].append(pi_details)
for person in personnel:
uid = person['PersonnelComputingID']['value']
details['person_details'].append(LdapService.user_info(uid))
return details
@staticmethod
def get_health_attesting_records():
"""Return a list with prepared information related to all approvals """
approvals = ApprovalService.get_all_approvals(include_cancelled=False)
health_attesting_rows = [
['university_computing_id',
'last_name',
'first_name',
'department',
'job_title',
'supervisor_university_computing_id']
]
for approval in approvals:
try:
details = ApprovalService.get_approval_details(approval)
if not details:
continue
for person in details['person_details']:
first_name = person.given_name
last_name = person.display_name.replace(first_name, '').strip()
record = [
person.uid,
last_name,
first_name,
'',
'Academic Researcher',
details['Supervisor'] if person.uid == details['person_details'][0].uid else 'askresearch'
]
if record not in health_attesting_rows:
health_attesting_rows.append(record)
except Exception as e:
app.logger.error(f'Error pulling data for workflow {approval.workflow_id}', exc_info=True)
return health_attesting_rows
@staticmethod
def get_not_really_csv_content():
approvals = ApprovalService.get_all_approvals(include_cancelled=False)
output = []
errors = []
for approval in approvals:
try:
details = ApprovalService.get_approval_details(approval)
for person in details['person_details']:
record = {
"study_id": approval.study_id,
"pi_uid": details['PI_Details'].uid,
"pi": details['PI_Details'].display_name,
"name": person.display_name,
"uid": person.uid,
"email": person.email_address,
"supervisor": details['Supervisor'] if person.uid == details['person_details'][0].uid else "",
"review_complete": details['Review'],
}
output.append(record)
except Exception as e:
errors.append(
f'Error pulling data for workflow #{approval.workflow_id} '
f'(Approval status: {approval.status} - '
f'More details in Sentry): {str(e)}'
)
# Detailed information sent to Sentry
app.logger.error(f'Error pulling data for workflow {approval.workflow_id}', exc_info=True)
return {"results": output, "errors": errors }
@staticmethod
def update_approval(approval_id, approver_uid):
"""Update a specific approval
NOTE: Actual update happens in the API layer, this
funtion is currently in charge of only sending
corresponding emails
"""
db_approval = session.query(ApprovalModel).get(approval_id)
status = db_approval.status
if db_approval:
if status == ApprovalStatus.APPROVED.value:
# second_approval = ApprovalModel().query.filter_by(
# study_id=db_approval.study_id, workflow_id=db_approval.workflow_id,
# status=ApprovalStatus.PENDING.value, version=db_approval.version).first()
# if second_approval:
# send rrp approval request for second approver
ldap_service = LdapService()
pi_user_info = ldap_service.user_info(db_approval.study.primary_investigator_id)
approver_info = ldap_service.user_info(approver_uid)
# send rrp submission
mail_result = send_ramp_up_approved_email(
'askresearch@virginia.edu',
[pi_user_info.email_address],
f'{approver_info.display_name} - ({approver_info.uid})'
)
if mail_result:
app.logger.error(mail_result, exc_info=True)
elif status == ApprovalStatus.DECLINED.value:
ldap_service = LdapService()
pi_user_info = ldap_service.user_info(db_approval.study.primary_investigator_id)
approver_info = ldap_service.user_info(approver_uid)
# send rrp submission
mail_result = send_ramp_up_denied_email(
'askresearch@virginia.edu',
[pi_user_info.email_address],
f'{approver_info.display_name} - ({approver_info.uid})'
)
if mail_result:
app.logger.error(mail_result, exc_info=True)
first_approval = ApprovalModel().query.filter_by(
study_id=db_approval.study_id, workflow_id=db_approval.workflow_id,
status=ApprovalStatus.APPROVED.value, version=db_approval.version).first()
if first_approval:
# Second approver denies
first_approver_info = ldap_service.user_info(first_approval.approver_uid)
approver_email = [first_approver_info.email_address] if first_approver_info.email_address else app.config['FALLBACK_EMAILS']
# send rrp denied by second approver email to first approver
mail_result = send_ramp_up_denied_email_to_approver(
'askresearch@virginia.edu',
approver_email,
f'{pi_user_info.display_name} - ({pi_user_info.uid})',
f'{approver_info.display_name} - ({approver_info.uid})'
)
if mail_result:
app.logger.error(mail_result, exc_info=True)
return db_approval
@staticmethod
def add_approval(study_id, workflow_id, approver_uid):
"""we might have multiple approvals for a workflow, so I would expect this
method to get called multiple times for the same workflow. This will
only add a new approval if no approval already exists for the approver_uid,
unless the workflow has changed, at which point, it will CANCEL any
pending approvals and create a new approval for the latest version
of the workflow."""
# Find any existing approvals for this workflow.
latest_approval_requests = db.session.query(ApprovalModel). \
filter(ApprovalModel.workflow_id == workflow_id). \
order_by(desc(ApprovalModel.version))
latest_approver_request = latest_approval_requests.filter(ApprovalModel.approver_uid == approver_uid).first()
# Construct as hash of the latest files to see if things have changed since
# the last approval.
workflow = db.session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first()
workflow_data_files = FileService.get_workflow_data_files(workflow_id)
current_data_file_ids = list(data_file.id for data_file in workflow_data_files)
if len(current_data_file_ids) == 0:
raise ApiError("invalid_workflow_approval", "You can't create an approval for a workflow that has"
"no files to approve in it.")
# If an existing approval request exists and no changes were made, do nothing.
# If there is an existing approval request for a previous version of the workflow
# then add a new request, and cancel any waiting/pending requests.
if latest_approver_request:
request_file_ids = list(file.file_data_id for file in latest_approver_request.approval_files)
current_data_file_ids.sort()
request_file_ids.sort()
other_approver = latest_approval_requests.filter(ApprovalModel.approver_uid != approver_uid).first()
if current_data_file_ids == request_file_ids:
return # This approval already exists or we're updating other approver.
else:
for approval_request in latest_approval_requests:
if (approval_request.version == latest_approver_request.version and
approval_request.status != ApprovalStatus.CANCELED.value):
approval_request.status = ApprovalStatus.CANCELED.value
db.session.add(approval_request)
version = latest_approver_request.version + 1
else:
version = 1
model = ApprovalModel(study_id=study_id, workflow_id=workflow_id,
approver_uid=approver_uid, status=ApprovalStatus.PENDING.value,
message="", date_created=datetime.now(),
version=version)
approval_files = ApprovalService._create_approval_files(workflow_data_files, model)
# Check approvals count
approvals_count = ApprovalModel().query.filter_by(study_id=study_id, workflow_id=workflow_id,
version=version).count()
db.session.add(model)
db.session.add_all(approval_files)
db.session.commit()
# Send first email
if approvals_count == 0:
ldap_service = LdapService()
pi_user_info = ldap_service.user_info(model.study.primary_investigator_id)
approver_info = ldap_service.user_info(approver_uid)
# send rrp submission
mail_result = send_ramp_up_submission_email(
'askresearch@virginia.edu',
[pi_user_info.email_address],
f'{approver_info.display_name} - ({approver_info.uid})'
)
if mail_result:
app.logger.error(mail_result, exc_info=True)
# send rrp approval request for first approver
# enhance the second part in case it bombs
approver_email = [approver_info.email_address] if approver_info.email_address else app.config['FALLBACK_EMAILS']
mail_result = send_ramp_up_approval_request_first_review_email(
'askresearch@virginia.edu',
approver_email,
f'{pi_user_info.display_name} - ({pi_user_info.uid})'
)
if mail_result:
app.logger.error(mail_result, exc_info=True)
@staticmethod
def _create_approval_files(workflow_data_files, approval):
"""Currently based exclusively on the status of files associated with a workflow."""
file_approval_models = []
for file_data in workflow_data_files:
file_approval_models.append(ApprovalFile(file_data_id=file_data.id,
approval=approval))
return file_approval_models

View File

@ -9,7 +9,6 @@ from ldap3.core.exceptions import LDAPSocketOpenError
from crc import db, session, app
from crc.api.common import ApiError
from crc.models.approval import ApprovalFile, ApprovalModel
from crc.models.file import FileDataModel, FileModel, FileModelSchema, File, LookupFileModel, LookupDataModel
from crc.models.ldap import LdapSchema
from crc.models.protocol_builder import ProtocolBuilderStudy, ProtocolBuilderStatus
@ -18,7 +17,6 @@ from crc.models.study import StudyModel, Study, StudyStatus, Category, WorkflowM
from crc.models.task_event import TaskEventModel, TaskEvent
from crc.models.workflow import WorkflowSpecCategoryModel, WorkflowModel, WorkflowSpecModel, WorkflowState, \
WorkflowStatus, WorkflowSpecDependencyFile
from crc.services.approval_service import ApprovalService
from crc.services.file_service import FileService
from crc.services.ldap_service import LdapService
from crc.services.protocol_builder import ProtocolBuilderService
@ -69,7 +67,6 @@ class StudyService(object):
study.last_activity_date = last_event.date
study.categories = StudyService.get_categories()
workflow_metas = StudyService.__get_workflow_metas(study_id)
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)
@ -108,10 +105,6 @@ class StudyService(object):
session.query(WorkflowSpecDependencyFile).filter_by(workflow_id=workflow_id).delete(synchronize_session='fetch')
session.query(FileModel).filter_by(workflow_id=workflow_id).update({'archived': True, 'workflow_id': None})
# Todo: Remove approvals completely.
session.query(ApprovalFile).filter(ApprovalModel.workflow_id == workflow_id).delete(synchronize_session='fetch')
session.query(ApprovalModel).filter_by(workflow_id=workflow.id).delete()
session.delete(workflow)
session.commit()
@ -125,32 +118,6 @@ class StudyService(object):
categories.append(Category(cat_model))
return categories
@staticmethod
def get_approvals(study_id):
"""Returns a list of non-hidden approval workflows."""
study = StudyService.get_study(study_id)
cat = next(c for c in study.categories if c.name == 'approvals')
approvals = []
for wf in cat.workflows:
if wf.state is WorkflowState.hidden:
continue
workflow = db.session.query(WorkflowModel).filter_by(id=wf.id).first()
approvals.append({
'study_id': study_id,
'workflow_id': wf.id,
'display_name': wf.display_name,
'display_order': wf.display_order or 0,
'name': wf.name,
'state': wf.state.value,
'status': wf.status.value,
'workflow_spec_id': workflow.workflow_spec_id,
})
approvals.sort(key=lambda k: k['display_order'])
return approvals
@staticmethod
def get_documents_status(study_id):
"""Returns a list of documents related to the study, and any file information

View File

@ -158,7 +158,9 @@ class WorkflowService(object):
if field.has_property(Task.FIELD_PROP_HIDE_EXPRESSION) and field.has_validation(Task.FIELD_CONSTRAINT_REQUIRED):
if not field.has_property(Task.FIELD_PROP_VALUE_EXPRESSION) or not (hasattr(field, 'default_value')):
raise ApiError(code='hidden and required field missing default',
message='Fields that are required but can be hidden must have either a default value or a value_expression')
message='Fields that are required but can be hidden must have either a default value or a value_expression',
task_id='task.id',
task_name=task.get_name())
# If the field is hidden and not required, it should not produce a value.
if field.has_property(Task.FIELD_PROP_HIDE_EXPRESSION) and not field.has_validation(Task.FIELD_CONSTRAINT_REQUIRED):
@ -212,7 +214,7 @@ class WorkflowService(object):
if not id[0].isalpha():
return False
for char in id[1:len(id)]:
if char.isalnum() or char == '_':
if char.isalnum() or char == '_' or char == '.':
pass
else:
return False

View File

@ -0,0 +1,48 @@
"""empty message
Revision ID: ff29528a9909
Revises: c6261ac7a7bc
Create Date: 2021-02-16 09:00:38.674050
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'ff29528a9909'
down_revision = 'c6261ac7a7bc'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('approval_file')
op.drop_table('approval')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('approval_file',
sa.Column('approval_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('file_data_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['approval_id'], ['approval.id'], name='approval_file_approval_id_fkey'),
sa.ForeignKeyConstraint(['file_data_id'], ['file_data.id'], name='approval_file_file_data_id_fkey')
)
op.create_table('approval',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('study_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('workflow_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('approver_uid', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('message', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('date_created', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('version', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('date_approved', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['study_id'], ['study.id'], name='approval_study_id_fkey'),
sa.ForeignKeyConstraint(['workflow_id'], ['workflow.id'], name='approval_workflow_id_fkey'),
sa.PrimaryKeyConstraint('id', name='approval_pkey')
)
# ### end Alembic commands ###

View File

@ -1,239 +0,0 @@
import json
import random
import string
from flask import g
from tests.base_test import BaseTest
from crc import session, db
from crc.models.approval import ApprovalModel, ApprovalStatus
from crc.models.study import StudyModel
from crc.models.workflow import WorkflowModel
class TestApprovals(BaseTest):
def setUp(self):
"""Initial setup shared by all TestApprovals tests"""
self.load_example_data()
# Add a study with 2 approvers
study_workflow_approvals_1 = self._create_study_workflow_approvals(
user_uid="dhf8r", title="first study", primary_investigator_id="lb3dp",
approver_uids=["lb3dp", "dhf8r"], statuses=[ApprovalStatus.PENDING.value, ApprovalStatus.PENDING.value]
)
self.study = study_workflow_approvals_1['study']
self.workflow = study_workflow_approvals_1['workflow']
self.approval = study_workflow_approvals_1['approvals'][0]
self.approval_2 = study_workflow_approvals_1['approvals'][1]
# Add a study with 1 approver
study_workflow_approvals_2 = self._create_study_workflow_approvals(
user_uid="dhf8r", title="second study", primary_investigator_id="dhf8r",
approver_uids=["lb3dp"], statuses=[ApprovalStatus.PENDING.value]
)
self.unrelated_study = study_workflow_approvals_2['study']
self.unrelated_workflow = study_workflow_approvals_2['workflow']
self.approval_3 = study_workflow_approvals_2['approvals'][0]
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', headers=self.logged_in_headers())
self.assert_success(rv)
response = json.loads(rv.get_data(as_text=True))
# Stored approvals are 3
approvals_count = ApprovalModel.query.count()
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 = response[0]
self.assertEqual(approval['approver']['uid'], approver_uid)
def test_list_approvals_as_user(self):
"""All approvals as different user"""
rv = self.app.get('/v1.0/approval?as_user=lb3dp', 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 for user ld3dp, we should get one
# approval back per study (2 studies), and that approval should have one related approval.
response_count = len(response)
self.assertEqual(2, response_count)
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.
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}
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_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(approval)
# Updated record should now have the data sent to the endpoint
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):
self.load_test_spec('two_forms')
self._add_lots_of_random_approvals(n=50, workflow_spec_name='two_forms')
# Get all workflows
workflows = db.session.query(WorkflowModel).filter_by(workflow_spec_id='two_forms').all()
# For each workflow, complete all tasks
for workflow in workflows:
workflow_api = self.get_workflow_api(workflow, user_uid=workflow.study.user_uid)
self.assertEqual('two_forms', workflow_api.workflow_spec_id)
# Log current user out.
g.user = None
self.assertIsNone(g.user)
# Complete the form for Step one and post it.
self.complete_form(workflow, workflow_api.next_task, {"color": "blue"}, error_code=None, user_uid=workflow.study.user_uid)
# Get the next Task
workflow_api = self.get_workflow_api(workflow, user_uid=workflow.study.user_uid)
self.assertEqual("StepTwo", workflow_api.next_task.name)
# Get all user Tasks and check that the data have been saved
task = workflow_api.next_task
self.assertIsNotNone(task.data)
for val in task.data.values():
self.assertIsNotNone(val)
rv = self.app.get(f'/v1.0/approval/csv', headers=self.logged_in_headers())
self.assert_success(rv)
def test_all_approvals(self):
self._add_lots_of_random_approvals()
not_canceled = session.query(ApprovalModel).filter(ApprovalModel.status != 'CANCELED').all()
not_canceled_study_ids = []
for a in not_canceled:
if a.study_id not in not_canceled_study_ids:
not_canceled_study_ids.append(a.study_id)
rv_all = self.app.get(f'/v1.0/all_approvals?status=false', headers=self.logged_in_headers())
self.assert_success(rv_all)
all_data = json.loads(rv_all.get_data(as_text=True))
self.assertEqual(len(all_data), len(not_canceled_study_ids), 'Should return all non-canceled approvals, grouped by study')
all_approvals = session.query(ApprovalModel).all()
all_approvals_study_ids = []
for a in all_approvals:
if a.study_id not in all_approvals_study_ids:
all_approvals_study_ids.append(a.study_id)
rv_all = self.app.get(f'/v1.0/all_approvals?status=true', headers=self.logged_in_headers())
self.assert_success(rv_all)
all_data = json.loads(rv_all.get_data(as_text=True))
self.assertEqual(len(all_data), len(all_approvals_study_ids), 'Should return all approvals, grouped by study')
def test_approvals_counts(self):
statuses = [name for name, value in ApprovalStatus.__members__.items()]
self._add_lots_of_random_approvals()
# Get the counts
rv_counts = self.app.get(f'/v1.0/approval-counts', headers=self.logged_in_headers())
self.assert_success(rv_counts)
counts = json.loads(rv_counts.get_data(as_text=True))
# Get the actual approvals
rv_approvals = self.app.get(f'/v1.0/approval', headers=self.logged_in_headers())
self.assert_success(rv_approvals)
approvals = json.loads(rv_approvals.get_data(as_text=True))
# Tally up the number of approvals in each status category
manual_counts = {}
for status in statuses:
manual_counts[status] = 0
for approval in approvals:
manual_counts[approval['status']] += 1
# Numbers in each category should match
for status in statuses:
self.assertEqual(counts[status], manual_counts[status], 'Approval counts for status %s should match' % status)
# Total number of approvals should match
total_counts = sum(counts[status] for status in statuses)
self.assertEqual(total_counts, len(approvals), 'Total approval counts for user should match number of approvals for user')
def _add_lots_of_random_approvals(self, n=100, workflow_spec_name="random_fact"):
num_studies_before = db.session.query(StudyModel).count()
statuses = [name for name, value in ApprovalStatus.__members__.items()]
# Add a whole bunch of approvals with random statuses
for i in range(n):
approver_uids = random.choices(["lb3dp", "dhf8r"])
self._create_study_workflow_approvals(
user_uid=random.choice(["lb3dp", "dhf8r"]),
title="".join(random.choices(string.ascii_lowercase, k=64)),
primary_investigator_id=random.choice(["lb3dp", "dhf8r"]),
approver_uids=approver_uids,
statuses=random.choices(statuses, k=len(approver_uids)),
workflow_spec_name=workflow_spec_name
)
session.flush()
num_studies_after = db.session.query(StudyModel).count()
self.assertEqual(num_studies_after, num_studies_before + n)

View File

@ -1,125 +0,0 @@
from tests.base_test import BaseTest
from crc import db
from crc.models.approval import ApprovalModel
from crc.services.approval_service import ApprovalService, ApprovalStatus
from crc.services.file_service import FileService
from crc.services.workflow_processor import WorkflowProcessor
class TestApprovalsService(BaseTest):
def test_create_approval_record(self):
self.create_reference_document()
workflow = self.create_workflow("empty_workflow")
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr" )
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
self.assertEqual(1, db.session.query(ApprovalModel).count())
model = db.session.query(ApprovalModel).first()
self.assertEqual(workflow.study_id, model.study_id)
self.assertEqual(workflow.id, model.workflow_id)
self.assertEqual("dhf8r", model.approver_uid)
self.assertEqual(1, model.version)
def test_new_requests_dont_add_if_approval_exists_for_current_workflow(self):
self.create_reference_document()
workflow = self.create_workflow("empty_workflow")
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr" )
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
self.assertEqual(1, db.session.query(ApprovalModel).count())
model = db.session.query(ApprovalModel).first()
self.assertEqual(1, model.version)
def test_new_approval_requests_after_file_modification_create_new_requests(self):
self.load_example_data()
self.create_reference_document()
workflow = self.create_workflow('empty_workflow')
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="AD_CoCAppr")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
self.assertEqual(2, db.session.query(ApprovalModel).count())
models = db.session.query(ApprovalModel).order_by(ApprovalModel.version).all()
self.assertEqual(1, models[0].version)
self.assertEqual(2, models[1].version)
def test_get_health_attesting_records(self):
self.load_example_data()
self.create_reference_document()
workflow = self.create_workflow('empty_workflow')
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="AD_CoCAppr")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
records = ApprovalService.get_health_attesting_records()
self.assertEqual(len(records), 1)
def test_get_not_really_csv_content(self):
self.load_example_data()
self.create_reference_document()
workflow = self.create_workflow('empty_workflow')
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="AD_CoCAppr")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
records = ApprovalService.get_not_really_csv_content()
self.assertEqual(len(records), 2)
def test_new_approval_cancels_all_previous_approvals(self):
self.create_reference_document()
workflow = self.create_workflow("empty_workflow")
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr" )
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="lb3dp")
current_count = ApprovalModel.query.count()
self.assertTrue(current_count, 2)
FileService.add_workflow_file(workflow_id=workflow.id,
name="borderline.png", content_type="text",
binary_data=b'906090', irb_doc_code="AD_CoCAppr" )
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
current_count = ApprovalModel.query.count()
canceled_count = ApprovalModel.query.filter(ApprovalModel.status == ApprovalStatus.CANCELED.value)
self.assertTrue(current_count, 2)
self.assertTrue(current_count, 3)
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="lb3dp")
current_count = ApprovalModel.query.count()
self.assertTrue(current_count, 4)
def test_new_approval_sends_proper_emails(self):
self.assertEqual(1, 1)
def test_new_approval_failed_ldap_lookup(self):
# failed lookup should send email to sartographysupport@googlegroups.com + Cheryl
self.assertEqual(1, 1)
def test_approve_approval_sends_proper_emails(self):
self.assertEqual(1, 1)
def test_deny_approval_sends_proper_emails(self):
self.assertEqual(1, 1)

View File

@ -1,68 +0,0 @@
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
from crc import db
from crc.models.approval import ApprovalModel
class TestRequestApprovalScript(BaseTest):
def test_do_task(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':'lb3dp'}}
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.assertEqual(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.assertEqual(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()
self.create_reference_document()
workflow = self.create_workflow('empty_workflow')
processor = WorkflowProcessor(workflow)
task = processor.next_task()
task.data = {"approvals": {'dhf8r':["invalid"], 'lb3dp':"invalid"}}
script = RequestApproval()
with self.assertRaises(ApiError):
script.do_task(task, workflow.study_id, workflow.id, "approvals")
def test_do_task_validate_only(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':'lb3dp'}}
script = RequestApproval()
script.do_task_validate_only(task, workflow.study_id, workflow.id, "study.approval1")
self.assertEqual(0, db.session.query(ApprovalModel).count())

View File

@ -13,7 +13,6 @@ from sqlalchemy import Sequence
from crc import app, db, session
from crc.models.api_models import WorkflowApiSchema, MultiInstanceType
from crc.models.approval import ApprovalModel, ApprovalStatus
from crc.models.file import FileModel, FileDataModel, CONTENT_TYPES
from crc.models.task_event import TaskEventModel
from crc.models.study import StudyModel, StudyStatus

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_06dpn07" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.2.0">
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_06dpn07" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.5.0">
<bpmn:process id="Process_1iqn8uk" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0dbfi6t</bpmn:outgoing>
@ -8,16 +8,16 @@
<bpmn:manualTask id="Activity_09rr8u7" name="Hello">
<bpmn:documentation>&lt;H1&gt;Hello&lt;/H1&gt;</bpmn:documentation>
<bpmn:incoming>Flow_0dbfi6t</bpmn:incoming>
<bpmn:outgoing>Flow_02rje6r</bpmn:outgoing>
<bpmn:outgoing>SequenceFlow_0o1egpu</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="Flow_02rje6r" sourceRef="Activity_09rr8u7" targetRef="Activity_GetName" />
<bpmn:userTask id="Activity_GetName" name="Get Name" camunda:formKey="GetName">
<bpmn:extensionElements>
<camunda:formData>
<camunda:formField id="name" label="Name" type="string" defaultValue="World" />
<camunda:formField id="me.name" label="Enter" type="string" />
</camunda:formData>
</bpmn:extensionElements>
<bpmn:incoming>Flow_02rje6r</bpmn:incoming>
<bpmn:incoming>SequenceFlow_1hytves</bpmn:incoming>
<bpmn:outgoing>Flow_1iphrck</bpmn:outgoing>
</bpmn:userTask>
<bpmn:sequenceFlow id="Flow_1iphrck" sourceRef="Activity_GetName" targetRef="Activity_GetTitle" />
@ -40,47 +40,61 @@
<bpmn:incoming>Flow_0hbiuz4</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0hbiuz4" sourceRef="Activity_SayHello" targetRef="Event_13veu8t" />
<bpmn:sequenceFlow id="SequenceFlow_0o1egpu" sourceRef="Activity_09rr8u7" targetRef="Task_SeedData" />
<bpmn:scriptTask id="Task_SeedData" name="Seed Data">
<bpmn:incoming>SequenceFlow_0o1egpu</bpmn:incoming>
<bpmn:outgoing>SequenceFlow_1hytves</bpmn:outgoing>
<bpmn:script>me = {'name': 'my_name'}</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="SequenceFlow_1hytves" sourceRef="Task_SeedData" targetRef="Activity_GetName" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1iqn8uk">
<bpmndi:BPMNEdge id="Flow_0dbfi6t_di" bpmnElement="Flow_0dbfi6t">
<di:waypoint x="215" y="117" />
<di:waypoint x="270" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_02rje6r_di" bpmnElement="Flow_02rje6r">
<di:waypoint x="370" y="117" />
<di:waypoint x="430" y="117" />
<di:waypoint x="195" y="117" />
<di:waypoint x="250" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1iphrck_di" bpmnElement="Flow_1iphrck">
<di:waypoint x="530" y="117" />
<di:waypoint x="590" y="117" />
<di:waypoint x="660" y="117" />
<di:waypoint x="720" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0cxh51h_di" bpmnElement="Flow_0cxh51h">
<di:waypoint x="690" y="117" />
<di:waypoint x="750" y="117" />
<di:waypoint x="820" y="117" />
<di:waypoint x="880" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0hbiuz4_di" bpmnElement="Flow_0hbiuz4">
<di:waypoint x="850" y="117" />
<di:waypoint x="912" y="117" />
<di:waypoint x="980" y="117" />
<di:waypoint x="1042" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="99" width="36" height="36" />
<dc:Bounds x="159" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_132twgr_di" bpmnElement="Activity_09rr8u7">
<dc:Bounds x="270" y="77" width="100" height="80" />
<dc:Bounds x="250" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0it9qzi_di" bpmnElement="Activity_GetName">
<dc:Bounds x="430" y="77" width="100" height="80" />
<dc:Bounds x="560" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_19s9l3h_di" bpmnElement="Activity_GetTitle">
<dc:Bounds x="590" y="77" width="100" height="80" />
<dc:Bounds x="720" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_05qpklh_di" bpmnElement="Activity_SayHello">
<dc:Bounds x="750" y="77" width="100" height="80" />
<dc:Bounds x="880" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_13veu8t_di" bpmnElement="Event_13veu8t">
<dc:Bounds x="912" y="99" width="36" height="36" />
<dc:Bounds x="1042" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_0o1egpu_di" bpmnElement="SequenceFlow_0o1egpu">
<di:waypoint x="350" y="117" />
<di:waypoint x="410" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="ScriptTask_09ok9u2_di" bpmnElement="Task_SeedData">
<dc:Bounds x="410" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="SequenceFlow_1hytves_di" bpmnElement="SequenceFlow_1hytves">
<di:waypoint x="510" y="117" />
<di:waypoint x="560" y="117" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -1,7 +1,6 @@
from tests.base_test import BaseTest
from crc import session
from crc.models.approval import ApprovalModel, ApprovalStatus
from crc.models.email import EmailModel
from crc.services.email_service import EmailService

View File

@ -9,8 +9,6 @@ from crc.models.workflow import WorkflowSpecModel
from crc.services.file_service import FileService
from crc.services.workflow_processor import WorkflowProcessor
from example_data import ExampleDataLoader
from crc.services.approval_service import ApprovalService
from crc.models.approval import ApprovalModel, ApprovalStatus
class TestFilesApi(BaseTest):
@ -251,39 +249,6 @@ class TestFilesApi(BaseTest):
rv = self.app.get('/v1.0/file/%i' % file_id, headers=self.logged_in_headers())
self.assertEqual(404, rv.status_code)
def test_delete_file_after_approval(self):
self.create_reference_document()
workflow = self.create_workflow("empty_workflow")
FileService.add_workflow_file(workflow_id=workflow.id,
name="anything.png", content_type="text",
binary_data=b'5678', irb_doc_code="UVACompl_PRCAppr")
FileService.add_workflow_file(workflow_id=workflow.id,
name="anotother_anything.png", content_type="text",
binary_data=b'1234', irb_doc_code="Study_App_Doc")
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
file = session.query(FileModel).\
filter(FileModel.workflow_id == workflow.id).\
filter(FileModel.name == "anything.png").first()
self.assertFalse(file.archived)
rv = self.app.get('/v1.0/file/%i' % file.id, headers=self.logged_in_headers())
self.assert_success(rv)
rv = self.app.delete('/v1.0/file/%i' % file.id, headers=self.logged_in_headers())
self.assert_success(rv)
session.refresh(file)
self.assertTrue(file.archived)
ApprovalService.add_approval(study_id=workflow.study_id, workflow_id=workflow.id, approver_uid="dhf8r")
approvals = session.query(ApprovalModel)\
.filter(ApprovalModel.status == ApprovalStatus.PENDING.value)\
.filter(ApprovalModel.study_id == workflow.study_id).all()
self.assertEqual(1, len(approvals))
self.assertEqual(1, len(approvals[0].approval_files))
def test_change_primary_bpmn(self):

View File

@ -9,14 +9,13 @@ from unittest.mock import patch
from crc import session, app
from crc.models.protocol_builder import ProtocolBuilderStatus, \
ProtocolBuilderStudySchema
from crc.models.approval import ApprovalStatus
from crc.models.file import FileModel
from crc.models.task_event import TaskEventModel
from crc.models.study import StudyEvent, StudyModel, StudySchema, StudyStatus, StudyEventType
from crc.models.workflow import WorkflowSpecModel, WorkflowModel
from crc.services.file_service import FileService
from crc.services.workflow_processor import WorkflowProcessor
from crc.services.workflow_service import WorkflowService
class TestStudyApi(BaseTest):
@ -100,22 +99,6 @@ class TestStudyApi(BaseTest):
# TODO: WRITE A TEST FOR STUDY FILES
def test_get_study_has_details_about_approvals(self):
self.load_example_data()
full_study = self._create_study_workflow_approvals(
user_uid="dhf8r", title="first study", primary_investigator_id="lb3dp",
approver_uids=["lb3dp", "dhf8r"], statuses=[ApprovalStatus.PENDING.value, ApprovalStatus.PENDING.value]
)
api_response = self.app.get('/v1.0/study/%i' % full_study['study'].id,
headers=self.logged_in_headers(), content_type="application/json")
self.assert_success(api_response)
study = StudySchema().loads(api_response.get_data(as_text=True))
self.assertEqual(len(study.approvals), 2)
for approval in study.approvals:
self.assertEqual(full_study['study'].title, approval['title'])
def test_add_study(self):
self.load_example_data()

View File

@ -100,9 +100,6 @@ class TestStudyService(BaseTest):
self.assertEqual(1, workflow.completed_tasks)
# Get approvals
approvals = StudyService.get_approvals(studies[0].id)
self.assertGreater(len(approvals), 0)
self.assertIsNotNone(approvals[0]['display_order'])
@patch('crc.services.protocol_builder.ProtocolBuilderService.get_required_docs') # mock_docs
def test_get_required_docs(self, mock_docs):

View File

@ -12,3 +12,14 @@ class TestFormFieldName(BaseTest):
self.assertEqual(json_data[0]['message'],
'When populating all fields ... Invalid Field name: "user-title". A field ID must begin '
'with a letter, and can only contain letters, numbers, and "_"')
def test_form_field_name_with_period(self):
workflow = self.create_workflow('workflow_form_field_name')
workflow_api = self.get_workflow_api(workflow)
first_task = workflow_api.next_task
self.complete_form(workflow_api, first_task, {})
workflow_api = self.get_workflow_api(workflow)
second_task = workflow_api.next_task
self.assertEqual('me.name', second_task.form['fields'][1]['id'])

View File

@ -12,6 +12,8 @@ class TestWorkflowHiddenRequiredField(BaseTest):
json_data = json.loads(rv.get_data(as_text=True))
self.assertEqual(json_data[0]['code'], 'hidden and required field missing default')
self.assertIn('task_id', json_data[0])
self.assertIn('task_name', json_data[0])
def test_default_used(self):
# If a field is hidden and required, make sure we use the default value