Exemplo n.º 1
0
 def __init__(self, survey_consent_event):
     self.logger = survey_consent_event.get("logger", utils.get_logger())
     self.correlation_id = survey_consent_event.get(
         "correlation_id", utils.new_correlation_id())
     self.consent_dict = json.loads(survey_consent_event["body"])
     self.consent_info_url = self.consent_dict["consent_info_url"]
     del self.consent_dict["consent_info_url"]
     consent_embedded_data_fieldname = "consent_statements"
     self.consent_dict[consent_embedded_data_fieldname] = json.loads(
         self.consent_dict[consent_embedded_data_fieldname])
     self.to_recipient_email = self.consent_dict.get("to_recipient_email")
     try:
         self.template_name = self.consent_dict["template_name"]
     except KeyError:
         self.template_name = DEFAULT_CONSENT_EMAIL_TEMPLATE
     else:
         del self.consent_dict["template_name"]
     try:
         self.consent_dict[
             "consent_datetime"] = qualtrics2thiscovery_timestamp(
                 self.consent_dict["consent_datetime"])
     except KeyError:
         self.consent_dict["consent_datetime"] = str(utils.now_with_tz())
     self.core_api_client = CoreApiClient(
         correlation_id=self.correlation_id)
     self.consent = Consent(core_api_client=self.core_api_client,
                            correlation_id=self.correlation_id)
     self.consent.from_dict(consent_dict=self.consent_dict)
Exemplo n.º 2
0
    def __init__(self):
        project_task_id = input("Please enter the id of the project task "
                                "this reminder is about:")

        self.project_task = pg_utils.execute_query(
            base_sql=sql_q.TASK_REMINDER_SQL, params=(project_task_id, ))[0]

        csv_import = input("Would you like to import a csv file containing "
                           "anon_project_specific_user_ids? (y/N)")

        if csv_import in ["y", "Y"]:
            importer = CsvImporter(
                anon_project_specific_user_id_column=
                "anon_project_specific_user_id",
                csvfile_path=None,
            )
            anon_project_specific_user_ids = (
                importer.output_list_of_anon_project_specific_user_ids())
        else:
            anon_project_specific_user_ids = input(
                "Please paste a list of anon_project_specific_user_ids separated by commas:"
            )

        anon_ids = anon_project_specific_user_ids.split(",")
        self.anon_ids = [x.strip() for x in anon_ids]
        self.users = list()
        for anon_id in anon_ids:
            user = u.get_user_by_anon_project_specific_user_id(anon_id)[0]
            if user:
                self.users.append(user)
            else:
                raise ValueError(f"User {anon_id} could not be found")

        self.core_api_client = CoreApiClient()
 def __init__(self, user_group_id, users_to_invite):
     """
     Args:
         user_group_id (str):
         users_to_invite (list):
     """
     self.user_group_id = user_group_id
     self.users_to_invite = users_to_invite
     self.project_task = None
     self.core_api_client = CoreApiClient()
    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
        )
Exemplo n.º 5
0
    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,
        )
Exemplo n.º 6
0
 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 __init__(self, logger=None, correlation_id=None, test_event=False):
     """
     Args:
         logger:
         correlation_id:
         test_event: set to True when the event originates from unittests
     """
     self.logger = logger
     if logger is None:
         self.logger = utils.get_logger()
     self.core_api_client = CoreApiClient(correlation_id=correlation_id)
     self.correlation_id = correlation_id
     self.test_mode = test_event
     self.appointment = None
     self.original_booking = None  # used for determining if interviewer has changed when processing rescheduling events
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
            },
        )
def core_service_alarm_test(event, context):
    client = CoreApiClient(correlation_id=event['correlation_id'])
    response = client.send_transactional_email(template_name='alarm_test', brew_coffee=True)
    assert response['statusCode'] == HTTPStatus.METHOD_NOT_ALLOWED, f'Core {ERROR_MESSAGE} {response}'
    return response
