Beispiel #1
0
 def __init__(self,
              qualtrics_account_name="cambridge",
              survey_id=None,
              correlation_id=None):
     client = SurveyDefinitionsClient(
         qualtrics_account_name=qualtrics_account_name,
         survey_id=survey_id,
         correlation_id=correlation_id,
     )
     response = client.get_survey()
     assert (response["meta"]["httpStatus"] == "200 - OK"
             ), f"Call to Qualtrics API failed with response {response}"
     self.survey_id = survey_id
     self.definition = response["result"]
     self.flow = self.definition["SurveyFlow"]["Flow"]
     self.blocks = self.definition["Blocks"]
     self.questions = self.definition["Questions"]
     self.modified = self.definition["LastModified"]
     self.ddb_client = Dynamodb(stack_name=const.STACK_NAME)
     self.logger = utils.get_logger()
     self.logger.debug(
         "Initialised SurveyDefinition",
         extra={
             "__dict__": self.__dict__,
             "correlation_id": correlation_id,
         },
     )
 def __init__(self, response_dict, correlation_id=None):
     self.survey_id = response_dict.pop("survey_id", None)
     self.response_id = response_dict.pop("response_id", None)
     self.project_task_id = str(
         utils.validate_uuid(response_dict.pop("project_task_id", None)))
     self.anon_project_specific_user_id = str(
         utils.validate_uuid(
             response_dict.pop("anon_project_specific_user_id", None)))
     self.anon_user_task_id = str(
         utils.validate_uuid(response_dict.pop("anon_user_task_id", None)))
     for required_parameter_name, value in [
         ("survey_id", self.survey_id),
         ("response_id", self.response_id),
     ]:
         if not value:
             raise utils.DetailedValueError(
                 f"Required parameter {required_parameter_name} not present in body of call",
                 details={
                     "response_dict": response_dict,
                     "correlation_id": correlation_id,
                 },
             )
     self.response_dict = response_dict
     self.ddb_client = Dynamodb(
         stack_name=const.STACK_NAME,
         correlation_id=correlation_id,
     )
     self.correlation_id = correlation_id
 def __init__(self, logger=None, correlation_id=None):
     self.ddb_client = Dynamodb(stack_name=STACK_NAME)
     self.correlation_id = correlation_id
     self.target_appointment_ids = self.get_appointments_to_be_deleted()
     self.logger = logger
     if logger is None:
         self.logger = utils.get_logger()
 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 __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()
