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)
# # 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,