Example #1
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,
         },
     )
Example #2
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
    )
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
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
Example #5
0
class SurveyInitialiser:
    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 query_task_response(self, survey_id):
        query_result = self.ddb_client.query(
            table_name=const.TASK_RESPONSES_TABLE["name"],
            IndexName="user-index",
            KeyConditionExpression="user_id = :user_id "
            "AND survey_id = :survey_id",
            ExpressionAttributeValues={
                ":user_id": self.user_id,
                ":survey_id": survey_id,
            },
        )
        if query_result:
            response = query_result[0]["participant_responses"]
            self.cached_responses[survey_id] = response
            return response
        else:
            self.missing_responses.append(survey_id)
            return None

    def parse_init_config(self):
        """
        Populates self.target_embedded_data with the best data available
        """
        for embedded_field_name, v_list in self.survey_init_config.details.items(
        ):
            for option_dict in v_list:
                previous_survey_id = option_dict["survey"]
                try:
                    # load already queried and cached previous survey response
                    source_response = self.cached_responses[previous_survey_id]
                except KeyError:
                    if previous_survey_id in self.missing_responses:
                        # we already know this doesn't exist in Ddb, so no point querying
                        source_response = None
                    else:
                        # we haven't tried fetching this previous response yet
                        source_response = self.query_task_response(
                            previous_survey_id)
                if source_response:
                    # we have a previous response; check it includes the question data we are looking for
                    source_data = source_response.get(option_dict["question"])
                    if source_data:
                        if isinstance(source_data, decimal.Decimal):
                            source_data = str(source_data)
                        elif isinstance(source_data, list):
                            source_data = [
                                int(x) for x in source_data
                            ]  # Qualtrics doesn't parse list of strings properly
                            source_data = json.dumps(source_data)
                        self.target_embedded_data[
                            embedded_field_name] = source_data
                        break  # no need to attempt other options; else attempt next option_dict

    def init_response(self):
        return self.responses_client.update_response(
            response_id=self.destination_response_id,
            embedded_data=self.target_embedded_data,
        )

    def main(self):
        self.parse_init_config()
        return self.init_response()

    def get_response(self):
        self.parse_init_config()
        return self.target_embedded_data
class ConsentEmailsManager(CsvImporter):
    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 get_consent_statements(self):
        if not self.consent_statements:
            with open(self.input_filename) as csvfile:
                reader = csv.DictReader(csvfile)
                question_description_row = next(reader)
                for tag in self.consent_question_tags:
                    self.consent_statements[
                        tag] = f"•  {question_description_row[tag].split(' - ', 1)[1]}"
        return self.consent_statements

    def consent_in_ddb_table(self, anon_user_task_id):
        items = self.ddb_client.query(
            table_name=const.CONSENT_DATA_TABLE,
            IndexName="user-task-index",
            KeyConditionExpression="anon_user_task_id = :anon_user_task_id",
            ExpressionAttributeValues={
                ":anon_user_task_id": anon_user_task_id,
            },
        )
        if items:
            return True
        return False

    def calculate_user_consent_statements(self, user_response_row):
        user_consent_statements = list()
        for tag in self.consent_question_tags:
            user_consent_statements.append(
                {self.consent_statements[tag]: user_response_row[tag]})
        return user_consent_statements

    def send_email(self, user_response_row):
        data = {
            "first_name":
            "",
            "consent_datetime":
            qualtrics2thiscovery_timestamp(
                user_response_row[self.consent_datetime_column_name]),
            "consent_statements":
            json.dumps(
                self.calculate_user_consent_statements(user_response_row)),
            "anon_project_specific_user_id":
            user_response_row[self.anon_project_specific_user_id_column_name],
            "anon_user_task_id":
            user_response_row[self.anon_user_task_id_column_name],
            "consent_info_url":
            self.consent_info_url,
        }
        return send_consent_email_api({"body": json.dumps(data)},
                                      context=dict())

    def parse(
        self,
        consent_statements_column_name="consent_statements",
    ):
        self.get_consent_statements()
        with open(self.input_filename) as csvfile:
            reader = csv.DictReader(csvfile)
            for row in reader:
                if not row[
                        consent_statements_column_name]:  # automated Qualtrics process failed
                    anon_user_task_id = row[self.anon_user_task_id_column_name]
                    if not self.consent_in_ddb_table(anon_user_task_id):
                        # this response row wasn't processed in a previous run of this script
                        result = self.send_email(user_response_row=row)
                        assert (result["statusCode"] == HTTPStatus.OK
                                ), f"Error sending consent email: {result}"
                        self.sent_emails.append(anon_user_task_id)
                    else:
                        self.logger.info(
                            "Skipped row; consent data detected in ddb",
                            extra={"anon_user_task_id": anon_user_task_id},
                        )
