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
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