class Inviter:
    def __init__(self, user_group_id, users_to_invite):
        """
        Args:
            user_group_id (str):
            users_to_invite (list):
        """
        self.user_group_id = user_group_id
        self.users_to_invite = users_to_invite
        self.project_task = None
        self.core_api_client = CoreApiClient()

    def _get_task_from_user_group_id(self):
        tasks = pg_utils.execute_query(
            base_sql=sql_q.TASKS_BY_USER_GROUP_ID_SQL, params=(self.user_group_id,)
        )
        if len(tasks) != 1:
            raise ValueError(
                f"User group {self.user_group_id} is associated with {len(tasks)} tasks; expected 1"
            )
        self.project_task = tasks[0]

    def _get_template_details(self):
        custom_properties_base = {
            "project_short_name": self.project_task.get("project_name"),
            "project_name": self.project_task.get("project_name"),
        }

        if self.project_task["task_type_name"] == "interview":
            template_name = "interview_task_invite"
        else:
            template_name = "generic_task_invite"
            custom_properties_base.update(
                task_short_name=self.project_task.get("task_description")
            )

        return template_name, custom_properties_base

    def send_invites(self):
        invited_users = list()
        self._get_task_from_user_group_id()
        template_name, custom_properties_base = self._get_template_details()
        for user in self.users_to_invite:
            user_id = user["id"]
            email_dict = {
                "to_recipient_id": user_id,
                "custom_properties": {
                    **custom_properties_base,
                    "user_first_name": user["first_name"],
                },
            }
            try:
                self.core_api_client.send_transactional_email(
                    template_name=template_name, **email_dict
                )
            except AssertionError:
                print(
                    f"The following users were invited before an error occurred:\n"
                    f"{invited_users}"
                )
                raise
            else:
                invited_users.append(user_id)
Exemplo n.º 11
0
class SurveyInitialiser:
    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 query_task_response(self, survey_id):
        query_result = self.ddb_client.query(
            table_name=const.TASK_RESPONSES_TABLE["name"],
            IndexName="user-index",
            KeyConditionExpression="user_id = :user_id "
            "AND survey_id = :survey_id",
            ExpressionAttributeValues={
                ":user_id": self.user_id,
                ":survey_id": survey_id,
            },
        )
        if query_result:
            response = query_result[0]["participant_responses"]
            self.cached_responses[survey_id] = response
            return response
        else:
            self.missing_responses.append(survey_id)
            return None

    def parse_init_config(self):
        """
        Populates self.target_embedded_data with the best data available
        """
        for embedded_field_name, v_list in self.survey_init_config.details.items(
        ):
            for option_dict in v_list:
                previous_survey_id = option_dict["survey"]
                try:
                    # load already queried and cached previous survey response
                    source_response = self.cached_responses[previous_survey_id]
                except KeyError:
                    if previous_survey_id in self.missing_responses:
                        # we already know this doesn't exist in Ddb, so no point querying
                        source_response = None
                    else:
                        # we haven't tried fetching this previous response yet
                        source_response = self.query_task_response(
                            previous_survey_id)
                if source_response:
                    # we have a previous response; check it includes the question data we are looking for
                    source_data = source_response.get(option_dict["question"])
                    if source_data:
                        if isinstance(source_data, decimal.Decimal):
                            source_data = str(source_data)
                        elif isinstance(source_data, list):
                            source_data = [
                                int(x) for x in source_data
                            ]  # Qualtrics doesn't parse list of strings properly
                            source_data = json.dumps(source_data)
                        self.target_embedded_data[
                            embedded_field_name] = source_data
                        break  # no need to attempt other options; else attempt next option_dict

    def init_response(self):
        return self.responses_client.update_response(
            response_id=self.destination_response_id,
            embedded_data=self.target_embedded_data,
        )

    def main(self):
        self.parse_init_config()
        return self.init_response()

    def get_response(self):
        self.parse_init_config()
        return self.target_embedded_data