Example #7
0
class RemindersHandler:
    """
    Send a reminder:
        - One day before an appointment (appointment_datetime)
        - Unless an email (notification or reminder) was already sent today (latest_email_datetime)
    """

    def __init__(self, logger=None, correlation_id=None):
        self.ddb_client = Dynamodb(stack_name=STACK_NAME)
        self.correlation_id = correlation_id
        self.target_appointments = self.get_appointments_to_be_reminded()
        self.logger = logger
        if logger is None:
            self.logger = utils.get_logger()

    def get_appointments_to_be_reminded(self, now=None):
        if now is None:
            now = utils.now_with_tz()
        date_format = "%Y-%m-%d"
        tomorrow = now + datetime.timedelta(days=1)
        today_string = now.strftime(date_format)
        tomorrow_string = tomorrow.strftime(date_format)
        return self.ddb_client.query(
            table_name=APPOINTMENTS_TABLE,
            IndexName="reminders-index",
            KeyConditionExpression="appointment_date = :date "
            "AND latest_participant_notification "
            "BETWEEN :t1 AND :t2",
            ExpressionAttributeValues={
                ":date": tomorrow_string,
                ":t1": "2020-00-00",  # excludes 0000-00-00 appointments only because those will not have received the initial booking notification yet
                ":t2": today_string,
            },
        )

    def send_reminders(self, now=None):
        results = list()
        for appointment in self.target_appointments:
            app_item_type = appointment["type"]
            appointment_id = appointment["id"]

            if app_item_type == "acuity-appointment":
                appointment_class = AcuityAppointment
                notifier_class = AppointmentNotifier
            elif app_item_type == views.VIEWS_DDB_ITEM_TYPE:
                appointment_class = views.ViewsAppointment
                notifier_class = views.ViewsNotifier
            else:
                raise NotImplementedError(
                    f"Appointment item type {app_item_type} not supported"
                )

            appointment = appointment_class(
                appointment_id, logger=self.logger, correlation_id=self.correlation_id
            )
            appointment.ddb_load()
            notifier = notifier_class(
                appointment=appointment,
                logger=self.logger,
                correlation_id=self.correlation_id,
            )

            try:
                reminder_result = notifier.send_reminder(now=now).get("statusCode")
            except:
                self.logger.error(
                    f"{notifier_class.__name__}.send_reminder raised an exception",
                    extra={
                        "appointment": appointment.as_dict(),
                        "correlation_id": self.correlation_id,
                        "traceback": traceback.format_exc(),
                    },
                )
                reminder_result = None
            results.append((reminder_result, appointment_id))
        return results
Example #8
0
class PersonalLinkManager:
    def __init__(
        self,
        survey_id,
        anon_project_specific_user_id,
        account,
        project_task_id,
        correlation_id=None,
    ):
        self.survey_id = survey_id
        self.anon_project_specific_user_id = anon_project_specific_user_id
        self.account = account
        self.account_survey_id = f"{account}_{survey_id}"
        self.correlation_id = correlation_id
        if correlation_id is None:
            self.correlation_id = utils.new_correlation_id()

        self.ddb_client = Dynamodb(stack_name=const.STACK_NAME,
                                   correlation_id=self.correlation_id)
        self.project_task_id = project_task_id

    def _query_user_link(self) -> list:
        """
        Retrieves a personal link previously assigned to this user
        """
        return self.ddb_client.query(
            table_name=const.PersonalLinksTable.NAME,
            IndexName="assigned-links",
            KeyConditionExpression=
            "anon_project_specific_user_id = :anon_project_specific_user_id "
            "AND account_survey_id = :account_survey_id",
            ExpressionAttributeValues={
                ":anon_project_specific_user_id":
                self.anon_project_specific_user_id,
                ":account_survey_id": self.account_survey_id,
            },
        )

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

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

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

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

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

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

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

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

            user_link = self._assign_link_to_user(unassigned_links)
            return user_link
class InterviewTask(DdbBaseItem):
    """
    Represents an interview system task item in ddb table InterviewTasks
    """

    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 as_dict(self):
        return {
            k: v
            for k, v in self.__dict__.items()
            if (k[0] != "_") and (k not in ["created"])
        }

    def ddb_dump(self, update_allowed=False):
        return self._ddb_client.put_item(
            table_name=const.INTERVIEW_TASKS_TABLE["name"],
            key=str(self._project_task_id),
            item_type="interview_task",
            item_details=None,
            item=self.as_dict(),
            update_allowed=update_allowed,
            key_name=const.INTERVIEW_TASKS_TABLE["partition_key"],
            sort_key={const.INTERVIEW_TASKS_TABLE["sort_key"]: self._interview_task_id},
        )

    def ddb_load(self):
        if self.modified is None:
            if self._project_task_id:
                item = self._ddb_client.get_item(
                    table_name=const.INTERVIEW_TASKS_TABLE["name"],
                    key=str(self._project_task_id),
                    key_name=const.INTERVIEW_TASKS_TABLE["partition_key"],
                    sort_key={
                        const.INTERVIEW_TASKS_TABLE["sort_key"]: self._interview_task_id
                    },
                )
                items = [item]
            else:
                items = self._ddb_client.query(
                    table_name=const.INTERVIEW_TASKS_TABLE["name"],
                    IndexName="interview-task-id-index",
                    KeyConditionExpression="interview_task_id = :interview_task_id",
                    ExpressionAttributeValues={
                        ":interview_task_id": self._interview_task_id,
                    },
                )
                items_n = len(items)
                assert (
                    items_n <= 1
                ), f'Found {items_n} interview_tasks in {const.INTERVIEW_TASKS_TABLE["name"]} ddb table; expected 1'

            try:
                self.__dict__.update(items[0])
            except (IndexError, TypeError):
                raise utils.ObjectDoesNotExistError(
                    f"InterviewTask could not be found in Dynamodb",
                    details={
                        "project_task_id": self._project_task_id,
                        "interview_task_id": self._interview_task_id,
                        "InterviewTask": self.as_dict(),
                    },
                )