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 __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 __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 setUp(self) -> None:
     # create an Acuity test appointment and process booking
     self.clear_appointments_table()
     self.clear_notifications_table()
     self.acuity_client = AcuityClient()
     self.acuity_info = self.acuity_client.create_appointment(
         appointment_datetime=datetime(2022, 1, 30, 10, 15),
         type_id=14792299,  # Test appointment
         email="*****@*****.**",
         first_name="Eddie",
         last_name="Eagleton",
         anon_user_task_id="4a7a29e8-2869-469f-a922-5e9ff5af4583",
         anon_project_specific_user_id="a7a8e630-cb7e-4421-a9b2-b8bad0298267",
         calendar_id=3887437,  # Andy Outlook
     )
     self.appointment_id = self.acuity_info["id"]
     try:
         booking_eb_event = self.views_interview_booked_event_from_acuity_info(
             self.acuity_info
         )
         views.interview_booked(booking_eb_event, dict())
     except:
         self.tearDown()
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 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 TestViewsReschedulingOnDifferentCalendar(AppointmentsTestCase, DdbMixin):
    def setUp(self) -> None:
        # create an Acuity test appointment and process booking
        self.clear_appointments_table()
        self.clear_notifications_table()
        self.acuity_client = AcuityClient()
        self.acuity_info = self.acuity_client.create_appointment(
            appointment_datetime=datetime(2022, 1, 30, 10, 15),
            type_id=14792299,  # Test appointment
            email="*****@*****.**",
            first_name="Eddie",
            last_name="Eagleton",
            anon_user_task_id="4a7a29e8-2869-469f-a922-5e9ff5af4583",
            anon_project_specific_user_id="a7a8e630-cb7e-4421-a9b2-b8bad0298267",
            calendar_id=3887437,  # Andy Outlook
        )
        self.appointment_id = self.acuity_info["id"]
        try:
            booking_eb_event = self.views_interview_booked_event_from_acuity_info(
                self.acuity_info
            )
            views.interview_booked(booking_eb_event, dict())
        except:
            self.tearDown()

    def tearDown(self) -> None:
        self.acuity_client.cancel_appointment(self.appointment_id)

    def test_process_rescheduling_with_different_interviewer_ok(self):
        # clear booking notifications
        self.clear_notifications_table()

        # reschedule appointment
        self.acuity_client.reschedule_appointment(
            self.appointment_id,
            datetime(2022, 1, 31, 11, 15),
            calendarID=4038206,
        )

        # generate Views rescheduling event
        rescheduling_info = {
            **self.acuity_info,
            "datetime": "2022-01-31T11:15:00+0000",
            "calendarID": 4038206,
            "calendar": "Andre",
        }
        test_eb_event = self.views_interview_rescheduled_event_from_acuity_info(
            rescheduling_info
        )

        result = test_tools.test_eb_request(
            local_method=views.interview_rescheduled,
            aws_eb_event=test_eb_event,
            aws_processing_delay=12,
        )
        if test_tools.tests_running_on_aws():
            self.assertEqual(
                HTTPStatus.OK, result["ResponseMetadata"]["HTTPStatusCode"]
            )
            expected_appointment = test_eb_event["detail"]
            self.check_dynamodb_appointment(expected_appointment=expected_appointment)
            # check notifications that were created in notifications table
            notifications = self.ddb_client.scan(
                table_name=self.notifications_table,
                table_name_verbatim=True,
            )
            self.assertEqual(3, len(notifications))
            notific_types = [x["type"] for x in notifications]
            self.assertEqual(["transactional-email"] * 3, notific_types)
            templates = [x["details"]["template_name"] for x in notifications]
            expected_templates = ["views_interview_rescheduled_researcher"] * 2 + [
                "views_interview_rescheduled_participant"
            ]
            self.assertCountEqual(expected_templates, templates)
        else:
            (
                storing_result,
                participant_and_researchers_notification_results,
            ) = result
            self.assertEqual(
                HTTPStatus.OK, storing_result["ResponseMetadata"]["HTTPStatusCode"]
            )
            participant_result = participant_and_researchers_notification_results.get(
                "participant"
            )
            self.assertEqual(HTTPStatus.NO_CONTENT, participant_result)
            researchers_result = participant_and_researchers_notification_results.get(
                "researchers"
            )
            self.assertEqual([HTTPStatus.NO_CONTENT] * 4, researchers_result)
Esempio n. 9
0
#
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU Affero General Public License for more details.
#
#   A copy of the GNU Affero General Public License is available in the
#   docs folder of this project.  It is also available www.gnu.org/licenses/
#
from local.dev_config import TestAppointmentConfig as tac
import local.secrets
import sys
from datetime import datetime
from common.acuity_utilities import AcuityClient

client = AcuityClient()


def cancel_appointment(appointment_id):
    return client.cancel_appointment(appointment_id)


def create_test_appointment():
    return client.create_appointment(
        appointment_datetime=tac.datetime,
        type_id=tac.appointment_type,
        email=tac.email,
        first_name=tac.first_name,
        last_name=tac.last_name,
        anon_user_task_id=tac.anon_user_task_id,
        anon_project_specific_user_id=tac.anon_project_specific_user_id,