Beispiel #6
0
def persist_thiscovery_event(event, context):
    event.pop("logger", None)
    event.pop("correlation_id", None)
    ddb_client = Dynamodb(stack_name=const.STACK_NAME, correlation_id=event["id"])
    table = ddb_client.get_table(table_name=const.AUDIT_TABLE)
    result = table.put_item(Item=event)
    assert result["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK
    return {"statusCode": HTTPStatus.OK, "body": json.dumps("")}
Beispiel #7
0
 def ddb_load_interview_questions(survey_id):
     ddb_client = Dynamodb(stack_name=const.STACK_NAME)
     return ddb_client.query(
         table_name=const.INTERVIEW_QUESTIONS_TABLE["name"],
         KeyConditionExpression="survey_id = :survey_id",
         ExpressionAttributeValues={
             ":survey_id": survey_id,
         },
     )
 def __init__(self, csvfile_path, consent_questions_tags, consent_info_url):
     super(ConsentEmailsManager, self).__init__(csvfile_path)
     self.consent_question_tags = consent_questions_tags
     self.consent_info_url = consent_info_url
     self.consent_statements = dict()
     self.ddb_client = Dynamodb(stack_name=const.STACK_NAME)
     self.anon_user_task_id_column_name = "anon_user_task_id"
     self.anon_project_specific_user_id_column_name = "anon_project_specific_user_id"
     self.consent_datetime_column_name = "StartDate"
     self.sent_emails = list()
 def setUpClass(cls):
     super().setUpClass()
     cls.ddb_client = Dynamodb(stack_name=const.STACK_NAME)
     cls.ddb_client.delete_all(
         table_name=const.AUTH0_EVENTS_TABLE_NAME,
         key_name=const.AUTH0_EVENTS_TABLE_HASH,
         sort_key_name=const.AUTH0_EVENTS_TABLE_SORT,
     )
     cls.eb_client = EventbridgeClient()
def add_template_to_ddb(template_id, template_name, template_type,
                        formatted_custom_properties, preview_url):
    ddb_client = Dynamodb()
    ddb_client.put_item(
        table_name="HubspotEmailTemplates",
        key=template_name,
        item_type=template_type,
        item_details={
            "preview_url": preview_url,
        },
        item={
            "bcc": [],
            "cc": [],
            "contact_properties": [],
            "custom_properties": formatted_custom_properties,
            "from": TRANSACTIONAL_EMAILS_FROM_ADDRESS,
            "hs_template_id": template_id,
        },
    )
Beispiel #11
0
    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
Beispiel #12
0
def get_unassigned_links(account_survey_id: str,
                         ddb_client=None) -> list[dict]:
    """
    Retrieves existing personal links that have not yet been assigned to an user
    """
    if ddb_client is None:
        ddb_client = Dynamodb(stack_name=const.STACK_NAME)
    return ddb_client.query(
        table_name=const.PersonalLinksTable.NAME,
        IndexName="unassigned-links",
        KeyConditionExpression="account_survey_id = :account_survey_id "
        "AND #status = :link_status",
        ExpressionAttributeValues={
            ":account_survey_id": account_survey_id,
            ":link_status": "new",
        },
        ExpressionAttributeNames={
            "#status": "status"
        },  # needed because status is a reserved word in ddb
    )
Beispiel #13
0
def clear_notification_queue(event, context):
    logger = event["logger"]
    correlation_id = event["correlation_id"]
    seven_days_ago = now_with_tz() - timedelta(days=7)
    # processed_notifications = get_notifications('processing_status', ['processed'])
    processed_notifications = c_notif.get_notifications_to_clear(
        datetime_threshold=seven_days_ago, stack_name=const.STACK_NAME)
    notifications_to_delete = [
        x for x in processed_notifications
        if (parser.isoparse(x["modified"]) < seven_days_ago) and (
            x[NotificationAttributes.TYPE.value] !=
            NotificationType.TRANSACTIONAL_EMAIL.value)
    ]
    deleted_notifications = list()
    ddb_client = Dynamodb(stack_name=const.STACK_NAME)
    for n in notifications_to_delete:
        response = ddb_client.delete_item(c_notif.NOTIFICATION_TABLE_NAME,
                                          n["id"],
                                          correlation_id=correlation_id)
        if response["ResponseMetadata"][
                "HTTPStatusCode"] == http.HTTPStatus.OK:
            deleted_notifications.append(n)
        else:
            logger.info(
                f"Notifications deleted before an error occurred",
                extra={
                    "deleted_notifications": deleted_notifications,
                    "correlation_id": correlation_id,
                },
            )
            logger.error(
                "Failed to delete notification",
                extra={
                    "notification": n,
                    "response": response
                },
            )
            raise Exception(
                f"Failed to delete notification {n}; received response: {response}"
            )
    return deleted_notifications
Beispiel #14
0
    def __init__(self, **kwargs):
        self._logger = utils.get_logger()
        self._correlation_id = kwargs.get("correlation_id")
        self.destination_survey_id = kwargs.get("survey_id")
        self.destination_response_id = kwargs.get("response_id")
        err_message = "Call to initialise_survey missing mandatory data: {}"
        assert self.destination_survey_id, err_message.format("survey_id")
        assert self.destination_survey_id, err_message.format("response_id")

        self.survey_init_config = SurveyInitConfig(
            destination_survey_id=self.destination_survey_id,
            correlation_id=self._correlation_id,
        )
        self.survey_init_config.get()
        try:
            self.survey_init_config.details
        except AttributeError:
            raise utils.ObjectDoesNotExistError(
                f"Initialisation config not found for survey {self.destination_survey_id}",
                details={},
            )

        self.destination_account = kwargs.get("account", "cambridge")
        self.anon_project_specific_user_id = kwargs.get(
            "anon_project_specific_user_id")
        assert self.anon_project_specific_user_id, err_message.format(
            "anon_project_specific_user_id")
        self._core_client = CoreApiClient(correlation_id=self._correlation_id)
        user = self._core_client.get_user_by_anon_project_specific_user_id(
            self.anon_project_specific_user_id)
        self.user_id = user["id"]
        self.ddb_client = Dynamodb(stack_name=const.STACK_NAME,
                                   correlation_id=self._correlation_id)
        self.cached_responses = dict()
        self.missing_responses = list()
        self.target_embedded_data = dict()
        self.responses_client = ResponsesClient(
            survey_id=self.destination_survey_id,
            qualtrics_account_name=self.destination_account,
            correlation_id=self._correlation_id,
        )
def get_appointments_by_type(type_ids, correlation_id=None):
    """
    Args:
        type_ids (list): Appointment type ids to query ddb
        correlation_id:
    Returns:
        List of appointments matching any of the input type ids
    """
    ddb_client = Dynamodb(stack_name=STACK_NAME, correlation_id=correlation_id)
    items = list()
    for i in type_ids:
        result = ddb_client.query(
            table_name=APPOINTMENTS_TABLE,
            IndexName="project-appointments-index",
            KeyConditionExpression="appointment_type_id = :type_id",
            ExpressionAttributeValues={
                ":type_id": i,
            },
        )
        items += result
    return items
Beispiel #16
0
 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)
