From 66e36e90365a0477f7d435a85043211ed89dba6f Mon Sep 17 00:00:00 2001 From: Nathan Moore Date: Fri, 2 Feb 2024 15:44:04 -0500 Subject: [PATCH] feat (redirect): Create contact to reduce latency (#91) * WIP * Redirect without auth * Redirect * Integration changes * Code Review comments and adding in the Rules Consent id * black * Adding some more embedded data * Adding more embedded data and properly formatting the time * Removing a dep i didnt need * formatting * Days not minutes --- .pre-commit-config.yaml | 4 +- qualtrix/api.py | 107 ++++++++++++++-- qualtrix/client.py | 270 +++++++++++++++++++++++++++++++++++++--- qualtrix/main.py | 1 + qualtrix/settings.py | 48 ++++++- requirements.txt | 2 +- 6 files changed, 403 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3998f3..2fdcb3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ --- repos: - repo: https://github.com/psf/black - rev: 22.3.0 # Update with 'pre-commit autoupdate' + rev: 24.1.1 # Update with 'pre-commit autoupdate' hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.4 + rev: 1.7.7 hooks: - id: bandit exclude: tests diff --git a/qualtrix/api.py b/qualtrix/api.py index 5f97dd9..365d5bc 100644 --- a/qualtrix/api.py +++ b/qualtrix/api.py @@ -2,7 +2,11 @@ qualtrix rest api """ +from asyncio import create_task +from datetime import datetime, timedelta import logging +import time +from zoneinfo import ZoneInfo import fastapi from fastapi import HTTPException @@ -29,7 +33,14 @@ class SessionModel(SurveyModel): class RedirectModel(SurveyModel): targetSurveyId: str - responseId: str + RulesConsentID: str # Client dependent + SurveyswapID: str # Client dependent + utm_campaign: str + utm_medium: str + utm_source: str + email: str + firstName: str + lastName: str @router.post("/bulk-responses") @@ -46,15 +57,97 @@ async def get_response(request: ResponseModel): @router.post("/redirect") -async def get_redirect(request: RedirectModel): +async def intake_redirect(request: RedirectModel): + start_time = time.time() try: - email = client.get_email(request.surveyId, request.responseId) - contact = client.get_contact(settings.DIRECTORY_ID, email) - distribution = client.get_distribution(settings.DIRECTORY_ID, contact["id"]) - return client.get_link(request.targetSurveyId, distribution["distributionId"]) + directory_entry = client.create_directory_entry( + request.email, + request.firstName, + request.lastName, + settings.DIRECTORY_ID, + settings.MAILING_LIST_ID, + ) + + email_distribution = client.create_email_distribution( + directory_entry["contactLookupId"], + settings.LIBRARY_ID, + settings.INVITE_MESSAGE_ID, + settings.MAILING_LIST_ID, + request.targetSurveyId, + ) + + link = client.get_link(request.targetSurveyId, email_distribution["id"]) + + # If link creation succeeds, create reminders while the link is returned + create_task(create_reminder_distributions(email_distribution["id"])) + create_task( + add_user_to_contact_list( + link["link"], + directory_entry["id"], + request.RulesConsentID, + request.SurveyswapID, + request.utm_campaign, + request.utm_medium, + request.utm_source, + request.firstName, + request.lastName, + # https://stackoverflow.com/questions/10997577/python-timezone-conversion + # Consumers to this data require mountain time + datetime.now(tz=ZoneInfo("MST")), + ) + ) + + log.info("Redirect link created in %.2f seconds" % (time.time() - start_time)) + return link except error.QualtricsError as e: logging.error(e) - raise HTTPException(status_code=400, detail=e.args) + # the next time any client side changes are required update this to 422 + raise HTTPException(status_code=422, detail=e.args) + + +async def create_reminder_distributions(distribution_id: str): + distribution = client.create_reminder_distribution( + settings.LIBRARY_ID, + settings.REMINDER_MESSAGE_ID, + distribution_id, + (datetime.utcnow() + timedelta(days=1)), + ) + + distribution = client.create_reminder_distribution( + settings.LIBRARY_ID, + settings.REMINDER_MESSAGE_ID, + distribution_id, + (datetime.utcnow() + timedelta(days=3)), + ) + + +async def add_user_to_contact_list( + survey_link: str, + contact_id: str, + rules_consent_id: str, + survey_swap_id: str, + utm_campaign: str, + utm_medium: str, + utm_source: str, + first_name: str, + last_name: str, + timestamp: datetime, +): + return client.add_participant_to_contact_list( + settings.DEMOGRAPHICS_SURVEY_LABEL, + survey_link, + settings.RULES_CONSENT_ID_LABEL, + client.modify_prefix("FS", "R", rules_consent_id), + settings.SURVEY_SWAP_ID_LABEL, + survey_swap_id, + contact_id, + utm_campaign, + utm_medium, + utm_source, + first_name, + last_name, + timestamp, + ) @router.post("/survey-schema") diff --git a/qualtrix/client.py b/qualtrix/client.py index 0be2df9..0d1e34a 100644 --- a/qualtrix/client.py +++ b/qualtrix/client.py @@ -4,6 +4,9 @@ import logging import requests import time +import datetime +from datetime import datetime, timedelta + from qualtrix import settings, error @@ -14,6 +17,17 @@ auth_header = {"X-API-TOKEN": settings.API_TOKEN} +class Participant: + def __init__( + self, r_id: str, f_name: str, l_name: str, email: str, lang: str + ) -> None: + self.response_id = r_id + self.first_name = f_name + self.last_name = l_name + self.email = email + self.language = lang + + class IBetaSurveyQuestion(Enum): TESTER_ID = 1 TEST_TYPE = 2 @@ -58,6 +72,226 @@ def __eq__(self, other): return self.value == other +def get_participant(survey_id: str, response_id: str): + header = copy.deepcopy(auth_header) + header["Accept"] = "application/json" + + logging.info( + f"Survey, Response -> Participant (SurveyId={survey_id}, Response={response_id})" + ) + + # ResponseId -> Email + r = requests.get( + settings.BASE_URL + f"/surveys/{survey_id}/responses/{response_id}", + headers=auth_header, + timeout=settings.TIMEOUT, + ) + + response_id_to_participant = r.json() + + if "error" in response_id_to_participant["meta"]: + raise error.QualtricsError(response_id_to_participant["meta"]["error"]) + + participant_str = response_id_to_participant["result"]["values"] + if participant_str is None: + raise error.QualtricsError( + "Participant not found, did they complete the intake survey?" + ) + + f_name = participant_str["QID37_1"] + l_name = participant_str["QID37_2"] + email = participant_str["QID37_3"] + lang = participant_str["userLanguage"] + + return Participant(response_id, f_name, l_name, email, lang) + + +def create_directory_entry( + email: str, first_name: str, last_name: str, directory_id: str, mailing_list_id: str +): + header = copy.deepcopy(auth_header) + header["Accept"] = "application/json" + + logging.info(f"Creating new directory entry for {email}") + + directory_payload = { + "firstName": first_name, + "lastName": last_name, + "email": email, + } + + # Create contact + r = requests.post( + settings.BASE_URL + + f"/directories/{directory_id}/mailinglists/{mailing_list_id}/contacts", + headers=header, + params={"includeEmbedded": "true"}, + json=directory_payload, + timeout=settings.TIMEOUT, + ) + + create_directory_entry_response = r.json() + if "error" in create_directory_entry_response["meta"]: + raise error.QualtricsError(create_directory_entry_response["meta"]["error"]) + + directory_entry = create_directory_entry_response.get("result", None) + if directory_entry is None: + raise error.QualtricsError("Something went wrong creating the contact") + + return directory_entry + + +def create_reminder_distribution( + library_id: str, + reminder_message_id: str, + distribution_id: str, + reminder_date: datetime, +): + header = copy.deepcopy(auth_header) + header["Accept"] = "application/json" + + create_reminder_distribution_payload = { + "message": {"libraryId": library_id, "messageId": reminder_message_id}, + "header": { + "fromEmail": settings.FROM_EMAIL, + "replyToEmail": settings.REPLY_TO_EMAIL, + "fromName": settings.FROM_NAME, + "subject": settings.REMINDER_SUBJECT, + }, + "embeddedData": {"property1": "string", "property2": "string"}, + "sendDate": reminder_date.isoformat() + "Z", + } + + r = requests.post( + settings.BASE_URL + f"/distributions/{distribution_id}/reminders", + headers=header, + json=create_reminder_distribution_payload, + timeout=settings.TIMEOUT, + ) + + create_reminder_distribution_response = r.json() + if "error" in create_reminder_distribution_response["meta"]: + raise error.QualtricsError( + create_reminder_distribution_response["meta"]["error"] + ) + + reminder_distribution = create_reminder_distribution_response["result"] + if reminder_distribution is None: + raise error.QualtricsError("Something went wrong creating the distribution") + + logging.info( + f"Create reminder distribution ({reminder_distribution['distributionId']}) -> {distribution_id} on {reminder_date}" + ) + + return reminder_distribution + + +def modify_prefix(current: str, desired: str, content: str) -> str: + return content.replace(f"{current}_", f"{desired}_", 1) + + +def add_participant_to_contact_list( + survey_label: str, + survey_link: str, + rules_consent_id_label, + rules_consent_id: str, + survey_swap_id_label: str, + survey_swap_id, + contact_id: str, + utm_campaign: str, + utm_medium: str, + utm_source: str, + first_name: str, + last_name: str, + timestamp: datetime, +): + header = copy.deepcopy(auth_header) + header["Accept"] = "application/json" + + add_particpant_payload = { + "embeddedData": { + survey_label: survey_link, + rules_consent_id_label: rules_consent_id, + survey_swap_id_label: survey_swap_id, + "utm_campaign": utm_campaign, + "utm_medium": utm_medium, + "utm_source": utm_source, + "firstName": first_name, + "lastName": last_name, + "Date": timestamp.strftime("%m/%d/%Y"), + "time": timestamp.strftime("%H:%M:%S"), + } + } + + logging.info( + f"Contact ({contact_id}) -> Directory ({settings.DIRECTORY_ID}), Mailing List ({settings.MAILING_LIST_ID}), Rules Consent ({rules_consent_id})" + ) + + r = requests.put( + settings.BASE_URL + + f"/directories/{settings.DIRECTORY_ID}/mailinglists/{settings.MAILING_LIST_ID}/contacts/{contact_id}", + headers=header, + json=add_particpant_payload, + timeout=settings.TIMEOUT, + ) + + add_to_contact_list_response = r.json() + if "error" in add_to_contact_list_response["meta"]: + raise error.QualtricsError(add_to_contact_list_response["meta"]["error"]) + + return contact_id + + +def create_email_distribution( + contact_id: str, + library_id: str, + message_id: str, + mailing_list_id: str, + survey_id: str, +): + header = copy.deepcopy(auth_header) + header["Accept"] = "application/json" + + logging.info(f"Create email distribution ") + + calltime = datetime.utcnow() + create_distribution_payload = { + "message": {"libraryId": library_id, "messageId": message_id}, + "recipients": {"mailingListId": mailing_list_id, "contactId": contact_id}, + "header": { + "fromEmail": settings.FROM_EMAIL, + "replyToEmail": settings.REPLY_TO_EMAIL, + "fromName": settings.FROM_NAME, + "subject": settings.INVITE_SUBJECT, + }, + "surveyLink": { + "surveyId": survey_id, + "expirationDate": (calltime + timedelta(minutes=5)).isoformat() + + "Z", # 1 month + "type": "Individual", + }, + "embeddedData": {"": ""}, # for some reason this is required + "sendDate": (calltime + timedelta(seconds=10)).isoformat() + "Z", + } + + r = requests.post( + settings.BASE_URL + f"/distributions", + headers=header, + json=create_distribution_payload, + timeout=settings.TIMEOUT, + ) + + create_distribution_response = r.json() + if "error" in create_distribution_response["meta"]: + raise error.QualtricsError(create_distribution_response["meta"]["error"]) + + email_distribution = create_distribution_response["result"] + if email_distribution is None: + raise error.QualtricsError("Something went wrong creating the distribution") + + return email_distribution + + def get_email(survey_id: str, response_id: str): header = copy.deepcopy(auth_header) header["Accept"] = "application/json" @@ -430,26 +664,26 @@ def get_answer_from_result(result): } if device_type_choice == 1: # Iphone or Ipad - device_response[ - "device_model" - ] = IBetaSurveyQuestion.DEVICE_MODEL_APPLE.QID_label(labels) - device_response[ - "device_details" - ] = IBetaSurveyQuestion.DEVICE_MODEL_APPLE.QID_text(values) + device_response["device_model"] = ( + IBetaSurveyQuestion.DEVICE_MODEL_APPLE.QID_label(labels) + ) + device_response["device_details"] = ( + IBetaSurveyQuestion.DEVICE_MODEL_APPLE.QID_text(values) + ) elif device_type_choice == 2: # Samsung Galaxy Phone or Tablet - device_response[ - "device_model" - ] = IBetaSurveyQuestion.DEVICE_MODEL_SAMSUNG.QID_label(labels) - device_response[ - "device_details" - ] = IBetaSurveyQuestion.DEVICE_MODEL_SAMSUNG.QID_text(values) + device_response["device_model"] = ( + IBetaSurveyQuestion.DEVICE_MODEL_SAMSUNG.QID_label(labels) + ) + device_response["device_details"] = ( + IBetaSurveyQuestion.DEVICE_MODEL_SAMSUNG.QID_text(values) + ) elif device_type_choice == 3: # Google Phone or Tablet - device_response[ - "device_model" - ] = IBetaSurveyQuestion.DEVICE_MODEL_GOOGLE.QID_label(labels) - device_response[ - "device_details" - ] = IBetaSurveyQuestion.DEVICE_MODEL_GOOGLE.QID_text(values) + device_response["device_model"] = ( + IBetaSurveyQuestion.DEVICE_MODEL_GOOGLE.QID_label(labels) + ) + device_response["device_details"] = ( + IBetaSurveyQuestion.DEVICE_MODEL_GOOGLE.QID_text(values) + ) return { "tester_id": IBetaSurveyQuestion.TESTER_ID.QID_label(labels), diff --git a/qualtrix/main.py b/qualtrix/main.py index b412072..441668f 100644 --- a/qualtrix/main.py +++ b/qualtrix/main.py @@ -1,6 +1,7 @@ """ Qualtrix Microservice FastAPI Web App. """ + import logging import fastapi diff --git a/qualtrix/settings.py b/qualtrix/settings.py index a226869..889781e 100644 --- a/qualtrix/settings.py +++ b/qualtrix/settings.py @@ -2,6 +2,7 @@ Configuration for the qualtrix microservice settings. Context is switched based on if the app is in debug mode. """ + import json import logging import os @@ -15,9 +16,28 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", logging.getLevelName(logging.INFO)) +# Qualtrics API Access API_TOKEN = None BASE_URL = None + +# Qualtrics API Control DIRECTORY_ID = None +LIBRARY_ID = None +REMINDER_MESSAGE_ID = None +INVITE_MESSAGE_ID = None +MAILING_LIST_ID = None + +# Distribution Content Config +FROM_EMAIL = None +REPLY_TO_EMAIL = None +FROM_NAME = None + +INVITE_SUBJECT = None +REMINDER_SUBJECT = None +SURVEY_LINK_TYPE = None +DEMOGRAPHICS_SURVEY_LABEL = None +RULES_CONSENT_ID_LABEL = None +SURVEY_SWAP_ID_LABEL = None try: vcap_services = os.getenv("VCAP_SERVICES") @@ -32,11 +52,37 @@ API_TOKEN = config["api_token"] BASE_URL = config["base_url"] DIRECTORY_ID = config["directory_id"] + LIBRARY_ID = config["library_id"] + REMINDER_MESSAGE_ID = config["reminder_message_id"] + INVITE_MESSAGE_ID = config["invite_message_id"] + MAILING_LIST_ID = config["mailing_list_id"] + FROM_EMAIL = config["from_email"] + REPLY_TO_EMAIL = config["reply_to_email"] + FROM_NAME = config["from_name"] + INVITE_SUBJECT = config["invite_subject"] + REMINDER_SUBJECT = config["reminder_subject"] + SURVEY_LINK_TYPE = config["survey_link_type"] + DEMOGRAPHICS_SURVEY_LABEL = config["demographics_survey_label"] + RULES_CONSENT_ID_LABEL = config["rules_consent_id_label"] + SURVEY_SWAP_ID_LABEL = config["survey_swap_id_label"] + else: API_TOKEN = os.getenv("QUALTRIX_API_TOKEN") BASE_URL = os.getenv("QUALTRIX_BASE_URL") DIRECTORY_ID = os.getenv("QUALTRIX_DIRECTORY_ID") - + LIBRARY_ID = os.getenv("QUALTRIX_LIBRARY_ID") + REMINDER_MESSAGE_ID = os.getenv("QUALTRIX_REMINDER_MESSAGE_ID") + INVITE_MESSAGE_ID = os.getenv("QUALTRIX_INVITE_MESSAGE_ID") + MAILING_LIST_ID = os.getenv("QUALTRIX_MAILING_LIST_ID") + FROM_EMAIL = os.getenv("QUALTRIX_FROM_EMAIL") + REPLY_TO_EMAIL = os.getenv("QUALTRIX_REPLY_TO_EMAIL") + FROM_NAME = os.getenv("QUALTRIX_FROM_NAME") + INVITE_SUBJECT = os.getenv("QUALTRIX_INVITE_SUBJECT") + REMINDER_SUBJECT = os.getenv("QUALTRIX_REMINDER_SUBJECT") + SURVEY_LINK_TYPE = os.getenv("QUALTRIX_SURVEY_LINK_TYPE") + DEMOGRAPHICS_SURVEY_LABEL = os.getenv("QUALTRIX_DEMOGRAPHICS_SURVEY_LABEL") + RULES_CONSENT_ID_LABEL = os.getenv("QUALTRIX_RULES_CONSENT_ID_LABEL") + SURVEY_SWAP_ID_LABEL = os.getenv("QUALTRIX_SURVEY_SWAP_ID_LABEL") except (json.JSONDecodeError, KeyError, FileNotFoundError) as err: log.warning("Unable to load credentials from VCAP_SERVICES") diff --git a/requirements.txt b/requirements.txt index 41a5c47..c0abcde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ uvicorn==0.27.0 starlette-prometheus==0.9.0 google-api-python-client==2.115.0 google-auth-httplib2==0.2.0 -google-auth-oauthlib==1.2.0 +google-auth-oauthlib==1.2.0 \ No newline at end of file