Exemplo n.º 12
0
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"]
Exemplo n.º 13
0
class ConsentEvent:
    def __init__(self, survey_consent_event):
        self.logger = survey_consent_event.get("logger", utils.get_logger())
        self.correlation_id = survey_consent_event.get(
            "correlation_id", utils.new_correlation_id())
        self.consent_dict = json.loads(survey_consent_event["body"])
        self.consent_info_url = self.consent_dict["consent_info_url"]
        del self.consent_dict["consent_info_url"]
        consent_embedded_data_fieldname = "consent_statements"
        self.consent_dict[consent_embedded_data_fieldname] = json.loads(
            self.consent_dict[consent_embedded_data_fieldname])
        self.to_recipient_email = self.consent_dict.get("to_recipient_email")
        try:
            self.template_name = self.consent_dict["template_name"]
        except KeyError:
            self.template_name = DEFAULT_CONSENT_EMAIL_TEMPLATE
        else:
            del self.consent_dict["template_name"]
        try:
            self.consent_dict[
                "consent_datetime"] = qualtrics2thiscovery_timestamp(
                    self.consent_dict["consent_datetime"])
        except KeyError:
            self.consent_dict["consent_datetime"] = str(utils.now_with_tz())
        self.core_api_client = CoreApiClient(
            correlation_id=self.correlation_id)
        self.consent = Consent(core_api_client=self.core_api_client,
                               correlation_id=self.correlation_id)
        self.consent.from_dict(consent_dict=self.consent_dict)

    @classmethod
    def from_eb_event(cls, event):
        detail_type = event["detail-type"]
        assert detail_type == "user_consent", f"Unexpected detail-type: {detail_type}"
        event["body"] = json.dumps(event["detail"])
        return cls(survey_consent_event=event)

    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 _split_consent_datetime(self):
        consent_dt = parser.parse(self.consent.consent_datetime)
        date = consent_dt.strftime("%e %B %Y")
        time = consent_dt.strftime("%H:%M")
        return date, time

    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 parse(self):
        dump_result = None
        if self.consent.anon_project_specific_user_id or self.consent.anon_user_task_id:
            try:
                dump_result = self.consent.ddb_dump()
            except:
                self.logger.error(
                    "Failed to store consent data in Dynamodb",
                    extra={
                        "consent_as_dict": self.consent.as_dict(),
                        "correlation_id": self.correlation_id,
                        "traceback": traceback.format_exc(),
                    },
                )
        else:  # not a thiscovery participant (e.g. consent event contains only partner email)
            self.logger.warning(
                "Consent event does not contain thiscovery participant ids; skipped storing in ddb",
                extra={
                    "consent_as_dict": self.consent.as_dict(),
                    "correlation_id": self.correlation_id,
                    "traceback": traceback.format_exc(),
                },
            )
            dump_result = "aborted"
        notification_result = self._notify_participant().get("statusCode")
        assert notification_result == HTTPStatus.NO_CONTENT
        return dump_result, notification_result
Exemplo n.º 14
0
class Reminder:
    def __init__(self):
        project_task_id = input("Please enter the id of the project task "
                                "this reminder is about:")

        self.project_task = pg_utils.execute_query(
            base_sql=sql_q.TASK_REMINDER_SQL, params=(project_task_id, ))[0]

        csv_import = input("Would you like to import a csv file containing "
                           "anon_project_specific_user_ids? (y/N)")

        if csv_import in ["y", "Y"]:
            importer = CsvImporter(
                anon_project_specific_user_id_column=
                "anon_project_specific_user_id",
                csvfile_path=None,
            )
            anon_project_specific_user_ids = (
                importer.output_list_of_anon_project_specific_user_ids())
        else:
            anon_project_specific_user_ids = input(
                "Please paste a list of anon_project_specific_user_ids separated by commas:"
            )

        anon_ids = anon_project_specific_user_ids.split(",")
        self.anon_ids = [x.strip() for x in anon_ids]
        self.users = list()
        for anon_id in anon_ids:
            user = u.get_user_by_anon_project_specific_user_id(anon_id)[0]
            if user:
                self.users.append(user)
            else:
                raise ValueError(f"User {anon_id} could not be found")

        self.core_api_client = CoreApiClient()

    def _get_template_details(self):
        custom_properties_base = {
            "project_short_name": self.project_task.get("project_name"),
            "project_name": self.project_task.get("project_name"),
        }

        if self.project_task["task_type_name"] == "interview":
            template_name = "interview_task_reminder"
        else:
            template_name = "generic_task_reminder"
            custom_properties_base.update(
                task_short_name=self.project_task.get("task_description"))

        return template_name, custom_properties_base

    def remind_users(self):
        reminded_users = list()
        template_name, custom_properties_base = self._get_template_details()
        for user in self.users:
            user_id = user["id"]
            email_dict = {
                "to_recipient_id": user_id,
                "custom_properties": {
                    **custom_properties_base,
                    "user_first_name":
                    user["first_name"],
                },
            }
            try:
                self.core_api_client.send_transactional_email(
                    template_name=template_name, **email_dict)
            except AssertionError:
                print(
                    f"The following users were reminded before an error occurred:\n"
                    f"{reminded_users}")
                raise
            else:
                reminded_users.append(user_id)