class AppointmentsCleaner:
    def __init__(self, logger=None, correlation_id=None):
        self.ddb_client = Dynamodb(stack_name=STACK_NAME)
        self.correlation_id = correlation_id
        self.target_appointment_ids = self.get_appointments_to_be_deleted()
        self.logger = logger
        if logger is None:
            self.logger = utils.get_logger()

    def get_appointments_to_be_deleted(self, now=None):
        """
        Queries ddb for appointments booked for 60 days ago
        """
        if now is None:
            now = utils.now_with_tz()
        date_format = "%Y-%m-%d"
        sixty_days_ago = now - datetime.timedelta(days=60)
        sixty_days_ago_string = sixty_days_ago.strftime(date_format)
        result = self.ddb_client.query(
            table_name=APPOINTMENTS_TABLE,
            IndexName="reminders-index",
            KeyConditionExpression="appointment_date = :date",
            ExpressionAttributeValues={
                ":date": sixty_days_ago_string,
            },
        )
        return [x["id"] for x in result]

    def delete_old_appointments(self):
        results = list()
        for app_id in self.target_appointment_ids:
            result = self.ddb_client.delete_item(
                table_name=APPOINTMENTS_TABLE,
                key=app_id,
            )
            results.append(result["ResponseMetadata"]["HTTPStatusCode"])
        return results
def main():
    calendar_name = input(
        "Please input the name of the calendar you want to add to Dynamodb, as shown in Acuity's UI:"
    )
    if not calendar_name:
        sys.exit()
    acuity_client = AcuityClient()
    ddb_client = Dynamodb(stack_name=STACK_NAME)
    acuity_calendars = acuity_client.get_calendars()
    pprint(acuity_calendars)
    target_calendar = None
    for c in acuity_calendars:
        if c["name"] == calendar_name:
            target_calendar = c
            continue
    if target_calendar:
        response = ddb_client.put_item(
            "Calendars",
            target_calendar["id"],
            item_type="acuity-calendar",
            item_details=target_calendar,
            item={
                "label": target_calendar["name"],
                "block_monday_morning": True,
                "emails_to_notify": list(),
                "myinterview_link": None,
            },
        )
        assert (
            response["ResponseMetadata"]["HTTPStatusCode"] == HTTPStatus.OK
        ), f"Dynamodb client put_item operation failed with response: {response}"
        print(
            f'Calendar "{calendar_name}" successfully added to Dynamodb table')
    else:
        raise utils.ObjectDoesNotExistError(
            f'Calendar "{calendar_name}" not found in Acuity')
Beispiel #19
0
 def test_deletion_of_questions_no_longer_in_survey_definition(self):
     mock_deleted_question = {
         "block_id": "BL_3qH1dnbq50y9V0a",
         "block_name": "Your experience using thiscovery",
         "question_description":
         "<p>Take a moment to reflect on how much you like bread before answering this question.</p>",
         "question_id": "QID12",
         "question_name": "Q12",
         "question_text":
         "<h3>Is thiscovery the best invention since sliced bread?</h3>",
         "sequence_no": "12",
         "survey_id": "SV_eDrjXPqGElN0Mwm",
         "survey_modified": "2021-02-16T15:59:12Z",
     }
     ddb_client = Dynamodb(stack_name=const.STACK_NAME)
     ddb_client.put_item(
         table_name=const.INTERVIEW_QUESTIONS_TABLE["name"],
         key=mock_deleted_question["survey_id"],
         item_type="interview_question",
         item_details=None,
         item=mock_deleted_question,
         update_allowed=True,
         key_name=const.INTERVIEW_QUESTIONS_TABLE["partition_key"],
         sort_key={
             const.INTERVIEW_QUESTIONS_TABLE["sort_key"]:
             mock_deleted_question["question_id"]
         },
     )
     test_event = copy.deepcopy(
         td.TEST_INTERVIEW_QUESTIONS_UPDATED_EB_EVENT)
     result = ep.put_interview_questions(test_event, None)
     self.assertEqual(HTTPStatus.OK, result["statusCode"])
     updated_question_ids, deleted_question_ids = json.loads(result["body"])
     self.assertEqual(4, len(updated_question_ids))
     self.assertEqual([mock_deleted_question["question_id"]],
                      deleted_question_ids)
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']
    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)
