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 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
Exemple #3
0
class PersonalLinkManager:
    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 _query_user_link(self) -> list:
        """
        Retrieves a personal link previously assigned to this user
        """
        return self.ddb_client.query(
            table_name=const.PersonalLinksTable.NAME,
            IndexName="assigned-links",
            KeyConditionExpression=
            "anon_project_specific_user_id = :anon_project_specific_user_id "
            "AND account_survey_id = :account_survey_id",
            ExpressionAttributeValues={
                ":anon_project_specific_user_id":
                self.anon_project_specific_user_id,
                ":account_survey_id": self.account_survey_id,
            },
        )

    def _assign_link_to_user(self, unassigned_links: list) -> str:
        """
        Assigns to user the unassigned link with the soonest expiration date.
        An existing anon_project_specific_user_id is checked at assignment type to protect against
        the unlikely but possible scenario where a concurrent invocation of the
        lambda has assigned the same link to a different user.

        Args:
            unassigned_links (list): All unassigned links for this account_survey_id in PersonalLinks table

        Returns:
            A url representing the personal link assigned to this user

        """
        # assign oldest link to user
        unassigned_links.sort(key=lambda x: x["expires"])
        user_id_attr_name = "anon_project_specific_user_id"
        logger = utils.get_logger()
        for unassigned_link in unassigned_links:
            user_link = unassigned_link["url"]
            try:
                self.ddb_client.update_item(
                    table_name=const.PersonalLinksTable.NAME,
                    key=self.account_survey_id,
                    name_value_pairs={
                        "status": "assigned",
                        user_id_attr_name: self.anon_project_specific_user_id,
                    },
                    key_name="account_survey_id",
                    sort_key={"url": user_link},
                    ConditionExpression=Attr(user_id_attr_name).not_exists(),
                )
            except ClientError:
                logger.info(
                    "Link assignment failed; link is already assigned to another user",
                    extra={
                        "user_link": user_link,
                    },
                )
            else:
                return user_link

        logger.info(
            "Ran out of unassigned links; creating some more and retrying",
            extra={
                "unassigned_links": unassigned_links,
            },
        )
        unassigned_links = self._create_personal_links()
        return self._assign_link_to_user(unassigned_links)

    def _put_create_personal_links_event(self):
        eb_event = eb.ThiscoveryEvent({
            "detail-type": "create_personal_links",
            "detail": {
                "account": self.account,
                "survey_id": self.survey_id,
                "project_task_id": self.project_task_id,
            },
        })
        return eb_event.put_event()

    def _create_personal_links(self) -> list[dict]:
        dlg = DistributionLinksGenerator(
            account=self.account,
            survey_id=self.survey_id,
            contact_list_id=const.DISTRIBUTION_LISTS[self.account]["id"],
            correlation_id=self.correlation_id,
            project_task_id=self.project_task_id,
        )
        return dlg.generate_links_and_upload_to_dynamodb()

    def get_personal_link(self) -> str:
        try:
            return self._query_user_link()[0]["url"]
        except IndexError:  # user link not found; get unassigned links and assign one to user
            unassigned_links = get_unassigned_links(
                account_survey_id=self.account_survey_id,
                ddb_client=self.ddb_client)
            unassigned_links_len = len(unassigned_links)
            if (not unassigned_links
                ):  # unassigned links not found; create some synchronously
                unassigned_links = self._create_personal_links()
            elif (unassigned_links_len < const.PersonalLinksTable.BUFFER
                  ):  # create links asynchronously
                self._put_create_personal_links_event()

            user_link = self._assign_link_to_user(unassigned_links)
            return user_link