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']
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"]
class Consent: """ Represents a consent item in Dynamodb """ 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) def as_dict(self): return { k: v for k, v in self.__dict__.items() if (k[0] != "_") and ( k not in ["created", "modified", "details", "type"]) } def from_dict(self, consent_dict): self.__dict__.update(consent_dict) def ddb_dump(self, update_allowed=False): self._get_project() result = self._ddb_client.put_item( table_name=CONSENT_DATA_TABLE, key=self.project_task_id, key_name="project_task_id", item_type="qualtrics-consent-data", item_details=dict(), item=self.as_dict(), update_allowed=update_allowed, )["ResponseMetadata"]["HTTPStatusCode"] assert result == HTTPStatus.OK return result def ddb_load(self): if self.modified is None: self._get_project_task_id() item = self._ddb_client.get_item( table_name=CONSENT_DATA_TABLE, key=self.project_task_id, key_name="project_task_id", sort_key={"consent_id": self.consent_id}, correlation_id=self._correlation_id, ) try: self.__dict__.update(item) except TypeError: raise utils.ObjectDoesNotExistError( f"Consent item {self.project_task_id}, {self.consent_id} could not be found in Dynamodb", details={ "consent_dict": self.as_dict(), "correlation_id": self._correlation_id, }, ) else: return HTTPStatus.OK def _get_project_task_id(self): if self.project_task_id is None: self.project_task_id = ( self._core_api_client.get_user_task_from_anon_user_task_id( anon_user_task_id=self.anon_user_task_id) ["project_task_id"]) assert self.project_task_id def _get_project(self): if self.project_id is None: self._get_project_task_id() project = self._core_api_client.get_project_from_project_task_id( project_task_id=self.project_task_id) self.project_id = project["id"] self.project_short_name = project["name"] self.project_name = project["name"]
class InterviewTask(DdbBaseItem): """ Represents an interview system task item in ddb table InterviewTasks """ 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 as_dict(self): return { k: v for k, v in self.__dict__.items() if (k[0] != "_") and (k not in ["created"]) } def ddb_dump(self, update_allowed=False): return self._ddb_client.put_item( table_name=const.INTERVIEW_TASKS_TABLE["name"], key=str(self._project_task_id), item_type="interview_task", item_details=None, item=self.as_dict(), update_allowed=update_allowed, key_name=const.INTERVIEW_TASKS_TABLE["partition_key"], sort_key={const.INTERVIEW_TASKS_TABLE["sort_key"]: self._interview_task_id}, ) def ddb_load(self): if self.modified is None: if self._project_task_id: item = self._ddb_client.get_item( table_name=const.INTERVIEW_TASKS_TABLE["name"], key=str(self._project_task_id), key_name=const.INTERVIEW_TASKS_TABLE["partition_key"], sort_key={ const.INTERVIEW_TASKS_TABLE["sort_key"]: self._interview_task_id }, ) items = [item] else: items = self._ddb_client.query( table_name=const.INTERVIEW_TASKS_TABLE["name"], IndexName="interview-task-id-index", KeyConditionExpression="interview_task_id = :interview_task_id", ExpressionAttributeValues={ ":interview_task_id": self._interview_task_id, }, ) items_n = len(items) assert ( items_n <= 1 ), f'Found {items_n} interview_tasks in {const.INTERVIEW_TASKS_TABLE["name"]} ddb table; expected 1' try: self.__dict__.update(items[0]) except (IndexError, TypeError): raise utils.ObjectDoesNotExistError( f"InterviewTask could not be found in Dynamodb", details={ "project_task_id": self._project_task_id, "interview_task_id": self._interview_task_id, "InterviewTask": self.as_dict(), }, )