Beispiel #22
0
 def __init__(
     self,
     account: str,
     survey_id: str,
     contact_list_id: str,
     project_task_id: str,
     correlation_id: str = None,
 ):
     self.account = account
     self.survey_id = survey_id
     self.account_survey_id = f"{account}_{survey_id}"
     self.contact_list_id = contact_list_id
     self.dist_client = qualtrics.DistributionsClient(
         qualtrics_account_name=account)
     self.correlation_id = correlation_id
     self.ddb_client = Dynamodb(stack_name=const.STACK_NAME,
                                correlation_id=correlation_id)
     self.project_task_id = project_task_id
        def setup():
            ddb_client = Dynamodb(stack_name=const.STACK_NAME)
            ddb_client.delete_all(
                table_name=const.AUTH0_EVENTS_TABLE_NAME,
                key_name=const.AUTH0_EVENTS_TABLE_HASH,
                sort_key_name=const.AUTH0_EVENTS_TABLE_SORT,
            )

            ddb_client.batch_put_items(
                const.AUTH0_EVENTS_TABLE_NAME,
                td.METRICS_TEST_DATA,
                const.AUTH0_EVENTS_TABLE_HASH,
            )
Beispiel #24
0
 def clear_appointments_table(cls):
     try:
         cls.ddb_client.delete_all(table_name=app.APPOINTMENTS_TABLE)
     except AttributeError:
         cls.ddb_client = Dynamodb(stack_name=STACK_NAME)
         cls.ddb_client.delete_all(table_name=app.APPOINTMENTS_TABLE)
class TaskResponse(DdbBaseItem):
    """
    Represents a TaskResponse Ddb item
    """
    def __init__(
        self,
        response_id,
        event_time,
        anon_project_specific_user_id=None,
        anon_user_task_id=None,
        detail_type=None,
        detail=None,
        correlation_id=None,
        account=None,
    ):
        self._response_id = response_id
        self._event_time = event_time
        self.account = account
        self.anon_project_specific_user_id = anon_project_specific_user_id
        self.anon_user_task_id = anon_user_task_id
        self._detail_type = detail_type
        self._detail = detail
        self._correlation_id = correlation_id
        self._core_client = CoreApiClient(correlation_id=correlation_id)
        self._ddb_client = Dynamodb(stack_name=const.STACK_NAME,
                                    correlation_id=correlation_id)
        self.project_task_id = None
        split_response_id = response_id.split("-")
        assert (
            len(split_response_id) == 2
        ), f"response_id ({response_id}) not in expected format SurveyID-QualtricsResponseID"
        self.survey_id = split_response_id[0]
        self.qualtrics_response_id = split_response_id[1]

    @classmethod
    def from_eb_event(cls, event):
        event_detail = event["detail"]
        response_id = event_detail.pop("response_id")
        event_time = event["time"]
        try:
            anon_project_specific_user_id = event_detail.pop(
                "anon_project_specific_user_id")
            anon_user_task_id = event_detail.pop("anon_user_task_id")
        except KeyError as exc:
            raise utils.DetailedValueError(
                f"Mandatory {exc} data not found in source event",
                details={
                    "event": event,
                },
            )
        return cls(
            response_id=response_id,
            event_time=event_time,
            anon_project_specific_user_id=anon_project_specific_user_id,
            anon_user_task_id=anon_user_task_id,
            detail_type=event["detail-type"],
            detail=event_detail,
            correlation_id=event["id"],
            account=event_detail.pop("account", "cambridge"),
        )

    def get_project_task_id(self):
        user_task = self._core_client.get_user_task_from_anon_user_task_id(
            anon_user_task_id=self.anon_user_task_id)
        self.project_task_id = user_task["project_task_id"]

    def get_user_id(self):
        user = self._core_client.get_user_by_anon_project_specific_user_id(
            self.anon_project_specific_user_id)
        return user["id"]

    def ddb_dump(self, update_allowed=False, unpack_detail=False):
        item = self.as_dict()
        if unpack_detail:
            item.update(self._detail)
            self._detail = dict()
        return self._ddb_client.put_item(
            table_name=const.TASK_RESPONSES_TABLE["name"],
            key=self._response_id,
            item_type=self._detail_type,
            item_details=self._detail,
            item=item,
            update_allowed=update_allowed,
            key_name=const.TASK_RESPONSES_TABLE["partition_key"],
            sort_key={
                const.TASK_RESPONSES_TABLE["sort_key"]: self._event_time
            },
        )
