def __init__(self, qualtrics_account_name="cambridge", survey_id=None, correlation_id=None): client = SurveyDefinitionsClient( qualtrics_account_name=qualtrics_account_name, survey_id=survey_id, correlation_id=correlation_id, ) response = client.get_survey() assert (response["meta"]["httpStatus"] == "200 - OK" ), f"Call to Qualtrics API failed with response {response}" self.survey_id = survey_id self.definition = response["result"] self.flow = self.definition["SurveyFlow"]["Flow"] self.blocks = self.definition["Blocks"] self.questions = self.definition["Questions"] self.modified = self.definition["LastModified"] self.ddb_client = Dynamodb(stack_name=const.STACK_NAME) self.logger = utils.get_logger() self.logger.debug( "Initialised SurveyDefinition", extra={ "__dict__": self.__dict__, "correlation_id": correlation_id, }, )
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 __init__(self, logger=None, correlation_id=None): self.ddb_client = Dynamodb(stack_name=STACK_NAME) self.correlation_id = correlation_id self.target_appointment_ids = self.get_appointments_to_be_deleted() self.logger = logger if logger is None: self.logger = utils.get_logger()
def __init__(self, interview_task_id, **kwargs): self._project_task_id = kwargs.get("project_task_id") self._interview_task_id = interview_task_id optional_attributes = [ "name", "short_name", "description", "completion_url", "on_demand_available", "on_demand_questions_random", "live_available", "live_questions_random", "appointment_type_id", "modified", ] index_key_attributes = [ # these cannot be None "on_demand_survey_id", "live_survey_id", ] for oa in optional_attributes: self.__dict__[oa] = kwargs.get(oa) for ika in index_key_attributes: key_attribute = kwargs.get(ika) if key_attribute: self.__dict__[ika] = key_attribute self._ddb_client = Dynamodb(stack_name=const.STACK_NAME)
def __init__(self, logger, correlation_id): self.logger = logger self.correlation_id = correlation_id self.calendars_table = "Calendars" self.blocks_table = "CalendarBlocks" self.ddb_client = Dynamodb(stack_name=STACK_NAME) self.acuity_client = AcuityClient() self.sns_client = SnsClient()
def persist_thiscovery_event(event, context): event.pop("logger", None) event.pop("correlation_id", None) ddb_client = Dynamodb(stack_name=const.STACK_NAME, correlation_id=event["id"]) table = ddb_client.get_table(table_name=const.AUDIT_TABLE) result = table.put_item(Item=event) assert result["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK return {"statusCode": HTTPStatus.OK, "body": json.dumps("")}
def ddb_load_interview_questions(survey_id): ddb_client = Dynamodb(stack_name=const.STACK_NAME) return ddb_client.query( table_name=const.INTERVIEW_QUESTIONS_TABLE["name"], KeyConditionExpression="survey_id = :survey_id", ExpressionAttributeValues={ ":survey_id": survey_id, }, )
def __init__(self, csvfile_path, consent_questions_tags, consent_info_url): super(ConsentEmailsManager, self).__init__(csvfile_path) self.consent_question_tags = consent_questions_tags self.consent_info_url = consent_info_url self.consent_statements = dict() self.ddb_client = Dynamodb(stack_name=const.STACK_NAME) self.anon_user_task_id_column_name = "anon_user_task_id" self.anon_project_specific_user_id_column_name = "anon_project_specific_user_id" self.consent_datetime_column_name = "StartDate" self.sent_emails = list()
def setUpClass(cls): super().setUpClass() cls.ddb_client = Dynamodb(stack_name=const.STACK_NAME) cls.ddb_client.delete_all( table_name=const.AUTH0_EVENTS_TABLE_NAME, key_name=const.AUTH0_EVENTS_TABLE_HASH, sort_key_name=const.AUTH0_EVENTS_TABLE_SORT, ) cls.eb_client = EventbridgeClient()
def add_template_to_ddb(template_id, template_name, template_type, formatted_custom_properties, preview_url): ddb_client = Dynamodb() ddb_client.put_item( table_name="HubspotEmailTemplates", key=template_name, item_type=template_type, item_details={ "preview_url": preview_url, }, item={ "bcc": [], "cc": [], "contact_properties": [], "custom_properties": formatted_custom_properties, "from": TRANSACTIONAL_EMAILS_FROM_ADDRESS, "hs_template_id": template_id, }, )
def __init__( self, survey_id, anon_project_specific_user_id, account, project_task_id, correlation_id=None, ): self.survey_id = survey_id self.anon_project_specific_user_id = anon_project_specific_user_id self.account = account self.account_survey_id = f"{account}_{survey_id}" self.correlation_id = correlation_id if correlation_id is None: self.correlation_id = utils.new_correlation_id() self.ddb_client = Dynamodb(stack_name=const.STACK_NAME, correlation_id=self.correlation_id) self.project_task_id = project_task_id
def get_unassigned_links(account_survey_id: str, ddb_client=None) -> list[dict]: """ Retrieves existing personal links that have not yet been assigned to an user """ if ddb_client is None: ddb_client = Dynamodb(stack_name=const.STACK_NAME) return ddb_client.query( table_name=const.PersonalLinksTable.NAME, IndexName="unassigned-links", KeyConditionExpression="account_survey_id = :account_survey_id " "AND #status = :link_status", ExpressionAttributeValues={ ":account_survey_id": account_survey_id, ":link_status": "new", }, ExpressionAttributeNames={ "#status": "status" }, # needed because status is a reserved word in ddb )
def clear_notification_queue(event, context): logger = event["logger"] correlation_id = event["correlation_id"] seven_days_ago = now_with_tz() - timedelta(days=7) # processed_notifications = get_notifications('processing_status', ['processed']) processed_notifications = c_notif.get_notifications_to_clear( datetime_threshold=seven_days_ago, stack_name=const.STACK_NAME) notifications_to_delete = [ x for x in processed_notifications if (parser.isoparse(x["modified"]) < seven_days_ago) and ( x[NotificationAttributes.TYPE.value] != NotificationType.TRANSACTIONAL_EMAIL.value) ] deleted_notifications = list() ddb_client = Dynamodb(stack_name=const.STACK_NAME) for n in notifications_to_delete: response = ddb_client.delete_item(c_notif.NOTIFICATION_TABLE_NAME, n["id"], correlation_id=correlation_id) if response["ResponseMetadata"][ "HTTPStatusCode"] == http.HTTPStatus.OK: deleted_notifications.append(n) else: logger.info( f"Notifications deleted before an error occurred", extra={ "deleted_notifications": deleted_notifications, "correlation_id": correlation_id, }, ) logger.error( "Failed to delete notification", extra={ "notification": n, "response": response }, ) raise Exception( f"Failed to delete notification {n}; received response: {response}" ) return deleted_notifications
def __init__(self, **kwargs): self._logger = utils.get_logger() self._correlation_id = kwargs.get("correlation_id") self.destination_survey_id = kwargs.get("survey_id") self.destination_response_id = kwargs.get("response_id") err_message = "Call to initialise_survey missing mandatory data: {}" assert self.destination_survey_id, err_message.format("survey_id") assert self.destination_survey_id, err_message.format("response_id") self.survey_init_config = SurveyInitConfig( destination_survey_id=self.destination_survey_id, correlation_id=self._correlation_id, ) self.survey_init_config.get() try: self.survey_init_config.details except AttributeError: raise utils.ObjectDoesNotExistError( f"Initialisation config not found for survey {self.destination_survey_id}", details={}, ) self.destination_account = kwargs.get("account", "cambridge") self.anon_project_specific_user_id = kwargs.get( "anon_project_specific_user_id") assert self.anon_project_specific_user_id, err_message.format( "anon_project_specific_user_id") self._core_client = CoreApiClient(correlation_id=self._correlation_id) user = self._core_client.get_user_by_anon_project_specific_user_id( self.anon_project_specific_user_id) self.user_id = user["id"] self.ddb_client = Dynamodb(stack_name=const.STACK_NAME, correlation_id=self._correlation_id) self.cached_responses = dict() self.missing_responses = list() self.target_embedded_data = dict() self.responses_client = ResponsesClient( survey_id=self.destination_survey_id, qualtrics_account_name=self.destination_account, correlation_id=self._correlation_id, )
def get_appointments_by_type(type_ids, correlation_id=None): """ Args: type_ids (list): Appointment type ids to query ddb correlation_id: Returns: List of appointments matching any of the input type ids """ ddb_client = Dynamodb(stack_name=STACK_NAME, correlation_id=correlation_id) items = list() for i in type_ids: result = ddb_client.query( table_name=APPOINTMENTS_TABLE, IndexName="project-appointments-index", KeyConditionExpression="appointment_type_id = :type_id", ExpressionAttributeValues={ ":type_id": i, }, ) items += result return items
def __init__(self, consent_id=None, core_api_client=None, correlation_id=None): self.project_id = None self.project_short_name = None self.project_name = None self.project_task_id = None self.consent_id = consent_id if consent_id is None: self.consent_id = str(uuid.uuid4()) self.consent_datetime = None self.anon_project_specific_user_id = None self.anon_user_task_id = None self.consent_statements = None self.modified = None # flag used in ddb_load method to check if ddb data was already fetched self._correlation_id = correlation_id self._ddb_client = Dynamodb(stack_name=STACK_NAME) self._core_api_client = core_api_client if core_api_client is None: self._core_api_client = CoreApiClient( correlation_id=correlation_id)
class AppointmentsCleaner: def __init__(self, logger=None, correlation_id=None): self.ddb_client = Dynamodb(stack_name=STACK_NAME) self.correlation_id = correlation_id self.target_appointment_ids = self.get_appointments_to_be_deleted() self.logger = logger if logger is None: self.logger = utils.get_logger() def get_appointments_to_be_deleted(self, now=None): """ Queries ddb for appointments booked for 60 days ago """ if now is None: now = utils.now_with_tz() date_format = "%Y-%m-%d" sixty_days_ago = now - datetime.timedelta(days=60) sixty_days_ago_string = sixty_days_ago.strftime(date_format) result = self.ddb_client.query( table_name=APPOINTMENTS_TABLE, IndexName="reminders-index", KeyConditionExpression="appointment_date = :date", ExpressionAttributeValues={ ":date": sixty_days_ago_string, }, ) return [x["id"] for x in result] def delete_old_appointments(self): results = list() for app_id in self.target_appointment_ids: result = self.ddb_client.delete_item( table_name=APPOINTMENTS_TABLE, key=app_id, ) results.append(result["ResponseMetadata"]["HTTPStatusCode"]) return results
def main(): calendar_name = input( "Please input the name of the calendar you want to add to Dynamodb, as shown in Acuity's UI:" ) if not calendar_name: sys.exit() acuity_client = AcuityClient() ddb_client = Dynamodb(stack_name=STACK_NAME) acuity_calendars = acuity_client.get_calendars() pprint(acuity_calendars) target_calendar = None for c in acuity_calendars: if c["name"] == calendar_name: target_calendar = c continue if target_calendar: response = ddb_client.put_item( "Calendars", target_calendar["id"], item_type="acuity-calendar", item_details=target_calendar, item={ "label": target_calendar["name"], "block_monday_morning": True, "emails_to_notify": list(), "myinterview_link": None, }, ) assert ( response["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK ), f"Dynamodb client put_item operation failed with response: {response}" print( f'Calendar "{calendar_name}" successfully added to Dynamodb table') else: raise utils.ObjectDoesNotExistError( f'Calendar "{calendar_name}" not found in Acuity')
def test_deletion_of_questions_no_longer_in_survey_definition(self): mock_deleted_question = { "block_id": "BL_3qH1dnbq50y9V0a", "block_name": "Your experience using thiscovery", "question_description": "<p>Take a moment to reflect on how much you like bread before answering this question.</p>", "question_id": "QID12", "question_name": "Q12", "question_text": "<h3>Is thiscovery the best invention since sliced bread?</h3>", "sequence_no": "12", "survey_id": "SV_eDrjXPqGElN0Mwm", "survey_modified": "2021-02-16T15:59:12Z", } ddb_client = Dynamodb(stack_name=const.STACK_NAME) ddb_client.put_item( table_name=const.INTERVIEW_QUESTIONS_TABLE["name"], key=mock_deleted_question["survey_id"], item_type="interview_question", item_details=None, item=mock_deleted_question, update_allowed=True, key_name=const.INTERVIEW_QUESTIONS_TABLE["partition_key"], sort_key={ const.INTERVIEW_QUESTIONS_TABLE["sort_key"]: mock_deleted_question["question_id"] }, ) test_event = copy.deepcopy( td.TEST_INTERVIEW_QUESTIONS_UPDATED_EB_EVENT) result = ep.put_interview_questions(test_event, None) self.assertEqual(HTTPStatus.OK, result["statusCode"]) updated_question_ids, deleted_question_ids = json.loads(result["body"]) self.assertEqual(4, len(updated_question_ids)) self.assertEqual([mock_deleted_question["question_id"]], deleted_question_ids)
def get_forward_to_address(received_for, correlation_id=None): """ Args: received_for: correlation_id: Returns: Notes: This function can probably be optimised by making a call to the scan method of ddb_client and then parsing the results, rather than making up to three separate calls to get_item """ ddb_client = Dynamodb(stack_name=STACK_NAME) # try matching full received_for email address ddb_item = ddb_client.get_item(table_name='ForwardingMap', key=received_for, correlation_id=correlation_id) if ddb_item is not None: return ddb_item['forward-to'] # try matching subdomain subdomain = received_for.split('@')[1] ddb_item = ddb_client.get_item(table_name='ForwardingMap', key=subdomain, correlation_id=correlation_id) if ddb_item is not None: return ddb_item['forward-to'] # go for the domain catch-all rule ddb_item = ddb_client.get_item(table_name='ForwardingMap', key="thiscovery.org", correlation_id=correlation_id) if ddb_item is not None: return ddb_item['forward-to']
def __init__(self, appointment, logger=None, ddb_client=None, correlation_id=None): """ Args: appointment: instance of BaseAcuityAppointment or subclasses logger: correlation_id: """ self.appointment = appointment self.project_id = None self.project_name = None self.anon_project_specific_user_id = None self.interviewer_calendar_ddb_item = None self.logger = logger if logger is None: self.logger = utils.get_logger() self.correlation_id = correlation_id self.ddb_client = ddb_client if ddb_client is None: self.ddb_client = Dynamodb(stack_name=STACK_NAME)
def __init__( self, account: str, survey_id: str, contact_list_id: str, project_task_id: str, correlation_id: str = None, ): self.account = account self.survey_id = survey_id self.account_survey_id = f"{account}_{survey_id}" self.contact_list_id = contact_list_id self.dist_client = qualtrics.DistributionsClient( qualtrics_account_name=account) self.correlation_id = correlation_id self.ddb_client = Dynamodb(stack_name=const.STACK_NAME, correlation_id=correlation_id) self.project_task_id = project_task_id
def setup(): ddb_client = Dynamodb(stack_name=const.STACK_NAME) ddb_client.delete_all( table_name=const.AUTH0_EVENTS_TABLE_NAME, key_name=const.AUTH0_EVENTS_TABLE_HASH, sort_key_name=const.AUTH0_EVENTS_TABLE_SORT, ) ddb_client.batch_put_items( const.AUTH0_EVENTS_TABLE_NAME, td.METRICS_TEST_DATA, const.AUTH0_EVENTS_TABLE_HASH, )
def clear_appointments_table(cls): try: cls.ddb_client.delete_all(table_name=app.APPOINTMENTS_TABLE) except AttributeError: cls.ddb_client = Dynamodb(stack_name=STACK_NAME) cls.ddb_client.delete_all(table_name=app.APPOINTMENTS_TABLE)
class TaskResponse(DdbBaseItem): """ Represents a TaskResponse Ddb item """ def __init__( self, response_id, event_time, anon_project_specific_user_id=None, anon_user_task_id=None, detail_type=None, detail=None, correlation_id=None, account=None, ): self._response_id = response_id self._event_time = event_time self.account = account self.anon_project_specific_user_id = anon_project_specific_user_id self.anon_user_task_id = anon_user_task_id self._detail_type = detail_type self._detail = detail self._correlation_id = correlation_id self._core_client = CoreApiClient(correlation_id=correlation_id) self._ddb_client = Dynamodb(stack_name=const.STACK_NAME, correlation_id=correlation_id) self.project_task_id = None split_response_id = response_id.split("-") assert ( len(split_response_id) == 2 ), f"response_id ({response_id}) not in expected format SurveyID-QualtricsResponseID" self.survey_id = split_response_id[0] self.qualtrics_response_id = split_response_id[1] @classmethod 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 get_project_task_id(self): user_task = self._core_client.get_user_task_from_anon_user_task_id( anon_user_task_id=self.anon_user_task_id) self.project_task_id = user_task["project_task_id"] def get_user_id(self): user = self._core_client.get_user_by_anon_project_specific_user_id( self.anon_project_specific_user_id) return user["id"] def ddb_dump(self, update_allowed=False, unpack_detail=False): item = self.as_dict() if unpack_detail: item.update(self._detail) self._detail = dict() return self._ddb_client.put_item( table_name=const.TASK_RESPONSES_TABLE["name"], key=self._response_id, item_type=self._detail_type, item_details=self._detail, item=item, update_allowed=update_allowed, key_name=const.TASK_RESPONSES_TABLE["partition_key"], sort_key={ const.TASK_RESPONSES_TABLE["sort_key"]: self._event_time }, )
class SurveyDefinition: def __init__(self, qualtrics_account_name="cambridge", survey_id=None, correlation_id=None): client = SurveyDefinitionsClient( qualtrics_account_name=qualtrics_account_name, survey_id=survey_id, correlation_id=correlation_id, ) response = client.get_survey() assert (response["meta"]["httpStatus"] == "200 - OK" ), f"Call to Qualtrics API failed with response {response}" self.survey_id = survey_id self.definition = response["result"] self.flow = self.definition["SurveyFlow"]["Flow"] self.blocks = self.definition["Blocks"] self.questions = self.definition["Questions"] self.modified = self.definition["LastModified"] self.ddb_client = Dynamodb(stack_name=const.STACK_NAME) self.logger = utils.get_logger() self.logger.debug( "Initialised SurveyDefinition", extra={ "__dict__": self.__dict__, "correlation_id": correlation_id, }, ) @classmethod def from_eb_event(cls, event): logger = utils.get_logger() logger.debug( "EB event", extra={ "event": event, }, ) event_detail = event["detail"] try: qualtrics_account_name = event_detail.pop("account") survey_id = event_detail.pop("survey_id") except KeyError as exc: raise utils.DetailedValueError( f"Mandatory {exc} data not found in source event", details={ "event": event, }, ) return cls( qualtrics_account_name=qualtrics_account_name, survey_id=survey_id, correlation_id=event["id"], ) 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 ddb_update_interview_questions(self): """ Updates the list of interview questions held in Dynamodb for a particular survey. This includes not only adding and updating questions, but also deleting questions that are no longer present in the survey """ ddb_question_list = self.ddb_load_interview_questions(self.survey_id) survey_question_list = self.get_interview_question_list_from_Qualtrics( ) updated_question_ids = list() deleted_question_ids = list() for q in survey_question_list: self.ddb_client.put_item( table_name=const.INTERVIEW_QUESTIONS_TABLE["name"], key=q._survey_id, key_name=const.INTERVIEW_QUESTIONS_TABLE["partition_key"], item_type="interview_question", item_details=None, item=q.as_dict(), update_allowed=True, sort_key={ const.INTERVIEW_QUESTIONS_TABLE["sort_key"]: q._question_id, }, ) updated_question_ids.append(q._question_id) for q in ddb_question_list: question_id = q["question_id"] if question_id not in updated_question_ids: self.ddb_client.delete_item( table_name=const.INTERVIEW_QUESTIONS_TABLE["name"], key=q["survey_id"], key_name=const.INTERVIEW_QUESTIONS_TABLE["partition_key"], sort_key={ const.INTERVIEW_QUESTIONS_TABLE["sort_key"]: question_id, }, ) deleted_question_ids.append(question_id) return updated_question_ids, deleted_question_ids @staticmethod def ddb_load_interview_questions(survey_id): ddb_client = Dynamodb(stack_name=const.STACK_NAME) return ddb_client.query( table_name=const.INTERVIEW_QUESTIONS_TABLE["name"], KeyConditionExpression="survey_id = :survey_id", ExpressionAttributeValues={ ":survey_id": survey_id, }, ) @staticmethod def get_interview_questions(survey_id): """ Get interview questions for a VIEWS interview. Randomises question order within blocks if that is specified by InterviewTask Args: survey_id: Qualtrics survey id of survey containing the interview questions Returns: """ def randomise_questions_check(): """ Reads configuration in InterviewTask items containing survey_id to determine whether or not interview questions should be randomised. Returns: True if questions should be randomised; otherwise False """ bool_config_choices = {True, False} error_message = ( f"Conflicting interview task configuration(s) found for survey {survey_id}. " f"Random question order specified in some but not all interview tasks using that survey" ) interview_tasks_table = const.InterviewTasksTable() # get config for live interviews with questions coming from survey_id live_config_matches = interview_tasks_table.query_live_survey_id_index( survey_id=survey_id) live_random_config_set = set() for m in live_config_matches: random_config = m.get("live_questions_random", False) live_random_config_set.add(random_config) # get config for on-demand interviews with questions coming from survey_id on_demand_config_matches = ( interview_tasks_table.query_on_demand_survey_id_index( survey_id=survey_id)) on_demand_random_config_set = set() for m in on_demand_config_matches: random_config = m.get("on_demand_questions_random", False) on_demand_random_config_set.add(random_config) # check for conflicts and return randomise config if there are no issues random_config_choices = live_random_config_set.union( on_demand_random_config_set) random_config_choices_excluding_none = random_config_choices.intersection( bool_config_choices) assert len( random_config_choices_excluding_none) <= 1, error_message try: return random_config_choices_excluding_none.pop() except KeyError: # no config found; default to non-random return False interview_questions = SurveyDefinition.ddb_load_interview_questions( survey_id) try: survey_modified = interview_questions[0]["survey_modified"] except IndexError: raise utils.ObjectDoesNotExistError( f"No interview questions found for survey {survey_id}", details={}) block_dict = dict() for iq in interview_questions: block_id = iq["block_id"] try: block = block_dict[block_id] except KeyError: block = { "block_id": block_id, "block_name": iq["block_name"], "questions": list(), } question = { "question_id": iq["question_id"], "question_name": iq["question_name"], "sequence_no": iq["sequence_no"], "question_text": iq["question_text"], "question_description": iq["question_description"], } block["questions"].append(question) block_dict[block_id] = block # order questions in each block randomise = randomise_questions_check() for block in block_dict.values(): if randomise: random.shuffle(block["questions"]) else: block["questions"] = sorted( block["questions"], key=lambda k: int(k["sequence_no"])) body = { "survey_id": survey_id, "modified": survey_modified, "blocks": list(block_dict.values()), "count": len(interview_questions), } return body
class CalendarBlocker: def __init__(self, logger, correlation_id): self.logger = logger self.correlation_id = correlation_id self.calendars_table = "Calendars" self.blocks_table = "CalendarBlocks" self.ddb_client = Dynamodb(stack_name=STACK_NAME) self.acuity_client = AcuityClient() self.sns_client = SnsClient() def notify_sns_topic(self, message, subject): topic_arn = utils.get_secret( "sns-topics")["interview-notifications-arn"] self.sns_client.publish( message=message, topic_arn=topic_arn, Subject=subject, ) def get_target_calendar_ids(self): calendars = self.ddb_client.scan( self.calendars_table, "block_monday_morning", [True], ) return [(x["id"], x["label"]) for x in calendars] def block_upcoming_weekend(self, calendar_id): next_monday_date = next_weekday(0) block_end = datetime.datetime.combine(next_monday_date, datetime.time(hour=12, minute=0)) saturday_before_next_monday = next_monday_date - datetime.timedelta( days=2) block_start = datetime.datetime.combine( saturday_before_next_monday, datetime.time(hour=0, minute=0)) return self.acuity_client.post_block(calendar_id, block_start, block_end) def create_blocks(self): calendars = self.get_target_calendar_ids() self.logger.debug("Calendars to block", extra={"calendars": calendars}) created_blocks_ids = list() affected_calendar_names = list() for i, name in calendars: try: block_dict = self.block_upcoming_weekend(i) created_blocks_ids.append(block_dict["id"]) affected_calendar_names.append(name) response = self.ddb_client.put_item( self.blocks_table, block_dict["id"], item_type="calendar-block", item_details=block_dict, item={ "status": "new", "error_message": None, }, correlation_id=self.correlation_id, ) assert ( response["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK ), f"Call to Dynamodb client put_item method failed with response: {response}. " except Exception as err: self.logger.error( f"{repr(err)} {len(created_blocks_ids)} blocks were created before this error occurred. " f"Created blocks ids: {created_blocks_ids}") raise return created_blocks_ids, affected_calendar_names def mark_failed_block_deletion(self, item_key, exception): error_message = f"This error happened when trying to delete Acuity calendar block {item_key}: {repr(exception)}" self.logger.error(error_message) self.ddb_client.update_item( self.blocks_table, item_key, name_value_pairs={ "status": "error", "error_message": error_message }, ) def delete_blocks(self): blocks = self.ddb_client.scan( self.blocks_table, filter_attr_name="status", filter_attr_values=["new"], ) deleted_blocks_ids = list() affected_calendar_names = list() for b in blocks: item_key = b.get("id") try: delete_response = self.acuity_client.delete_block(item_key) assert delete_response == HTTPStatus.NO_CONTENT, ( f"Call to Acuity client delete_block method failed with response: {delete_response}. " f"{len(deleted_blocks_ids)} blocks were deleted before this error occurred. Deleted blocks ids: {deleted_blocks_ids}" ) deleted_blocks_ids.append(item_key) affected_calendar_names.append( self.acuity_client.get_calendar_by_id( b["details"]["calendarID"])["name"]) response = self.ddb_client.delete_item( self.blocks_table, item_key, correlation_id=self.correlation_id) assert ( response["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK ), (f"Call to Dynamodb client delete_item method failed with response: {response}. " f"{len(deleted_blocks_ids)} blocks were deleted before this error occurred. Deleted blocks ids: {deleted_blocks_ids}" ) except Exception as err: self.mark_failed_block_deletion(item_key, err) continue return deleted_blocks_ids, affected_calendar_names
class AppointmentType: """ Represents an Acuity appointment type with additional attributes """ def __init__(self, ddb_client=None, acuity_client=None, logger=None, correlation_id=None): self.type_id = None self.name = None self.category = None self.has_link = None self.send_notifications = None self.templates = None self.modified = None # flag used in ddb_load method to check if ddb data was already fetched self.project_task_id = None self.system = None # Views, MyInterview, etc self._logger = logger self._correlation_id = correlation_id if logger is None: self._logger = utils.get_logger() self._ddb_client = ddb_client if ddb_client is None: self._ddb_client = Dynamodb(stack_name=STACK_NAME) self._acuity_client = acuity_client if acuity_client is None: self._acuity_client = AcuityClient( correlation_id=self._correlation_id) def as_dict(self): return { k: v for k, v in self.__dict__.items() if (k[0] != "_") and (k not in ["created", "modified"]) } def from_dict(self, type_dict): self.__dict__.update(type_dict) def ddb_dump(self, update_allowed=False): return self._ddb_client.put_item( table_name=APPOINTMENT_TYPES_TABLE, key=str(self.type_id), item_type="acuity-appointment-type", item_details=None, item=self.as_dict(), update_allowed=update_allowed, ) def ddb_load(self): if self.modified is None: item = self._ddb_client.get_item( table_name=APPOINTMENT_TYPES_TABLE, key=str(self.type_id), correlation_id=self._correlation_id, ) try: self.__dict__.update(item) except TypeError: raise utils.ObjectDoesNotExistError( f"Appointment type {self.type_id} could not be found in Dynamodb", details={ "appointment_type": self.as_dict(), "correlation_id": self._correlation_id, }, ) def get_appointment_type_id_to_info_map(self): """ Converts the list of appointment types returned by AcuityClient.get_appointment_types() to a dictionary indexed by id """ appointment_types = self._acuity_client.get_appointment_types() return {str(x["id"]): x for x in appointment_types} def get_appointment_type_info_from_acuity(self): """ There is no direct method to get a appointment type by id (https://developers.acuityscheduling.com/reference), so we have to fetch all appointment types and lookup """ if (self.name is None) or (self.category is None): id_to_info = self.get_appointment_type_id_to_info_map() self.name = id_to_info[str(self.type_id)]["name"] self.category = id_to_info[str(self.type_id)]["category"]
class BaseAppointmentNotifier(metaclass=ABCMeta): calendar_table = "Calendars" def __init__(self, appointment, logger=None, ddb_client=None, correlation_id=None): """ Args: appointment: instance of BaseAcuityAppointment or subclasses logger: correlation_id: """ self.appointment = appointment self.project_id = None self.project_name = None self.anon_project_specific_user_id = None self.interviewer_calendar_ddb_item = None self.logger = logger if logger is None: self.logger = utils.get_logger() self.correlation_id = correlation_id self.ddb_client = ddb_client if ddb_client is None: self.ddb_client = Dynamodb(stack_name=STACK_NAME) def _check_and_get_project_short_name(self, properties_list): if ("project_short_name" in properties_list) and (self.project_name is None): self._get_project_short_name() 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_calendar_ddb_item(self): if self.appointment.calendar_id is None: self.appointment.get_appointment_info_from_acuity() self.interviewer_calendar_ddb_item = self.ddb_client.get_item( table_name=self.calendar_table, key=self.appointment.calendar_id) if not self.interviewer_calendar_ddb_item: raise utils.ObjectDoesNotExistError( f"Calendar {self.appointment.calendar_id} not found in Dynamodb", details={ "appointment": self.appointment.as_dict(), "correlation_id": self.correlation_id, }, ) return self.interviewer_calendar_ddb_item def _get_researcher_email_address(self): if self.interviewer_calendar_ddb_item is None: self._get_calendar_ddb_item() try: return self.interviewer_calendar_ddb_item["emails_to_notify"] except KeyError: raise utils.ObjectDoesNotExistError( f"Calendar {self.appointment.calendar_id} Dynamodb item does not contain an emails_to_notify column", details={ "appointment": self.appointment.as_dict(), "correlation_id": self.correlation_id, }, ) def _notify_email(self, recipient_email, recipient_type, event_type): """ Calls transactional email endpoint using email address as user identifier """ template = self._get_email_template( recipient_email=recipient_email, recipient_type=recipient_type, event_type=event_type, ) return self.appointment._core_api_client.send_transactional_email( template_name=template["name"], to_recipient_email=recipient_email, custom_properties=self._get_custom_properties( properties_list=template["custom_properties"], template_type=recipient_type, ), ) @abstractmethod def _get_email_template(self, recipient_email, recipient_type, event_type): pass @abstractmethod def _get_custom_properties(self, properties_list, template_type): pass @abstractmethod def _notify_participant(self, event_type): pass @abstractmethod def _notify_researchers(self, event_type): pass def _check_appointment_cancelled(self): """ Gets latest appointment info from Acuity to ensure appointment is still valid before sending out notification Returns: True is appointment is cancelled; False if it is not cancelled """ self.appointment.get_appointment_info_from_acuity(force_refresh=True) return self.appointment.acuity_info["canceled"] is True def _abort_notification_check(self, event_type): if not event_type == "cancellation": if self._check_appointment_cancelled(): self.logger.info( "Notification aborted; appointment has been cancelled", extra={ "appointment": self.appointment.as_dict(), "correlation_id": self.correlation_id, }, ) return True return check_appointment_in_the_past(self.appointment) def _get_project_short_name(self): project_list = self.appointment._core_api_client.get_projects() for p in project_list: for t in p["tasks"]: if t["id"] == self.appointment.appointment_type.project_task_id: self.project_id = p["id"] self.project_name = p["name"] return self.project_name raise utils.ObjectDoesNotExistError( f"Project task {self.appointment.appointment_type.project_task_id} not found", details={}, ) def _check_email_result_and_update_latest_notification( self, result, event_type): if result["statusCode"] != HTTPStatus.NO_CONTENT: self.logger.error( f"Failed to notify {self.appointment.participant_email} of interview appointment", extra={ "appointment": self.appointment.as_dict(), "event_type": event_type, "correlation_id": self.correlation_id, }, ) else: self.appointment.update_latest_participant_notification() def send_notifications(self, event_type): # todo: split this into two functions when EventBridge is in place participant_result = self._notify_participant(event_type=event_type) researchers_results = None try: researchers_notifications_results = self._notify_researchers( event_type=event_type) researchers_results = [ r["statusCode"] for r in researchers_notifications_results ] except: self.logger.error( "Failed to notify researchers", extra={ "appointment": self.appointment.as_dict(), "traceback": traceback.format_exc(), "correlation_id": self.correlation_id, }, ) return { "participant": participant_result.get("statusCode"), "researchers": researchers_results, } def send_reminder(self, now=None): return self._notify_participant(event_type="reminder")
class BaseAcuityAppointment(metaclass=ABCMeta): """ An abstract class representing an Acuity appointment """ def __init__(self, appointment_id, logger=None, correlation_id=None): self.appointment_id = str(appointment_id) self.acuity_info = None self.calendar_id = None self.calendar_name = None self.participant_email = None self.participant_user_id = None self.appointment_type = AppointmentType() self.latest_participant_notification = ( "0000-00-00 00:00:00+00:00" # used as GSI sort key, so cannot be None ) self.appointment_date = None self.anon_project_specific_user_id = None self.anon_user_task_id = None self.appointment_type_id = None self._logger = logger if self._logger is None: self._logger = utils.get_logger() self._correlation_id = correlation_id self._ddb_client = Dynamodb(stack_name=STACK_NAME) self._core_api_client = CoreApiClient( correlation_id=self._correlation_id) # transactional emails self._acuity_client = AcuityClient(correlation_id=self._correlation_id) self.original_appointment = ( None # used to store appointment history if rescheduled ) def __repr__(self): return str(self.__dict__) def from_dict(self, appointment_dict): """Used to quickly load appointments into Dynamodb for testing""" self.appointment_type.from_dict( appointment_dict.pop("appointment_type", dict())) self.__dict__.update(appointment_dict) def as_dict(self): d = { k: v for k, v in self.__dict__.items() if (k[0] != "_") and ( k not in ["created", "modified", "appointment_type"]) } d["appointment_type"] = self.appointment_type.as_dict() return d def ddb_dump( self, update_allowed=False, item_type="acuity-appointment", ): self.get_appointment_info_from_acuity( ) # populates self.appointment_type.type_id self.appointment_type.ddb_load() return self._ddb_client.put_item( table_name=APPOINTMENTS_TABLE, key=self.appointment_id, item_type=item_type, item_details=None, item=self.as_dict(), update_allowed=update_allowed, ) def ddb_load(self): item = self.get_appointment_item_from_ddb() try: item_app_type = item["appointment_type"] except TypeError: raise utils.ObjectDoesNotExistError( f"Appointment {self.appointment_id} could not be found in Dynamodb", details={ "appointment": self.as_dict(), "correlation_id": self._correlation_id, }, ) del item["appointment_type"] self.__dict__.update(item) self.appointment_type.from_dict(item_app_type) def get_appointment_info_from_acuity(self, force_refresh=False): if (self.acuity_info is None) or (force_refresh is True): self.acuity_info = self._acuity_client.get_appointment_by_id( self.appointment_id) self.appointment_type.type_id = str( self.acuity_info["appointmentTypeID"]) self.appointment_type_id = self.appointment_type.type_id self.calendar_name = self.acuity_info["calendar"] self.calendar_id = str(self.acuity_info["calendarID"]) self.participant_email = self.acuity_info["email"] self.appointment_date = self.acuity_info["datetime"].split("T")[0] # intake form processing if (self.anon_project_specific_user_id is None) or (self.anon_user_task_id is None): for form in self.acuity_info["forms"]: if form["id"] == ACUITY_USER_METADATA_INTAKE_FORM_ID: intake_form_fields = { x.get("name"): x.get("value") for x in form["values"] } self.anon_project_specific_user_id = intake_form_fields.get( "anon_project_specific_user_id") self.anon_user_task_id = intake_form_fields.get( "anon_user_task_id") return self.acuity_info def get_appointment_item_from_ddb(self): return self._ddb_client.get_item(table_name=APPOINTMENTS_TABLE, key=self.appointment_id) def update_latest_participant_notification(self): self.latest_participant_notification = str(utils.now_with_tz()) result = self._ddb_client.update_item( table_name=APPOINTMENTS_TABLE, key=self.appointment_id, name_value_pairs={ "latest_participant_notification": self.latest_participant_notification }, ) assert ( result["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK ), f"Call to ddb client update_item method failed with response {result}" return result["ResponseMetadata"]["HTTPStatusCode"]