def wrapper(*args, **kwargs): event = args[0] correlation_id = event["correlation_id"] logger = event["logger"] parameters = event[parameter_type] if parameter_type == "body": parameters = json.loads(parameters) default_parameter = parameters.get(default_parameter_name) anon_id = parameters.get("anon_project_specific_user_id") if default_parameter and anon_id: errorjson = { parameter_type: parameters, "correlation_id": str(correlation_id), } raise utils.DetailedValueError( f"This endpoint requires {parameter_type} parameter {default_parameter_name} or anon_project_specific_user_id, not both", errorjson, ) elif default_parameter: logger.info( "API call", extra={ default_parameter_name: default_parameter, "anon_project_specific_user_id": anon_id, "correlation_id": correlation_id, "event": args[0], }, ) elif anon_id: logger.info( "API call", extra={ default_parameter_name: default_parameter, "anon_project_specific_user_id": anon_id, "correlation_id": correlation_id, "event": args[0], }, ) parameters[default_parameter_name] = lookup_func( anon_id, correlation_id ) if parameter_type == "body": event[parameter_type] = json.dumps(parameters) else: # e.g. parameters is None or an empty dict errorjson = { parameter_type: parameters, "correlation_id": str(correlation_id), } raise utils.DetailedValueError( f"This endpoint requires {parameter_type} parameter user_task_id or anon_project_specific_user_id; none were given", errorjson, ) updated_args = (event, *args[1:]) result = func(*updated_args, **kwargs) return result
def patch_user_api(event, context): logger = event["logger"] correlation_id = event["correlation_id"] # get info supplied to api call user_id = event["pathParameters"]["id"] try: user_jsonpatch = JsonPatch.from_string(event["body"]) except InvalidJsonPatch: raise utils.DetailedValueError( "invalid jsonpatch", details={ "traceback": traceback.format_exc(), "correlation_id": correlation_id, }, ) # convert email to lowercase for p in user_jsonpatch: if p.get("path") == "/email": p["value"] = p["value"].lower() # strip leading and trailing spaces try: p["value"] = p["value"].strip() except KeyError: raise utils.DetailedValueError( "invalid jsonpatch", details={ "traceback": traceback.format_exc(), "correlation_id": correlation_id, }, ) logger.info( "API call", extra={ "user_id": user_id, "user_jsonpatch": user_jsonpatch, "correlation_id": correlation_id, "event": event, }, ) modified_time = utils.now_with_tz() # create an audit record of update, inc 'undo' patch entity_update = create_user_entity_update(user_id, user_jsonpatch, modified_time, correlation_id) patch_user(user_id, user_jsonpatch, modified_time, correlation_id) # on successful update save audit record entity_update.save() return {"statusCode": HTTPStatus.NO_CONTENT, "body": json.dumps("")}
def __init__(self, response_dict, correlation_id=None): self.survey_id = response_dict.pop("survey_id", None) self.response_id = response_dict.pop("response_id", None) self.project_task_id = str( utils.validate_uuid(response_dict.pop("project_task_id", None))) self.anon_project_specific_user_id = str( utils.validate_uuid( response_dict.pop("anon_project_specific_user_id", None))) self.anon_user_task_id = str( utils.validate_uuid(response_dict.pop("anon_user_task_id", None))) for required_parameter_name, value in [ ("survey_id", self.survey_id), ("response_id", self.response_id), ]: if not value: raise utils.DetailedValueError( f"Required parameter {required_parameter_name} not present in body of call", details={ "response_dict": response_dict, "correlation_id": correlation_id, }, ) self.response_dict = response_dict self.ddb_client = Dynamodb( stack_name=const.STACK_NAME, correlation_id=correlation_id, ) self.correlation_id = correlation_id
def _validate_status(status): if status in STATUS_CHOICES: return status else: errorjson = {"status": status} raise utils.DetailedValueError("invalid user_task status", errorjson)
def user_task_completed_handler(event, context): """ Handles user_task_completed events posted to EB bus. Note that the standard way to do this would be create a json patch entity and implement full patch functionality in user_task as in other patchable entities. """ detail_type = event["detail-type"] assert (detail_type == "user_task_completed" ), f"Unexpected detail-type: {detail_type}" correlation_id = event["correlation_id"] event_detail = event["detail"] try: user_task_id = event_detail["user_task_id"] except KeyError: try: anon_ut_id = event_detail["anon_user_task_id"] except KeyError: raise utils.DetailedValueError( "Mandatory data (user_task_id or anon_user_task_id) not found in event detail", details={ "event_detail": event_detail, }, ) user_task_id = anon_user_task_id_2_user_task_id( anon_ut_id, correlation_id) set_user_task_completed(user_task_id, correlation_id) return {"statusCode": HTTPStatus.NO_CONTENT}
def post_block(self, calendar_id, start, end, notes="automated block"): """ Args: calendar_id (int): acuity calendar id start (datetime.datetime): start time of block end (datetime.datetime): end time of block notes (str): any notes to include for the blocked off time Returns: """ body_params = { "calendarID": calendar_id, "start": start.strftime(self.strftime_format_str), "end": end.strftime(self.strftime_format_str), "notes": notes, } body_json = json.dumps(body_params) self.logger.debug( "Acuity API call", extra={ "body_params": body_params, "correlation_id": self.correlation_id, }, ) response = self.session.post(f"{self.base_url}blocks", data=body_json) if response.ok: return response.json() else: raise utils.DetailedValueError( f"Acuity post block call failed with response: {response.status_code}, {response.text}", details={}, )
def _format_consent_statements(self): counter = 0 custom_properties_dict = dict() for statement_dict in self.consent.consent_statements: counter += 1 key = list(statement_dict.keys())[0] custom_properties_dict[f"consent_row_{counter:02}"] = key custom_properties_dict[ f"consent_value_{counter:02}"] = statement_dict[key] if counter > CONSENT_ROWS_IN_TEMPLATE: raise utils.DetailedValueError( "Number of consent statements exceeds maximum supported by template", details={ "len_consent_statements": len(self.consent.consent_statements), "consent_statements": self.consent.consent_statements, "rows_in_template": CONSENT_ROWS_IN_TEMPLATE, "correlation_id": self.correlation_id, }, ) while counter < CONSENT_ROWS_IN_TEMPLATE: counter += 1 custom_properties_dict[f"consent_row_{counter:02}"] = str() custom_properties_dict[f"consent_value_{counter:02}"] = str() return custom_properties_dict
def from_json(cls, ugm_json, correlation_id): """ Creates new object as specified by JSON. Checks that attributes are present but does not check for referential integrity (ie that user and group exist) :param ugm_json: MUST contain: user_id, user_group_id, may OPTIONALLY include: id, created, modified :param correlation_id: :return: new ugm object """ try: user_id = utils.validate_uuid(ugm_json["user_id"]) user_group_id = utils.validate_uuid(ugm_json["user_group_id"]) except utils.DetailedValueError as err: # uuids are not valid err.add_correlation_id(correlation_id) raise err except KeyError as err: # mandatory data not present error_json = { "parameter": err.args[0], "correlation_id": str(correlation_id), } raise utils.DetailedValueError( "mandatory data missing", error_json ) from err ugm = cls(user_id, user_group_id, ugm_json, correlation_id) return ugm
def validate_status(s): if s in STATUS_CHOICES: return s else: errorjson = {"status": s} raise utils.DetailedValueError("invalid user_project status", errorjson)
def _notify_participant(self): custom_properties_dict = self._format_consent_statements() custom_properties_dict["consent_info_url"] = self.consent_info_url custom_properties_dict[ "project_short_name"] = self.consent.project_short_name ( custom_properties_dict["current_date"], custom_properties_dict["current_time"], ) = self._split_consent_datetime() email_dict = dict() email_dict["custom_properties"] = custom_properties_dict self.logger.info( "API call", extra={ "email_dict": email_dict, "correlation_id": self.correlation_id, }, ) template_name = self.template_name if self.consent.anon_project_specific_user_id: email_dict[ "to_recipient_id"] = self.consent.anon_project_specific_user_id elif self.to_recipient_email: email_dict["to_recipient_email"] = self.to_recipient_email else: raise utils.DetailedValueError( "Input consent event does not contain recipient info", details={ "consent_dict": self.consent_dict, }, ) return self.core_api_client.send_transactional_email( template_name=template_name, **email_dict)
def from_eb_event(cls, event): detail_type = event["detail-type"] assert ( detail_type == "user_interview_task" ), f"Unexpected detail-type: {detail_type}" task_response = super().from_eb_event(event=event) try: interview_task_id = task_response._detail.pop("interview_task_id") except KeyError: raise utils.DetailedValueError( "Mandatory interview_task_id data not found in user_interview_task event", details={ "event": event, }, ) return cls( response_id=task_response._response_id, event_time=task_response._event_time, anon_project_specific_user_id=task_response.anon_project_specific_user_id, anon_user_task_id=task_response.anon_user_task_id, detail_type=detail_type, detail=task_response._detail, correlation_id=task_response._correlation_id, interview_task_id=interview_task_id, )
def from_eb_event(cls, event): event_detail = event["detail"] response_id = event_detail.pop("response_id") event_time = event["time"] try: anon_project_specific_user_id = event_detail.pop( "anon_project_specific_user_id") anon_user_task_id = event_detail.pop("anon_user_task_id") except KeyError as exc: raise utils.DetailedValueError( f"Mandatory {exc} data not found in source event", details={ "event": event, }, ) return cls( response_id=response_id, event_time=event_time, anon_project_specific_user_id=anon_project_specific_user_id, anon_user_task_id=anon_user_task_id, detail_type=event["detail-type"], detail=event_detail, correlation_id=event["id"], account=event_detail.pop("account", "cambridge"), )
def create_user_task(self, ut_dict): """ Inserts new UserTask row in thiscovery db Args: ut_dict: must contain user_id, project_task_id and consented; may optionally include id, created, status, anon_user_task_id, first_name, last_name, email Returns: Dictionary representation of new user task """ self.from_dict(ut_dict=ut_dict) self._create_user_task_validate_mandatory_data() self._create_user_task_process_optional_data(ut_dict=ut_dict) self._get_project_task() user_project = create_user_project_if_not_exists( self.user_id, self.project_id, self._correlation_id) self.user_project_id = user_project["id"] self.anon_project_specific_user_id = user_project[ "anon_project_specific_user_id"] self._create_user_task_abort_if_exists() self._get_user_info() if self.project_task_status not in ["active", "testing"]: raise utils.DetailedValueError( f"Project task is not active or testing", {"event": ut_dict}) if self.user_specific_url and (self.project_task_status in ["active", "complete"]): self.user_task_url = self._get_survey_personal_link() else: self.user_task_url = self.base_url self.thiscovery_db_dump() url = self.calculate_url() new_user_task = { "id": self.id, "created": self.created, "modified": self.created, "user_id": self.user_id, "user_project_id": self.user_project_id, "project_task_id": self.project_task_id, "task_provider_name": self.task_provider_name, "url": url, "status": self.status, "consented": self.consented, "anon_user_task_id": self.anon_user_task_id, } extra_data_for_crm = self.get_task_signup_data_for_crm() task_signup_event = ThiscoveryEvent({ "detail-type": "task_signup", "detail": { **new_user_task, "extra_data": extra_data_for_crm }, }) task_signup_event.put_event() return new_user_task
def delete_block(self, block_id): response = self.session.delete(f"{self.base_url}blocks/{block_id}") if response.ok: return response.status_code else: error_message = f"Acuity delete block call failed with status code: {response.status_code}" error_dict = {"block_id": block_id} self.logger.error(error_message, extra=error_dict) raise utils.DetailedValueError(error_message, details=error_dict)
def get_interview_question_list_from_Qualtrics(self): def parse_question_html(s): text_m = PROMPT_RE.search(s) text = text_m.group(1) description_m = DESCRIPTION_RE.search(s) try: description = description_m.group(1) except AttributeError: description = None return text, description interview_question_list = list() question_counter = 1 block_ids_flow = [ x["ID"] for x in self.flow if x["Type"] not in ["Branch"] ] # flow items of Branch type represent survey branching logic for block_id in block_ids_flow: block = self.blocks[block_id] block_name = block["Description"] question_ids = [x["QuestionID"] for x in block["BlockElements"]] for question_id in question_ids: q = self.questions[question_id] question_name = q["DataExportTag"] question_text_raw = q["QuestionText"] try: question_text, question_description = parse_question_html( question_text_raw) except AttributeError: # no match found for PROMPT_RE if SYSTEM_RE.findall(question_text_raw): continue # this is a system config question; skip else: raise utils.DetailedValueError( "Mandatory prompt div could not be found in interview question", details={ "question": question_text_raw, "survey_id": self.survey_id, "question_id": question_id, "question_export_tag": question_name, }, ) question = InterviewQuestion( survey_id=self.survey_id, survey_modified=self.modified, question_id=question_id, question_name=question_name, sequence_no=str(question_counter), block_name=block_name, block_id=block_id, question_text=question_text, question_description=question_description, ) interview_question_list.append(question) question_counter += 1 return interview_question_list
def get_calendars(self): response = self.session.get(f"{self.base_url}calendars") if response.ok: calendars = response.json() self.calendars = {x["id"]: x for x in calendars} return response.json() else: raise utils.DetailedValueError( f"Acuity get calendars call failed with response: {response}", details={}, )
def _parse_properties(self, properties_list, properties_map): try: return {k: properties_map[k] for k in properties_list} except KeyError as err: raise utils.DetailedValueError( f"Custom property {err} not found in properties_map", details={ "properties_list": properties_list, "properties_map": properties_map, "correlation_id": self.correlation_id, }, )
def get_task_signup_data_for_crm(self): extra_data = execute_query(sql_q.SIGNUP_DETAILS_SELECT_SQL, (str(self.id), ), self._correlation_id) if len(extra_data) == 1: return extra_data[0] else: errorjson = { "user_task_id": self.id, "correlation_id": str(self._correlation_id), } raise utils.DetailedValueError( "could not load details for user task", errorjson)
def get_personal_link_api(event, context): valid_accounts = ["cambridge", "thisinstitute"] logger = event["logger"] correlation_id = event["correlation_id"] params = event["queryStringParameters"] project_task_id = params.get('project_task_id') # validate params try: survey_id = params["survey_id"] anon_project_specific_user_id = str( utils.validate_uuid(params["anon_project_specific_user_id"])) if (account := params["account"]) not in valid_accounts: raise utils.DetailedValueError( f'Account {account} is not supported. Valid values are {",".join(valid_accounts)}', details={"params": params}, ) except KeyError as err: raise utils.DetailedValueError(f"Mandatory {err} data not provided", details={"params": params}) logger.info( "API call", extra={ "anon_project_specific_user_id": anon_project_specific_user_id, "correlation_id": correlation_id, "survey_id": survey_id, "project_task_id": project_task_id, }, ) plm = PersonalLinkManager( survey_id=survey_id, anon_project_specific_user_id=anon_project_specific_user_id, account=account, project_task_id=project_task_id, ) return { "statusCode": HTTPStatus.OK, "body": json.dumps({"personal_link": plm.get_personal_link()}), }
def cochrane_get(url): full_url = utils.get_secret("cochrane-connection")["base_url"] + url headers = dict() headers["Content-Type"] = "application/json" logger = utils.get_logger() response = requests.get(full_url, headers=headers) if response.ok: data = response.json() logger.info("API response", extra={"body": data}) return data else: raise utils.DetailedValueError("Cochrane API call failed", details={"response": response.content})
def appointment_date_check(self, now): tomorrow = now + datetime.timedelta(days=1) tomorrow_string = tomorrow.strftime("%Y-%m-%d") if self.appointment.appointment_date != tomorrow_string: err_message = ( f"Appointment {self.appointment.appointment_id} is on " f"{self.appointment.appointment_date}, not tomorrow; aborting reminder" ) details = { "appointment": self.appointment.as_dict(), } self.logger.debug(err_message, extra=details) raise utils.DetailedValueError(err_message, details=details)
def _create_user_task_validate_mandatory_data(self): for param in ["user_id", "project_task_id", "consented"]: if self.__dict__[param] is None: errorjson = { "parameter": param, "correlation_id": str(self._correlation_id), } raise utils.DetailedValueError("mandatory data missing", details=errorjson) try: utils.validate_uuid(self.user_id) utils.validate_uuid(self.project_task_id) utils.validate_utc_datetime(self.consented) except utils.DetailedValueError as err: err.add_correlation_id(self._correlation_id) raise err
def reschedule_appointment(self, appointment_id, new_datetime, **kwargs): response = self.session.put( url=f"{self.base_url}appointments/{appointment_id}/reschedule", data=json.dumps({ **kwargs, "datetime": new_datetime.strftime("%Y-%m-%dT%H:%M:%S%Z") }), ) if response.ok: return response.status_code else: error_message = ( f"Acuity call failed with status code: {response.status_code}") error_dict = {"response": response.content} self.logger.error(error_message, extra=error_dict) raise utils.DetailedValueError(error_message, details=error_dict)
def wrapper(*args, **kwargs): response = func(*args, **kwargs) if response.ok: try: return response.json() except JSONDecodeError: return response else: logger = utils.get_logger() logger.error( f"Acuity API call failed with response: {response}", extra={"response.content": response.content}, ) raise utils.DetailedValueError( f"Acuity API call failed with response: {response}", details={"response": response.content}, )
def redirect_to_user_interview_task(event, context): """ Updates user task url in response to user_interview_task events posted by Qualtrics """ detail_type = event["detail-type"] assert (detail_type == "user_interview_task" ), f"Unexpected detail-type: {detail_type}" event_detail = event["detail"] correlation_id = event["id"] try: anon_user_task_id = utils.validate_uuid( event_detail.pop("anon_user_task_id")) except KeyError as exc: raise utils.DetailedValueError( f"Mandatory {exc} data not found in source event", details={ "event": event, }, ) ssm_client = SsmClient() vcs_param = ssm_client.get_parameter(name="video-call-system") ut_base_url = vcs_param["base-url"] user_task_url = f"{ut_base_url}?response_id={event_detail['response_id']}" ut_id = anon_user_task_id_2_user_task_id(anon_user_task_id, correlation_id=correlation_id) updated_rows_count = execute_non_query( sql_q.UPDATE_USER_TASK_URL, ( user_task_url, str(utils.now_with_tz()), str(ut_id), ), correlation_id, ) assert ( updated_rows_count == 1 ), f"Failed to update url of user task {ut_id}; updated_rows_count: {updated_rows_count}" body = { "user_task_id": ut_id, "user_task_url": user_task_url, } return {"statusCode": HTTPStatus.OK, "body": json.dumps(body)}
def list_user_tasks_api(event, context): logger = event["logger"] correlation_id = event["correlation_id"] parameters = event["queryStringParameters"] user_id = parameters.get("user_id") if not user_id: # e.g. parameters is None or an empty dict errorjson = { "queryStringParameters": parameters, "correlation_id": str(correlation_id), } raise utils.DetailedValueError( "This endpoint requires parameter user_id", errorjson) project_task_id = parameters.get("project_task_id") if project_task_id: logger.info( "API call", extra={ "user_id": user_id, "project_task_id": project_task_id, "correlation_id": correlation_id, "event": event, }, ) result = filter_user_tasks_by_project_task_id(user_id, project_task_id, correlation_id) else: logger.info( "API call", extra={ "user_id": user_id, "correlation_id": correlation_id, "event": event, }, ) result = list_user_tasks_by_user(user_id, correlation_id) # todo: this was added here as a way of quickly fixing an issue with the thiscovery frontend; review what to do for the longer term if len(result) == 1: result = result[0] return {"statusCode": HTTPStatus.OK, "body": json.dumps(result)}
def post_event(event, context): ssm_client = SsmClient() allowed_detail_types = ssm_client.get_parameter("thiscovery-events")[ "allowed_detail_types" ] event_dict = json.loads(event["body"]) alarm_test = event_dict.get("brew_coffee") if alarm_test: raise utils.DeliberateError("Coffee is not available", details={}) detail_type = event_dict.get("detail-type") if detail_type not in allowed_detail_types: raise utils.DetailedValueError( f"Unsupported event type: {detail_type}", details={"event": event_dict} ) thiscovery_event = eb.ThiscoveryEvent(event_dict) eb_client = eb.EventbridgeClient() eb_client.put_event(thiscovery_event=thiscovery_event) return {"statusCode": HTTPStatus.OK, "body": ""}
def user_group_membership_handler(event, context): """ Handles user_group_membership events posted to EB bus """ logger = event["logger"] detail_type = event["detail-type"] assert ( detail_type == "user_group_membership" ), f"Unexpected detail-type: {detail_type}" correlation_id = event["correlation_id"] event_detail = event["detail"] try: user_id = event_detail["user_id"] except KeyError: try: anon_ut_id = event_detail["anon_user_task_id"] except KeyError: raise utils.DetailedValueError( "Mandatory data (user_id or anon_user_task_id) not found in event detail", details={ "event_detail": event_detail, }, ) user_id = anon_user_task_id_2_user_id(anon_ut_id, correlation_id) event_detail["user_id"] = user_id try: ugm = UserGroupMembership.new_from_json(event_detail, correlation_id) return {"statusCode": HTTPStatus.CREATED, "body": json.dumps(ugm.to_dict())} except utils.DuplicateInsertError: return {"statusCode": HTTPStatus.NO_CONTENT, "body": json.dumps({})} except utils.ObjectDoesNotExistError as err: return utils.log_exception_and_return_edited_api_response( err, HTTPStatus.NOT_FOUND, logger, correlation_id ) except utils.DetailedValueError as err: return utils.log_exception_and_return_edited_api_response( err, HTTPStatus.BAD_REQUEST, logger, correlation_id ) except Exception as err: return utils.log_exception_and_return_edited_api_response( err, HTTPStatus.INTERNAL_SERVER_ERROR, logger, correlation_id )
def from_eb_event(cls, event: dict): event_detail = event["detail"] try: account = event_detail["account"] return cls( account=account, survey_id=event_detail["survey_id"], contact_list_id=event_detail.get( "contact_list_id", const.DISTRIBUTION_LISTS[account]["id"]), correlation_id=event["id"], project_task_id=event_detail["project_task_id"], ) except KeyError as exc: raise utils.DetailedValueError( f"Mandatory {exc} data not found in source event", details={ "event": event, }, )
def put_interview_questions(event, context): """ Handles interview_questions_update events posted by Qualtrics """ try: event_for_interview_system = { "detail-type": event["detail-type"], "detail": { "survey_id": event["detail"]["survey_id"], }, } except KeyError as err: raise utils.DetailedValueError( f"interview_questions_update event missing mandatory data {err}", details={}) sd = SurveyDefinition.from_eb_event(event=event) body = sd.ddb_update_interview_questions() eac = EventsApiClient() eac.post_event(event_for_interview_system) return {"statusCode": HTTPStatus.OK, "body": json.dumps(body)}