Beispiel #26
0
class SurveyDefinition:
    def __init__(self,
                 qualtrics_account_name="cambridge",
                 survey_id=None,
                 correlation_id=None):
        client = SurveyDefinitionsClient(
            qualtrics_account_name=qualtrics_account_name,
            survey_id=survey_id,
            correlation_id=correlation_id,
        )
        response = client.get_survey()
        assert (response["meta"]["httpStatus"] == "200 - OK"
                ), f"Call to Qualtrics API failed with response {response}"
        self.survey_id = survey_id
        self.definition = response["result"]
        self.flow = self.definition["SurveyFlow"]["Flow"]
        self.blocks = self.definition["Blocks"]
        self.questions = self.definition["Questions"]
        self.modified = self.definition["LastModified"]
        self.ddb_client = Dynamodb(stack_name=const.STACK_NAME)
        self.logger = utils.get_logger()
        self.logger.debug(
            "Initialised SurveyDefinition",
            extra={
                "__dict__": self.__dict__,
                "correlation_id": correlation_id,
            },
        )

    @classmethod
    def from_eb_event(cls, event):
        logger = utils.get_logger()
        logger.debug(
            "EB event",
            extra={
                "event": event,
            },
        )
        event_detail = event["detail"]
        try:
            qualtrics_account_name = event_detail.pop("account")
            survey_id = event_detail.pop("survey_id")
        except KeyError as exc:
            raise utils.DetailedValueError(
                f"Mandatory {exc} data not found in source event",
                details={
                    "event": event,
                },
            )
        return cls(
            qualtrics_account_name=qualtrics_account_name,
            survey_id=survey_id,
            correlation_id=event["id"],
        )

    def get_interview_question_list_from_Qualtrics(self):
        def parse_question_html(s):
            text_m = PROMPT_RE.search(s)
            text = text_m.group(1)

            description_m = DESCRIPTION_RE.search(s)
            try:
                description = description_m.group(1)
            except AttributeError:
                description = None
            return text, description

        interview_question_list = list()
        question_counter = 1
        block_ids_flow = [
            x["ID"] for x in self.flow if x["Type"] not in ["Branch"]
        ]  # flow items of Branch type represent survey branching logic
        for block_id in block_ids_flow:
            block = self.blocks[block_id]
            block_name = block["Description"]
            question_ids = [x["QuestionID"] for x in block["BlockElements"]]
            for question_id in question_ids:
                q = self.questions[question_id]
                question_name = q["DataExportTag"]
                question_text_raw = q["QuestionText"]
                try:
                    question_text, question_description = parse_question_html(
                        question_text_raw)
                except AttributeError:  # no match found for PROMPT_RE
                    if SYSTEM_RE.findall(question_text_raw):
                        continue  # this is a system config question; skip
                    else:
                        raise utils.DetailedValueError(
                            "Mandatory prompt div could not be found in interview question",
                            details={
                                "question": question_text_raw,
                                "survey_id": self.survey_id,
                                "question_id": question_id,
                                "question_export_tag": question_name,
                            },
                        )
                question = InterviewQuestion(
                    survey_id=self.survey_id,
                    survey_modified=self.modified,
                    question_id=question_id,
                    question_name=question_name,
                    sequence_no=str(question_counter),
                    block_name=block_name,
                    block_id=block_id,
                    question_text=question_text,
                    question_description=question_description,
                )
                interview_question_list.append(question)
                question_counter += 1
        return interview_question_list

    def ddb_update_interview_questions(self):
        """
        Updates the list of interview questions held in Dynamodb for a particular survey.
        This includes not only adding and updating questions, but also deleting questions that are no longer
        present in the survey
        """
        ddb_question_list = self.ddb_load_interview_questions(self.survey_id)
        survey_question_list = self.get_interview_question_list_from_Qualtrics(
        )
        updated_question_ids = list()
        deleted_question_ids = list()
        for q in survey_question_list:
            self.ddb_client.put_item(
                table_name=const.INTERVIEW_QUESTIONS_TABLE["name"],
                key=q._survey_id,
                key_name=const.INTERVIEW_QUESTIONS_TABLE["partition_key"],
                item_type="interview_question",
                item_details=None,
                item=q.as_dict(),
                update_allowed=True,
                sort_key={
                    const.INTERVIEW_QUESTIONS_TABLE["sort_key"]:
                    q._question_id,
                },
            )
            updated_question_ids.append(q._question_id)

        for q in ddb_question_list:
            question_id = q["question_id"]
            if question_id not in updated_question_ids:
                self.ddb_client.delete_item(
                    table_name=const.INTERVIEW_QUESTIONS_TABLE["name"],
                    key=q["survey_id"],
                    key_name=const.INTERVIEW_QUESTIONS_TABLE["partition_key"],
                    sort_key={
                        const.INTERVIEW_QUESTIONS_TABLE["sort_key"]:
                        question_id,
                    },
                )
                deleted_question_ids.append(question_id)

        return updated_question_ids, deleted_question_ids

    @staticmethod
    def ddb_load_interview_questions(survey_id):
        ddb_client = Dynamodb(stack_name=const.STACK_NAME)
        return ddb_client.query(
            table_name=const.INTERVIEW_QUESTIONS_TABLE["name"],
            KeyConditionExpression="survey_id = :survey_id",
            ExpressionAttributeValues={
                ":survey_id": survey_id,
            },
        )

    @staticmethod
    def get_interview_questions(survey_id):
        """
        Get interview questions for a VIEWS interview. Randomises question order
        within blocks if that is specified by InterviewTask

        Args:
            survey_id: Qualtrics survey id of survey containing the interview questions

        Returns:

        """
        def randomise_questions_check():
            """
            Reads configuration in InterviewTask items containing survey_id to determine whether or not
            interview questions should be randomised.

            Returns:
                True if questions should be randomised; otherwise False
            """
            bool_config_choices = {True, False}
            error_message = (
                f"Conflicting interview task configuration(s) found for survey {survey_id}. "
                f"Random question order specified in some but not all interview tasks using that survey"
            )
            interview_tasks_table = const.InterviewTasksTable()

            # get config for live interviews with questions coming from survey_id
            live_config_matches = interview_tasks_table.query_live_survey_id_index(
                survey_id=survey_id)
            live_random_config_set = set()
            for m in live_config_matches:
                random_config = m.get("live_questions_random", False)
                live_random_config_set.add(random_config)

            # get config for on-demand interviews with questions coming from survey_id
            on_demand_config_matches = (
                interview_tasks_table.query_on_demand_survey_id_index(
                    survey_id=survey_id))
            on_demand_random_config_set = set()
            for m in on_demand_config_matches:
                random_config = m.get("on_demand_questions_random", False)
                on_demand_random_config_set.add(random_config)

            # check for conflicts and return randomise config if there are no issues
            random_config_choices = live_random_config_set.union(
                on_demand_random_config_set)
            random_config_choices_excluding_none = random_config_choices.intersection(
                bool_config_choices)
            assert len(
                random_config_choices_excluding_none) <= 1, error_message
            try:
                return random_config_choices_excluding_none.pop()
            except KeyError:  # no config found; default to non-random
                return False

        interview_questions = SurveyDefinition.ddb_load_interview_questions(
            survey_id)

        try:
            survey_modified = interview_questions[0]["survey_modified"]
        except IndexError:
            raise utils.ObjectDoesNotExistError(
                f"No interview questions found for survey {survey_id}",
                details={})

        block_dict = dict()
        for iq in interview_questions:
            block_id = iq["block_id"]

            try:
                block = block_dict[block_id]
            except KeyError:
                block = {
                    "block_id": block_id,
                    "block_name": iq["block_name"],
                    "questions": list(),
                }

            question = {
                "question_id": iq["question_id"],
                "question_name": iq["question_name"],
                "sequence_no": iq["sequence_no"],
                "question_text": iq["question_text"],
                "question_description": iq["question_description"],
            }

            block["questions"].append(question)
            block_dict[block_id] = block

        # order questions in each block
        randomise = randomise_questions_check()
        for block in block_dict.values():
            if randomise:
                random.shuffle(block["questions"])
            else:
                block["questions"] = sorted(
                    block["questions"], key=lambda k: int(k["sequence_no"]))

        body = {
            "survey_id": survey_id,
            "modified": survey_modified,
            "blocks": list(block_dict.values()),
            "count": len(interview_questions),
        }

        return body
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 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"]