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"]
Esempio n. 5
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"]
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(),
                    },
                )