diff --git a/microsetta_private_api/admin/admin_impl.py b/microsetta_private_api/admin/admin_impl.py index d4c889cbc..6839af990 100644 --- a/microsetta_private_api/admin/admin_impl.py +++ b/microsetta_private_api/admin/admin_impl.py @@ -542,6 +542,21 @@ def create_daklapack_orders(body, token_info): return response +# We need an internal wrapper to create orders based on contributions coming +# from Fundrazr without authenticating an admin user. +# Do NOT expose an API endpoint for this. +def create_daklapack_order_internal(order_dict): + # Since we've established the consent dummy as a stable account across + # dev, staging, and production, we'll continue to use that internally + with Transaction() as t: + account_repo = AccountRepo(t) + order_dict[SUBMITTER_ACCT_KEY] = account_repo.get_account( + SERVER_CONFIG['fulfillment_account_id']) + + result = _create_daklapack_order(order_dict) + return result + + def _create_daklapack_order(order_dict): order_dict[ORDER_ID_KEY] = str(uuid.uuid4()) diff --git a/microsetta_private_api/admin/email_templates.py b/microsetta_private_api/admin/email_templates.py index 442121bdc..a6846b82e 100644 --- a/microsetta_private_api/admin/email_templates.py +++ b/microsetta_private_api/admin/email_templates.py @@ -79,6 +79,38 @@ class EmailMessage(Enum): EventType.EMAIL, EventSubtype.EMAIL_SUBMIT_INTEREST_CONFIRMATION ) + thank_you_with_kit = ( + gettext( + "Registration code & kit update!"), + "email/thank_you_with_kit.jinja2", + ("first_name", "registration_code", "interface_endpoint"), + EventType.EMAIL, + EventSubtype.EMAIL_THANK_YOU_WITH_KIT + ) + thank_you_no_kit = ( + gettext( + "Your questionnaire is ready!"), + "email/thank_you_no_kit.jinja2", + ("first_name", "registration_code", "interface_endpoint"), + EventType.EMAIL, + EventSubtype.EMAIL_THANK_YOU_NO_KIT + ) + kit_tracking_number = ( + gettext( + "Your kit is on its way!"), + "email/kit_tracking_number.jinja2", + ("first_name", "tracking_number"), + EventType.EMAIL, + EventSubtype.EMAIL_KIT_TRACKING_NUMBER + ) + subscription_ffq_code = ( + gettext( + "Registration code & kit update!"), + "email/subscription_ffq_code.jinja2", + ("first_name", "tracking_number", "interface_endpoint"), + EventType.EMAIL, + EventSubtype.EMAIL_SUBSCRIPTION_FFQ_CODE + ) def __init__(self, subject, html, required, event_type, event_sub): self.subject = subject diff --git a/microsetta_private_api/api/_account.py b/microsetta_private_api/api/_account.py index 2e8ab7a41..7c43a03b0 100644 --- a/microsetta_private_api/api/_account.py +++ b/microsetta_private_api/api/_account.py @@ -14,6 +14,8 @@ from microsetta_private_api.repo.account_repo import AccountRepo from microsetta_private_api.repo.transaction import Transaction from microsetta_private_api.config_manager import SERVER_CONFIG +from microsetta_private_api.repo.perk_fulfillment_repo import\ + PerkFulfillmentRepo def find_accounts_for_login(token_info): @@ -66,6 +68,16 @@ def register_account(body, token_info): acct_repo = AccountRepo(t) acct_repo.create_account(account_obj) new_acct = acct_repo.get_account(new_acct_id) + + # Check for unclaimed subscriptions attached to the email address + pfr = PerkFulfillmentRepo(t) + subscription_ids = pfr.get_unclaimed_subscriptions_by_email( + new_acct.email + ) + # If we find any, claim them + for sub_id in subscription_ids: + pfr.claim_unclaimed_subscription(sub_id, new_acct_id) + t.commit() response = jsonify(new_acct.to_api()) diff --git a/microsetta_private_api/celery_utils.py b/microsetta_private_api/celery_utils.py index 1cf2c8d3a..79c322de5 100644 --- a/microsetta_private_api/celery_utils.py +++ b/microsetta_private_api/celery_utils.py @@ -35,12 +35,32 @@ def __call__(self, *args, **kwargs): }, "poll_daklapack_orders": { "task": "microsetta_private_api.admin.daklapack_polling.poll_dak_orders", # noqa - "schedule": 60 * 60 * 24 # every 24 hours + "schedule": 60 * 60 * 4 # every 4 hours }, "update_qiita_metadata": { "task": "microsetta_private_api.tasks.update_qiita_metadata", # noqa "schedule": 60 * 60 * 24 # every 24 hours }, + "pull_fundrazr_transactions": { + "task": "microsetta_private_api.util.fundrazr.get_fundrazr_transactions", # noqa + "schedule": 60 * 60 # every hour + }, + "fulfill_new_transactions": { + "task": "microsetta_private_api.util.perk_fulfillment.fulfill_new_transactions", # noqa + "schedule": 60 * 60 # every hour + }, + "fulfill_subscriptions": { + "task": "microsetta_private_api.util.perk_fulfillment.process_subscription_fulfillments", # noqa + "schedule": 60 * 60 * 24 # every 24 hours + }, + "check_shipping_updates": { + "task": "microsetta_private_api.util.perk_fulfillment.check_shipping_updates", # noqa + "schedule": 60 * 60 * 4 # every 4 hours + }, + "perks_without_fulfillment_details": { + "task": "microsetta_private_api.util.perk_fulfillment.perks_without_fulfillment_details", # noqa + "schedule": 60 * 60 * 24 # every 24 hours + }, # "fetch_ffqs": { # "task": "microsetta_private_api.util.vioscreen.fetch_ffqs", # "schedule": 60 * 60 * 24 # every 24 hours diff --git a/microsetta_private_api/celery_worker.py b/microsetta_private_api/celery_worker.py index f81614785..d98485ebe 100644 --- a/microsetta_private_api/celery_worker.py +++ b/microsetta_private_api/celery_worker.py @@ -2,10 +2,17 @@ from microsetta_private_api.celery_utils import celery, init_celery from microsetta_private_api.util.vioscreen import refresh_headers from microsetta_private_api.admin.daklapack_polling import poll_dak_orders -from microsetta_private_api.tasks import update_qiita_metadata +# from microsetta_private_api.util.fundrazr import get_fundrazr_transactions +from microsetta_private_api.util.perk_fulfillment import check_shipping_updates +# from microsetta_private_api.tasks import update_qiita_metadata init_celery(celery, app.app) # Run any celery tasks that require initialization on worker start refresh_headers.delay() # Initialize the vioscreen task with a token poll_dak_orders.delay() # check for orders -update_qiita_metadata.delay() # run Qiita metadata push +# get_fundrazr_transactions.delay() # check for new transactions +check_shipping_updates.delay() # check for tracking updates +# update_qiita_metadata.delay() # run Qiita metadata push + +# Disabling Qiita metadata push until we have survey changes in place and +# are ready to test. - Cassidy 2022-12-01 diff --git a/microsetta_private_api/client/tests/test_fundrazr.py b/microsetta_private_api/client/tests/test_fundrazr.py index eb2f079fa..0a5b5d333 100644 --- a/microsetta_private_api/client/tests/test_fundrazr.py +++ b/microsetta_private_api/client/tests/test_fundrazr.py @@ -40,8 +40,8 @@ def test_payments(self): def test_campaigns(self): obs = self.c.campaigns() - # staging has two campaigns, let's assume that's stable... - self.assertEqual(len(obs), 2) + # staging has three campaigns, let's assume that's stable... + self.assertEqual(len(obs), 3) self.assertTrue(len(obs[0].items) > 0) @skipIf(SERVER_CONFIG['fundrazr_url'] in ('', 'fundrazr_url_placeholder'), diff --git a/microsetta_private_api/db/patches/0110.sql b/microsetta_private_api/db/patches/0110.sql new file mode 100644 index 000000000..28e71061c --- /dev/null +++ b/microsetta_private_api/db/patches/0110.sql @@ -0,0 +1,106 @@ +-- Add a flag to the campaign.fundrazr_transaction_perk table to reflect whether it has been processed +ALTER TABLE campaign.fundrazr_transaction_perk ADD COLUMN processed BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add a flag to the campaign.fundrazr_daklapack_orders table to reflect whether we've sent out a tracking number +ALTER TABLE campaign.fundrazr_daklapack_orders ADD COLUMN tracking_sent BOOLEAN NOT NULL DEFAULT FALSE; + +-- The campaign.fundrazr_perk_to_daklapack_article table hasn't been used yet, so it's safe to drop. +DROP TABLE campaign.fundrazr_perk_to_daklapack_article; + +-- Since our model has changed to include perks that are FFQ-only and subscriptions, +-- we're going to retool the table to better reflect how perks function. +-- For subscriptions, we'll utilize the fulfillment_spacing_* columns to control scheduling. +-- E.g., fulfillment_spacing_number = 3 and fulfillment_spacing_unit = 'months' will schedule quarterly orders. +-- If we decide to offer perks with multiple kits shipped at once in the future, fulfillment_spacing_number = 0 can be used to reflect this behavior. +CREATE TYPE FULFILLMENT_SPACING_UNIT AS ENUM ('days', 'months'); +CREATE TABLE campaign.fundrazr_perk_fulfillment_details ( + perk_id VARCHAR NOT NULL PRIMARY KEY, + ffq_quantity INTEGER NOT NULL, + kit_quantity INTEGER NOT NULL, + dak_article_code VARCHAR, -- Must be nullable, as not all perks include a kit + fulfillment_spacing_number INTEGER NOT NULL, + fulfillment_spacing_unit FULFILLMENT_SPACING_UNIT, + CONSTRAINT fk_perk_to_dak FOREIGN KEY (dak_article_code) REFERENCES barcodes.daklapack_article (dak_article_code) +); + +INSERT INTO campaign.transaction_source_to_campaign + SELECT '57xV2' AS remote_campaign_id, campaign_id AS internal_campaign_id, 'usd' AS currency + FROM campaign.campaigns + WHERE title='The Microsetta Initiative'; + +-- The API will pull down perks automatically, but we need it to exist so we can add the fullfilment info, +-- so we're just going to insert them here + +-- Perk values for Fundrazr's production environment +INSERT INTO campaign.fundrazr_perk + (id, remote_campaign_id, title, price) + VALUES ('3QeVd', '4Tqx5', 'Analyze Your Nutrition', 20), + ('3QeW6', '4Tqx5', 'Explore Your Microbiome', 180), + ('0QeXa', '4Tqx5', 'Follow Your Gut', 720); + +-- Perk values for Fundrazr's staging environment +INSERT INTO campaign.fundrazr_perk + (id, remote_campaign_id, title, price) + VALUES ('13lja', '57xV2', 'Analyze Your Nutrition', 20), + ('93lk8', '57xV2', 'Explore Your Microbiome', 180), + ('13ll7', '57xV2', 'Follow Your Gut', 720); + +-- Insert the fulfillment info for the perks we're offering +-- Production perks +INSERT INTO campaign.fundrazr_perk_fulfillment_details + (perk_id, ffq_quantity, kit_quantity, dak_article_code, fulfillment_spacing_number, fulfillment_spacing_unit) + VALUES ('3QeVd', 1, 0, NULL, 0, NULL), + ('3QeW6', 1, 1, '3510005E', 0, NULL), + ('0QeXa', 4, 4, '3510005E', 3, 'months'); +-- Staging perks +INSERT INTO campaign.fundrazr_perk_fulfillment_details + (perk_id, ffq_quantity, kit_quantity, dak_article_code, fulfillment_spacing_number, fulfillment_spacing_unit) + VALUES ('13lja', 1, 0, NULL, 0, NULL), + ('93lk8', 1, 1, '3510005E', 0, NULL), + ('13ll7', 4, 4, '3510005E', 3, 'months'); + +-- Both the subscriptions and subscriptions_fulfillment tables will have cancelled flags to create +-- an audit trail in the event someone contacts us to cancel scheduled shipments. +-- We're storing the flag at both levels so we can track what portion of a subscription was actually sent. +-- We say "No refunds" but it would be good to have the granular data on what people received, just in case. +CREATE TABLE campaign.subscriptions ( + subscription_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID, -- Must be nullable in case someone contributes to receive a subscription before creating their account + transaction_id VARCHAR NOT NULL, + fundrazr_transaction_perk_id UUID NOT NULL, + cancelled BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_account_id FOREIGN KEY (account_id) REFERENCES ag.account (id), + CONSTRAINT fk_transaction_id FOREIGN KEY (transaction_id) REFERENCES campaign.transaction (id), + CONSTRAINT fk_ftp_id FOREIGN KEY (fundrazr_transaction_perk_id) REFERENCES campaign.fundrazr_transaction_perk (id) +); + +-- Participants can alter shipment dates, but only once per shipment. +-- The fulfillment_date_changed column will manage this. +CREATE TYPE FULFILLMENT_TYPE AS ENUM ('ffq', 'kit'); +CREATE TABLE campaign.subscriptions_fulfillment ( + fulfillment_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + subscription_id UUID NOT NULL, + fulfillment_type FULFILLMENT_TYPE NOT NULL, + dak_article_code VARCHAR, -- Must be nullable, as not all perks include a kit + fulfillment_date DATE, + fulfillment_date_changed BOOLEAN NOT NULL DEFAULT FALSE, + fulfilled BOOLEAN NOT NULL DEFAULT FALSE, + cancelled BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT fk_subscription_id FOREIGN KEY (subscription_id) REFERENCES campaign.subscriptions (subscription_id), + CONSTRAINT fk_dak_article_code FOREIGN KEY (dak_article_code) REFERENCES barcodes.daklapack_article (dak_article_code) +); + +-- FFQs will have a registration code (similar to the old activation code) moving forward, +-- although it will not be tied to an email address. +CREATE TABLE campaign.ffq_registration_codes ( + ffq_registration_code VARCHAR PRIMARY KEY, + registration_code_used TIMESTAMP -- Nullable as null = unused code +); + +-- Create a record of the fulfillment of FFQ codes relative to a transaction/perk combination. +CREATE TABLE campaign.fundrazr_ffq_codes ( + fundrazr_transaction_perk_id UUID NOT NULL, + ffq_registration_code VARCHAR NOT NULL, + CONSTRAINT fk_ftp_id FOREIGN KEY (fundrazr_transaction_perk_id) REFERENCES campaign.fundrazr_transaction_perk (id), + CONSTRAINT fk_ffq_code FOREIGN KEY (ffq_registration_code) REFERENCES campaign.ffq_registration_codes (ffq_registration_code) +); diff --git a/microsetta_private_api/model/log_event.py b/microsetta_private_api/model/log_event.py index 98b09c855..66a4706f1 100644 --- a/microsetta_private_api/model/log_event.py +++ b/microsetta_private_api/model/log_event.py @@ -48,6 +48,14 @@ class EventSubtype(Enum): EMAIL_ADDRESS_INVALID = "address_invalid" # for confirmation emails of interested user signups EMAIL_SUBMIT_INTEREST_CONFIRMATION = "submit_interest_confirmation" + # Thank you for Fundrazr contribution - kit or subscription + EMAIL_THANK_YOU_WITH_KIT = "thank_you_with_kit" + # Thank you for Fundrazr contribution - FFQ only + EMAIL_THANK_YOU_NO_KIT = "thank_you_no_kit" + # Send tracking number for newly shipped kit + EMAIL_KIT_TRACKING_NUMBER = "kit_tracking_number" + # When next subscription fulfillment occurs, send FFQ code + EMAIL_SUBSCRIPTION_FFQ_CODE = "subscription_ffq_code" class LogEvent(ModelBase): diff --git a/microsetta_private_api/model/subscription.py b/microsetta_private_api/model/subscription.py new file mode 100644 index 000000000..70494f9a8 --- /dev/null +++ b/microsetta_private_api/model/subscription.py @@ -0,0 +1,21 @@ +from microsetta_private_api.model.model_base import ModelBase + + +class Subscription(ModelBase): + def __init__(self, **kwargs): + # subscription_id won't exist yet on new subscriptions + self.subscription_id = kwargs.get('subscription_id') + + # absolute minimum fields required for a subscription + self.transaction_id = kwargs['transaction_id'] + + # remaining fields are either optional or auto-created later + self.account_id = kwargs.get('account_id') + self.cancelled = kwargs.get('cancelled', False) + + def to_api(self): + return self.__dict__.copy() + + @classmethod + def from_dict(cls, values_dict): + return cls(**values_dict) diff --git a/microsetta_private_api/repo/campaign_repo.py b/microsetta_private_api/repo/campaign_repo.py index d28e268d4..2845d76e2 100644 --- a/microsetta_private_api/repo/campaign_repo.py +++ b/microsetta_private_api/repo/campaign_repo.py @@ -1,5 +1,6 @@ import psycopg2 import json +import datetime from microsetta_private_api.client.fundrazr import FundrazrClient from microsetta_private_api.repo.base_repo import BaseRepo @@ -450,36 +451,43 @@ def add_transaction(self, payment): interested_user_id = self._add_interested_user(payment) - # begin address verification - i_u_repo = InterestedUserRepo(self._transaction) - try: - valid_address = i_u_repo.verify_address(interested_user_id) - except RepoException: - # we shouldn't reach this point, but address wasn't verified - valid_address = False - - # we specifically care if valid_address is False, as verify_address - # will return None if the user doesn't have a shipping address - # in this case, that implies a perk that doesn't require shipping - if valid_address is False: - cn = payment.payer_first_name + " " + payment.payer_last_name - - # casting str to avoid concatenation error - resolution_url = SERVER_CONFIG["interface_endpoint"] + \ - "/update_address?uid=" + str(interested_user_id) + \ - "&email=" + payment.contact_email + # There's no reason to verify addresses and send emails for old + # transactions, so we're only going to verify post-relaunch ones. + # We're also not going to verify the address if they didn't claim + # any perks. + add_ver_cutoff = datetime.datetime(2022, 11, 15) + if payment.created.timestamp() >= add_ver_cutoff.timestamp() and\ + payment.claimed_items is not None: + # begin address verification + i_u_repo = InterestedUserRepo(self._transaction) try: - # TODO - will need to add actual language flag to the email - # Fundrazr doesn't provide a language flag, defer for now - send_email(payment.contact_email, - "address_invalid", - {"contact_name": cn, - "resolution_url": resolution_url}, - EN_US) - except: # noqa - # try our best to email - pass - # end address verification + valid_address = i_u_repo.verify_address(interested_user_id) + except RepoException: + # we shouldn't reach this point, but address wasn't verified + valid_address = False + + # we specifically care if valid_address is False, as verify_address + # will return None if the user doesn't have a shipping address + # in this case, that implies a perk that doesn't require shipping + if valid_address is False: + cn = payment.payer_first_name + " " + payment.payer_last_name + + # casting str to avoid concatenation error + resolution_url = SERVER_CONFIG["interface_endpoint"] + \ + "/update_address?uid=" + str(interested_user_id) + \ + "&email=" + payment.contact_email + try: + # TODO - will need to add actual language flag to the email + # Fundrazr doesn't provide a language flag, defer for now + send_email(payment.contact_email, + "address_invalid", + {"contact_name": cn, + "resolution_url": resolution_url}, + EN_US) + except: # noqa + # try our best to email + pass + # end address verification if payment.TRANSACTION_TYPE == self.TRN_TYPE_FUNDRAZR: return self._add_transaction_fundrazr(payment, interested_user_id) @@ -539,7 +547,7 @@ def _add_interested_user(self, payment): with self._transaction.cursor() as cur: cur.execute(*sql) - interested_user_id = cur.fetchone() + interested_user_id = cur.fetchone()[0] return interested_user_id @@ -584,14 +592,15 @@ def _add_transaction_fundrazr(self, payment, interested_user_id): data) if items is not None: - inserts = [(payment.transaction_id, i.id, i.quantity) + inserts = [(payment.transaction_id, i.id, i.quantity, False) for i in items] try: cur.executemany("""INSERT INTO campaign.fundrazr_transaction_perk - (transaction_id, perk_id, quantity) - VALUES (%s, %s, %s)""", + (transaction_id, perk_id, quantity, + processed) + VALUES (%s, %s, %s, %s)""", inserts) except psycopg2.errors.ForeignKeyViolation: # this would indicate a synchronization issue, where diff --git a/microsetta_private_api/repo/perk_fulfillment_repo.py b/microsetta_private_api/repo/perk_fulfillment_repo.py new file mode 100644 index 000000000..b52d705c6 --- /dev/null +++ b/microsetta_private_api/repo/perk_fulfillment_repo.py @@ -0,0 +1,778 @@ +import pycountry +import uuid +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from microsetta_private_api.repo.base_repo import BaseRepo +from microsetta_private_api.exceptions import RepoException +from microsetta_private_api.model.subscription import Subscription +from microsetta_private_api.admin.admin_impl import\ + create_daklapack_order_internal +from microsetta_private_api.model.daklapack_order import FEDEX_PROVIDER,\ + FEDEX_2DAY_SHIPPING +from microsetta_private_api.model.activation_code import ActivationCode +from microsetta_private_api.tasks import send_email +from microsetta_private_api.localization import EN_US +from microsetta_private_api.config_manager import SERVER_CONFIG +from microsetta_private_api.model.log_event import LogEvent +from microsetta_private_api.repo.event_log_repo import EventLogRepo +from microsetta_private_api.admin.email_templates import EmailMessage + + +class PerkFulfillmentRepo(BaseRepo): + def __init__(self, transaction): + super().__init__(transaction) + + @staticmethod + def _row_to_subscription(r): + return Subscription.from_dict(r) + + def find_perks_without_fulfillment_details(self): + """If a perk isn't represented in our system at all, the transaction + won't be imported. However, we need to have fulfillment details to + act on a perk being selected. This finds any perks for which we + haven't established fulfillment details and returns content for an + alert email. + """ + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT transaction_id, perk_id " + "FROM campaign.fundrazr_transaction_perk " + "WHERE perk_id NOT IN (" + " SELECT DISTINCT(perk_id) " + " FROM campaign.fundrazr_perk_fulfillment_details" + ")" + ) + rows = cur.fetchall() + if rows is None: + return None + else: + return [dict(r) for r in rows] + + def get_pending_fulfillments(self): + with self._transaction.dict_cursor() as cur: + """Find all purchased perks that have yet to be processed """ + cur.execute( + "SELECT ftp.id " + "FROM campaign.fundrazr_transaction_perk ftp " + "INNER JOIN campaign.transaction ft " + "ON ftp.transaction_id = ft.id " + "INNER JOIN campaign.interested_users iu " + "ON ft.interested_user_id = iu.interested_user_id " + "AND iu.address_valid = TRUE " + "WHERE ftp.processed = FALSE" + ) + rows = cur.fetchall() + return [r['id'] for r in rows] + + def process_pending_fulfillment(self, ftp_id): + """Process a single pending fulfillment""" + error_report = [] + + with self._transaction.dict_cursor() as cur: + # Lock the table to ensure that two concurrent operations + # can't fulfill the same row + self._transaction.lock_table("fundrazr_transaction_perk") + + # Once the table is locked, verify that the row has not + # been processed and collect the necessary fields + cur.execute( + "SELECT ftp.id ftp_id, ftp.transaction_id, ftp.perk_id, " + "ftp.quantity, ft.payer_email, fpfd.ffq_quantity, " + "fpfd.kit_quantity, fpfd.dak_article_code, " + "fpfd.fulfillment_spacing_number, " + "fpfd.fulfillment_spacing_unit, iu.first_name, iu.last_name, " + "iu.phone, iu.address_1, iu.address_2, iu.city, iu.state, " + "iu.postal_code, iu.country, iu.campaign_id " + "FROM campaign.fundrazr_transaction_perk ftp " + "INNER JOIN campaign.transaction ft " + "ON ftp.transaction_id = ft.id " + "INNER JOIN campaign.fundrazr_perk_fulfillment_details fpfd " + "ON ftp.perk_id = fpfd.perk_id " + "INNER JOIN campaign.interested_users iu " + "ON ft.interested_user_id = iu.interested_user_id " + "AND iu.address_valid = true " + "WHERE ftp.processed = FALSE AND ftp.id = %s", + (ftp_id, ) + ) + row = cur.fetchone() + if row is not None: + if self._is_subscription(row): + subscription_id = \ + self._create_subscription(row['payer_email'], + row['transaction_id'], + row['ftp_id']) + else: + subscription_id = None + + # If there are any FFQs attached to the perk, immediately + # fulfill the first one + if row['ffq_quantity'] > 0: + # If the perk is a kit or subscription, send thank you + # email with kit content. Otherwise, send thank you + # for FFQ only + if row['kit_quantity'] > 0: + template = "thank_you_with_kit" + else: + template = "thank_you_no_kit" + + error_info = self._fulfill_ffq( + row['ftp_id'], + template, + row['payer_email'], + row['first_name'], + subscription_id + ) + if error_info is not None: + error_report.append( + f"Error sending FFQ email for ftp_id " + f"{row['ftp_id']}: {error_info}" + ) + + # Then, if there are more FFQs, schedule/fulfill them as + # appropriate based on fulfillment_spacing_number + for x in range(1, row['ffq_quantity']): + if row['fulfillment_spacing_number'] > 0: + fulfillment_date =\ + self._future_fulfillment_date( + row['fulfillment_spacing_number'], + row['fulfillment_spacing_unit'], + x + ) + self._schedule_ffq( + subscription_id, + fulfillment_date, + False + ) + else: + error_info = self._fulfill_ffq( + row['ftp_id'], + row['kit_quantity'], + row['payer_email'], + row['first_name'] + ) + if error_info is not None: + error_report.append( + f"Error sending FFQ email for ftp_id " + f"{row['ftp_id']}: {error_info}" + ) + + # If there are any kits attached to the perk, immediately + # fulfill the first one + if row['kit_quantity'] > 0: + status, return_val = self._fulfill_kit( + row, + 1, + subscription_id + ) + if not status: + # Daklapack order failed, let the error percolate + error_report.append( + f"Error placing Daklapack order for ftp_id " + f"{row['ftp_id']}: {return_val}" + ) + + for x in range(1, row['kit_quantity']): + if row['fulfillment_spacing_number'] > 0: + fulfillment_date =\ + self._future_fulfillment_date( + row['fulfillment_spacing_number'], + row['fulfillment_spacing_unit'], + x + ) + self._schedule_kit(subscription_id, + fulfillment_date, + row['dak_article_code'], + False) + else: + status, return_val = self._fulfill_kit( + row, + 1, + subscription_id + ) + if not status: + # Daklapack order failed, let the error percolate + error_report.append( + f"Error placing Daklapack order for ftp_id " + f"{row['ftp_id']}: {return_val}" + ) + + cur.execute( + "UPDATE campaign.fundrazr_transaction_perk " + "SET processed = true " + "WHERE id = %s", + (row['ftp_id'], ) + ) + + return error_report + + def get_subscription_fulfillments(self): + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT fulfillment_id " + "FROM campaign.subscriptions_fulfillment " + "WHERE fulfilled = FALSE AND cancelled = FALSE " + "AND fulfillment_date <= CURRENT_DATE" + ) + rows = cur.fetchall() + return [r['fulfillment_id'] for r in rows] + + def process_subscription_fulfillment(self, fulfillment_id): + error_report = [] + + with self._transaction.dict_cursor() as cur: + # Lock the table to ensure that two concurrent operations + # can't fulfill the same row + self._transaction.lock_table("subscriptions_fulfillment") + + # Once the table is locked, verify that the row has not + # been processed and collect the necessary fields + cur.execute( + "SELECT sf.fulfillment_id, sf.fulfillment_type, " + "sf.dak_article_code, sf.subscription_id, ftp.id ftp_id, " + "ft.payer_email, iu.first_name, iu.last_name, iu.phone, " + "iu.address_1, iu.address_2, iu.city, iu.state, " + "iu.postal_code, iu.country, iu.campaign_id, s.account_id, " + "a.email a_email, a.first_name a_first_name, " + "a.last_name a_last_name, a.street a_address_1, " + "a.city a_city, a.state a_state, a.post_code a_postal_code, " + "a.country_code a_country " + "FROM campaign.subscriptions_fulfillment sf " + "INNER JOIN campaign.subscriptions s " + "ON sf.subscription_id = s.subscription_id " + "INNER JOIN campaign.fundrazr_transaction_perk ftp " + "ON s.fundrazr_transaction_perk_id = ftp.id " + "INNER JOIN campaign.transaction ft " + "ON ftp.transaction_id = ft.id " + "INNER JOIN campaign.interested_users iu " + "ON ft.interested_user_id = iu.interested_user_id " + "LEFT JOIN ag.account a " + "ON s.account_id = a.id " + "WHERE sf.fulfilled = FALSE AND sf.cancelled = FALSE " + "AND sf.fulfillment_date <= CURRENT_DATE " + "AND sf.fulfillment_id = %s", + (fulfillment_id, ) + ) + row = cur.fetchone() + if row is not None: + fulfillment_error = False + + if row['fulfillment_type'] == "ffq": + # If an account is linked to the subscription, we use + # that account's first name and email + if row['account_id'] is not None: + email = row['a_email'] + first_name = row['a_first_name'] + # If no account, fall back to original Fundrazr data + else: + email = row['payer_email'] + first_name = row['first_name'] + + email_error = self._fulfill_ffq( + row['ftp_id'], + "subscription_ffq_code", + email, + first_name + ) + if email_error is not None: + fulfillment_error = True + error_report.append( + f"Error sending FFQ email for subscription " + f"fulfillment {row['fulfillment_id']}: " + f"{email_error}" + ) + + elif row['fulfillment_type'] == "kit": + status, return_val = \ + self._fulfill_kit(row, 1, row['subscription_id']) + if not status: + # Daklapack order failed, let the error percolate + error_report.append( + f"Error placing Daklapack order for subscription " + f"fulfillment {row['fulfillment_id']}: " + f"{return_val}" + ) + else: + fulfillment_error = True + error_report.append( + f"Subscription fulfillment {row['fulfillment_id']} " + f"contains malformed fulfillment_type " + f"{row['fulfillment_type']}" + ) + + if not fulfillment_error: + cur.execute( + "UPDATE campaign.subscriptions_fulfillment " + "SET fulfilled = true " + "WHERE fulfillment_id = %s", + (row['fulfillment_id'], ) + ) + + return error_report + + def check_for_shipping_updates(self): + """Find orders for which we have not provided a tracking number, + see whether Daklapack has processed the order(s), and send out + tracking details as necessary.""" + emails_sent = 0 + error_report = [] + + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT fdo.fundrazr_transaction_perk_id ftp_id, " + "fdo.dak_order_id, kit.outbound_fedex_tracking, " + "t.payer_email, t.payer_first_name " + "FROM campaign.fundrazr_daklapack_orders fdo " + "INNER JOIN barcodes.daklapack_order dako " + "ON fdo.dak_order_id = dako.dak_order_id " + "AND dako.last_polling_status = 'Sent' " + "INNER JOIN barcodes.daklapack_order_to_kit dotk " + "ON dako.dak_order_id = dotk.dak_order_id " + "INNER JOIN barcodes.kit kit " + "ON dotk.kit_uuid = kit.kit_uuid " + "INNER JOIN campaign.fundrazr_transaction_perk ftp " + "ON fdo.fundrazr_transaction_perk_id = ftp.id " + "INNER JOIN campaign.transaction t " + "ON ftp.transaction_id = t.id " + "WHERE fdo.tracking_sent = false" + ) + rows = cur.fetchall() + template = "kit_tracking_number" + for r in rows: + try: + email_args = { + "first_name": r['payer_first_name'], + "tracking_number": r['outbound_fedex_tracking'] + } + + email_address = r['payer_email'] + + send_email( + email_address, + template, + email_args, + EN_US + ) + + # Log the email being sent + self._log_email(template, email_address, email_args) + + # Mark the email sent for the order + cur.execute( + "UPDATE campaign.fundrazr_daklapack_orders " + "SET tracking_sent = true " + "WHERE fundrazr_transaction_perk_id = %s " + "AND dak_order_id = %s", + (r['ftp_id'], r['dak_order_id']) + ) + emails_sent += 1 + except Exception as e: # noqa + # if the email fails, we'll log why but continue executing + email_error = f"FedEx tracking code email failed " \ + f"for ftp_id={r['ftp_id']} and " \ + f"dak_order_id={r['dak_order_id']} with " \ + f"the following: {repr(e)}" + error_report.append(email_error) + + return emails_sent, error_report + + def _fulfill_kit(self, row, quantity, subscription_id): + projects = \ + self._campaign_id_to_projects(row['campaign_id']) + + if "account_id" in row and row['account_id'] is not None: + country = pycountry.countries.get( + alpha_2=row['a_country'] + ) + country_name = country.name + + address_dict = { + "firstName": row['a_first_name'], + "lastName": row['a_last_name'], + "address1": row['a_address_1'], + "insertion": "", + "address2": "", + "postalCode": row['a_postal_code'], + "city": row['a_city'], + "state": row['a_state'], + "country": country_name, + "countryCode": row['a_country'], + "phone": row['a_phone'] + } + else: + country = pycountry.countries.get( + alpha_2=row['country'] + ) + country_name = country.name + + address_dict = { + "firstName": row['first_name'], + "lastName": row['last_name'], + "address1": row['address_1'], + "insertion": "", + "address2": row['address_2'], + "postalCode": row['postal_code'], + "city": row['city'], + "state": row['state'], + "country": country_name, + "countryCode": row['country'], + "phone": row['phone'] + } + + # TODO: If we expand automated perk fulfillment beyond the US, we'll + # need to handle shipping provider/type more elegantly. + daklapack_order = { + "project_ids": projects, + "article_code": row['dak_article_code'], + "address": address_dict, + "quantity": quantity, + "shipping_provider": FEDEX_PROVIDER, + "shipping_type": FEDEX_2DAY_SHIPPING + } + result = create_daklapack_order_internal(daklapack_order) + if not result['order_success']: + return False, result['daklapack_api_error_msg'] + else: + with self._transaction.cursor() as cur: + cur.execute( + "INSERT INTO campaign.fundrazr_daklapack_orders (" + "fundrazr_transaction_perk_id, dak_order_id, " + "tracking_sent" + ") VALUES (" + "%s, %s, %s" + ")", + (row['ftp_id'], result['order_id'], False) + ) + + # If this is the first kit of a subscription, + # we mark it as both scheduled and fulfilled + if subscription_id is not None: + cur_date = datetime.now() + cur_date = cur_date.strftime("%Y-%m-%d") + self._schedule_kit(subscription_id, + cur_date, + row['dak_article_code'], + True) + + return True, result['order_id'] + + def _fulfill_ffq(self, ftp_id, template, email, + first_name, subscription_id=None): + code = ActivationCode.generate_code() + with self._transaction.cursor() as cur: + # Insert the newly created registration code + cur.execute( + "INSERT INTO campaign.ffq_registration_codes (" + "ffq_registration_code" + ") VALUES (%s)", + (code,) + ) + + # Log the registration code as a fulfillment of a given FTP + cur.execute( + "INSERT INTO campaign.fundrazr_ffq_codes (" + "fundrazr_transaction_perk_id, ffq_registration_code" + ") VALUES (%s, %s)", + (ftp_id, code) + ) + + # If this is the first FFQ of a subscription, + # we mark it as both scheduled and fulfilled + if subscription_id is not None: + cur_date = datetime.now() + cur_date = cur_date.strftime("%Y-%m-%d") + self._schedule_ffq(subscription_id, cur_date, + True) + + email_error = None + try: + email_args = { + "first_name": first_name, + "registration_code": code, + "interface_endpoint": SERVER_CONFIG["interface_endpoint"] + } + + send_email( + email, + template, + email_args, + EN_US + ) + + self._log_email(template, email, email_args) + except Exception as e: # noqa + # if the email fails, we'll log why but continue executing + email_error = f"FFQ registration code email failed "\ + f"for ftp_id={ftp_id} and code={code} with "\ + f"the following: {repr(e)}" + + return email_error + + def _schedule_kit(self, subscription_id, fulfillment_date, + dak_article_code, fulfilled): + with self._transaction.cursor() as cur: + cur.execute( + "INSERT INTO campaign.subscriptions_fulfillment (" + "subscription_id, fulfillment_type, dak_article_code, " + "fulfillment_date, fulfillment_date_changed, fulfilled, " + "cancelled) VALUES (%s, %s, %s, %s, %s, %s, %s)", + (subscription_id, 'kit', dak_article_code, fulfillment_date, + False, fulfilled, False) + ) + + def _schedule_ffq(self, subscription_id, fulfillment_date, + fulfilled): + with self._transaction.cursor() as cur: + cur.execute( + "INSERT INTO campaign.subscriptions_fulfillment (" + "subscription_id, fulfillment_type, " + "fulfillment_date, fulfillment_date_changed, fulfilled, " + "cancelled) VALUES (%s, %s, %s, %s, %s, %s)", + (subscription_id, 'ffq', fulfillment_date, + False, fulfilled, False) + ) + + def _create_subscription(self, email, transaction_id, + ftp_id): + """Create a subscription to schedule fulfillments for + + Parameters + ---------- + email : str + The email address of the participant who paid for a subscription + transaction_id : str + The transaction ID from Fundrazr + ftp_id : str + The fundrazr_transaction_perk.id value + + Returns + ------- + subscription_id : uuid + The ID of the newly created subscription + """ + + account_id = None + cancelled = False + + # AuthRocket automatically returns email addresses as lowercase, so + # we can safely perform matching by converting the search email. ILIKE + # is not a suitable solution, as it can return false positives. + email = email.lower() + + # If an account exists under the email of the participant that + # contributed to Fundrazr, we link the subscription to that account. + # If not, we're going to watch for an account to be created using that + # email. If that fails, we'll associate the subscription to the + # account that uses the first kit from the subscription. + with self._transaction.cursor() as cur: + cur.execute( + "SELECT id " + "FROM ag.account " + "WHERE email = %s", + (email,) + ) + r = cur.fetchone() + if r: + account_id = r['id'] + + cur.execute( + "INSERT INTO campaign.subscriptions " + "(account_id, transaction_id, fundrazr_transaction_perk_id, " + "cancelled) " + "VALUES (%s, %s, %s, %s) " + "RETURNING subscription_id", + (account_id, transaction_id, ftp_id, cancelled) + ) + subscription_id = cur.fetchone()[0] + return subscription_id + + def get_subscriptions_by_account(self, account_id): + """Find all subscriptions associated with a given account + + Parameters + ---------- + account_id : str + An account ID + + Returns + ------- + Subscriptions or None + An list of Subscriptions if any exist. Otherwise, None. + """ + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT subscription_id, transaction_id, account_id, " + "cancelled " + "FROM campaign.subscriptions " + "WHERE account_id = %s AND account_id IS NOT NULL", + (account_id,) + ) + rows = cur.fetchall() + if rows is None: + return None + else: + return [self._row_to_subscription(r) for r in rows] + + def get_subscription_by_id(self, subscription_id): + """Find a subscription based on its id + + Parameters + ---------- + subscription_id : str + A subscription ID + + Returns + ------- + Subscription or None + A Subscription if one exists for provided id. Otherwise, None. + """ + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT subscription_id, transaction_id, account_id, " + "cancelled " + "FROM campaign.subscriptions " + "WHERE subscription_id = %s", + (subscription_id,) + ) + row = cur.fetchone() + if row is None: + return None + else: + return PerkFulfillmentRepo._row_to_subscription(row) + + def get_unclaimed_subscriptions_by_email(self, email): + """Find any subscriptions that are not yet attached to an account + based on the email address that obtained them + + Parameters + ---------- + email : str + An email address + + Returns + ------- + List of subscription_ids (UUID) or None + A list of subscription_ids if any exist for provided email. + Otherwise, None. + """ + + email = email.lower() + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT subscription_id " + "FROM campaign.subscriptions s " + "INNER JOIN campaign.transaction t " + "ON s.transaction_id = t.id " + "WHERE account_id IS NULL AND LOWER(t.payer_email) = %s", + (email, ) + ) + rows = cur.fetchall() + if rows is None: + return None + else: + return [r['subscription_id'] for r in rows] + + def claim_unclaimed_subscription(self, subscription_id, account_id): + """Attach an account_id to a subscription that doesn't yet have one + + Parameters + ---------- + subscription_id : UUID + A subscription's ID + account_id : UUID + An account ID + + Returns + ------- + int + Number of rows affected + """ + with self._transaction.cursor() as cur: + cur.execute( + "UPDATE campaign.subscriptions " + "SET account_id = %s " + "WHERE subscription_id = %s", + (account_id, subscription_id) + ) + return cur.rowcount + + def cancel_subscription(self, subscription_id): + """Cancels a subscription and associated future fulfillments + + Parameters + ---------- + subscription_id : str + A subscription ID + + Returns + ------- + int + The number of records affected in campaign.subscription + """ + with self._transaction.cursor() as cur: + cur.execute( + "UPDATE campaign.subscriptions " + "SET cancelled = true " + "WHERE subscription_id = %s", + (subscription_id,) + ) + return_val = cur.rowcount + cur.execute( + "UPDATE campaign.subscriptions_fulfillment " + "SET cancelled = true " + "WHERE subscription_id = %s ", + (subscription_id,) + ) + return return_val + + def _campaign_id_to_projects(self, campaign_id): + with self._transaction.dict_cursor() as cur: + cur.execute( + "SELECT project_id " + "FROM campaign.campaigns_projects " + "WHERE campaign_id = %s", + (campaign_id,) + ) + project_rows = cur.fetchall() + project_ids = [project['project_id'] for project in project_rows] + return project_ids + + def _future_fulfillment_date(self, spacing_number, spacing_unit, + multiplier): + cur_date = datetime.now() + spacing = abs(spacing_number)*multiplier + if spacing_unit == "days": + new_date = cur_date + relativedelta( + days=+spacing + ) + elif spacing_unit == "months": + new_date = cur_date + relativedelta( + months=+spacing + ) + else: + raise RepoException("Unknown " + "fulfillment_spacing_unit") + + return new_date.strftime("%Y-%m-%d") + + def _is_subscription(self, perk): + return (perk['ffq_quantity'] > 1 or perk['kit_quantity'] > 1) and \ + (perk['fulfillment_spacing_number'] > 0) + + def _log_email(self, template, email_address, email_args): + # Log the event of the email being sent + template_info = EmailMessage[template] + event = LogEvent( + uuid.uuid4(), + template_info.event_type, + template_info.event_subtype, + None, + { + # account_id and email are necessary to allow searching the + # event log. + "account_id": None, + "email": email_address, + "template": template, + "template_args": email_args + }) + EventLogRepo(self._transaction).add_event(event) diff --git a/microsetta_private_api/repo/tests/test_perk_fulfillment_repo.py b/microsetta_private_api/repo/tests/test_perk_fulfillment_repo.py new file mode 100644 index 000000000..fe9a2606e --- /dev/null +++ b/microsetta_private_api/repo/tests/test_perk_fulfillment_repo.py @@ -0,0 +1,980 @@ +import unittest +import datetime +import uuid +from unittest.mock import patch +import dateutil.parser + +from microsetta_private_api.config_manager import SERVER_CONFIG +from microsetta_private_api.repo.perk_fulfillment_repo import\ + PerkFulfillmentRepo +from microsetta_private_api.repo.campaign_repo import UserTransaction +from microsetta_private_api.repo.transaction import Transaction +from microsetta_private_api.model.campaign import (FundRazrPayment, Item, + Shipping) +from microsetta_private_api.model.address import Address +from microsetta_private_api.model.daklapack_order import DaklapackOrder +from microsetta_private_api.repo.account_repo import AccountRepo +from microsetta_private_api.repo.admin_repo import AdminRepo +from microsetta_private_api.model.account import Account + +ACCT_ID_1 = '7a98df6a-e4db-40f4-91ec-627ac315d881' +DUMMY_ACCT_INFO_1 = { + "address": { + "city": "Springfield", + "country_code": "US", + "post_code": "12345", + "state": "CA", + "street": "123 Main St. E. Apt. 2" + }, + "email": "microbe@bar.com", + "first_name": "Jane", + "last_name": "Doe", + "language": "en_US", + "kit_name": 'jb_qhxqe', + "id": ACCT_ID_1 +} +ACCT_MOCK_ISS_1 = "MrUnitTest.go" +ACCT_MOCK_SUB_1 = "NotARealSub" + +ADDRESS1 = Address( + '9500 Gilman Dr', + 'La Jolla', + 'CA', + '92093', + 'US') +SHIPPING1 = Shipping('Microbe', 'Researcher', ADDRESS1) + +ITEM_ONE_FFQ = [ + Item('Analyze Your Nutrition', 1, '3QeVd') +] +ITEM_ONE_KIT = [ + Item('Explore Your Microbiome', 1, '3QeW6'), +] +ITEM_ONE_SUBSCRIPTION = [ + Item('Follow Your Gut', 1, '0QeXa') +] +ITEM_FAKE_PERK = [ + Item('Not a Perk', 1, 'FAKEFAKE') +] + +FFQ_TRANSACTION_ID = "FFQ_TRANS" +KIT_TRANSACTION_ID = "KIT_TRANS" +SUB_TRANSACTION_ID = "SUB_TRANS" + +TRANSACTION_ONE_FFQ = FundRazrPayment( + FFQ_TRANSACTION_ID, + datetime.datetime.now(), + '4Tqx5', + 20., + 20., + 'usd', + 'Microbe', + 'Researcher', + 'paypal', + True, + None, + 'coolcool', + '123456789', + SHIPPING1, + ITEM_ONE_FFQ, + payer_email='microbe@bar.com', + contact_email='microbe@bar.com' +) +TRANSACTION_ONE_KIT = FundRazrPayment( + KIT_TRANSACTION_ID, + datetime.datetime.now(), + '4Tqx5', + 180., + 180., + 'usd', + 'Microbe', + 'Researcher', + 'paypal', + True, + None, + 'coolcool', + '123456789', + SHIPPING1, + ITEM_ONE_KIT, + payer_email='microbe@bar.com', + contact_email='microbe@bar.com' +) +TRANSACTION_ONE_SUBSCRIPTION = FundRazrPayment( + SUB_TRANSACTION_ID, + datetime.datetime.now(), + '4Tqx5', + 720., + 720., + 'usd', + 'Microbe', + 'Researcher', + 'paypal', + True, + None, + 'coolcool', + '123456789', + SHIPPING1, + ITEM_ONE_SUBSCRIPTION, + payer_email='microbe@bar.com', + contact_email='microbe@bar.com' +) +TRANSACTION_FAKE_PERK = FundRazrPayment( + '123abc', + datetime.datetime.now(), + '4Tqx5', + 6., + 6., + 'usd', + 'Microbe', + 'Researcher', + 'paypal', + True, + None, + 'coolcool', + '123456789', + SHIPPING1, + ITEM_FAKE_PERK, + payer_email='microbe@bar.com', + contact_email='microbe@bar.com' +) + +DUMMY_ORDER_ID = str(uuid.uuid4()) +DUMMY_ORDER_ID2 = str(uuid.uuid4()) +SUBMITTER_ID = SERVER_CONFIG['fulfillment_account_id'] +SUBMITTER_NAME = "demo demo" +PROJECT_IDS = [1, ] +DUMMY_DAKLAPACK_ORDER = { + 'orderId': DUMMY_ORDER_ID, + 'articles': [ + { + 'articleCode': '3510005E', + 'addresses': [ + { + 'firstName': 'Microbe', + 'lastName': 'Researcher', + 'address1': '9500 Gilman Dr', + 'insertion': '', + 'address2': '', + 'postalCode': 92093, + 'city': 'La Jolla', + 'state': 'CA', + 'country': 'United States', + 'countryCode': 'US', + 'phone': '1234567890', + 'creationDate': '2020-10-09T22:43:52.219328Z', + 'companyName': SUBMITTER_NAME + } + ] + } + ], + 'shippingProvider': 'FedEx', + 'shippingType': 'FEDEX_2_DAY', + 'shippingProviderMetadata': [ + {'key': 'Reference 1', + 'value': 'Bill Ted'} + ] +} +DUMMY_DAKLAPACK_ORDER2 = { + 'orderId': DUMMY_ORDER_ID2, + 'articles': [ + { + 'articleCode': '3510005E', + 'addresses': [ + { + 'firstName': 'Microbe', + 'lastName': 'Researcher', + 'address1': '9500 Gilman Dr', + 'insertion': '', + 'address2': '', + 'postalCode': 92093, + 'city': 'La Jolla', + 'state': 'CA', + 'country': 'United States', + 'countryCode': 'US', + 'phone': '1234567890', + 'creationDate': '2020-10-09T22:43:52.219328Z', + 'companyName': SUBMITTER_NAME + } + ] + } + ], + 'shippingProvider': 'FedEx', + 'shippingType': 'FEDEX_2_DAY', + 'shippingProviderMetadata': [ + {'key': 'Reference 1', + 'value': 'Bill Ted'} + ] +} + +VERIFY_ADDRESS_DICT = { + "valid": True, + "address_1": "9500 Gilman Dr", + "address_2": "", + "address_3": "", + "city": "La Jolla", + "state": "CA", + "postal": "92093", + "latitude": 32.879215217102335, + "longitude": -117.24106063080784 +} +DUMMY_KIT_UUID = str(uuid.uuid4()) +DUMMY_KIT_ID = "SOMEKIT44" +DUMMY_TRACKING = "qwerty123456" + + +class PerkFulfillmentRepoTests(unittest.TestCase): + @patch("microsetta_private_api.repo.interested_user_repo.verify_address") + def setUp(self, verify_address_result): + verify_address_result.return_value = VERIFY_ADDRESS_DICT + + self.test_campaign_title_1 = 'Test Campaign' + with Transaction() as t: + cur = t.cursor() + cur.execute( + "INSERT INTO campaign.campaigns (title) " + "VALUES (%s) " + "RETURNING campaign_id", + (self.test_campaign_title_1, ) + ) + self.test_campaign_id1 = cur.fetchone()[0] + + cur.execute( + "INSERT INTO campaign.campaigns_projects " + "(campaign_id, project_id) " + "VALUES (%s, 1)", + (self.test_campaign_id1, ) + ) + + # We need to insert some dummy transactions + ut = UserTransaction(t) + ut.add_transaction(TRANSACTION_ONE_FFQ) + cur.execute( + "SELECT id " + "FROM campaign.fundrazr_transaction_perk " + "WHERE transaction_id = %s", + (FFQ_TRANSACTION_ID, ) + ) + res = cur.fetchone() + self.ffq_ftp_id = res[0] + + ut.add_transaction(TRANSACTION_ONE_KIT) + cur.execute( + "SELECT id " + "FROM campaign.fundrazr_transaction_perk " + "WHERE transaction_id = %s", + (KIT_TRANSACTION_ID, ) + ) + res = cur.fetchone() + self.kit_ftp_id = res[0] + + ut.add_transaction(TRANSACTION_ONE_SUBSCRIPTION) + cur.execute( + "SELECT id " + "FROM campaign.fundrazr_transaction_perk " + "WHERE transaction_id = %s", + (SUB_TRANSACTION_ID, ) + ) + res = cur.fetchone() + self.sub_ftp_id = res[0] + + t.commit() + + def tearDown(self): + with Transaction() as t: + cur = t.cursor() + cur.execute( + "DELETE FROM campaign.campaigns_projects " + "WHERE campaign_id = %s", + (self.test_campaign_id1,) + ) + cur.execute( + "DELETE FROM campaign.campaigns " + "WHERE campaign_id = %s", + (self.test_campaign_id1, ) + ) + cur.execute( + "DELETE FROM campaign.fundrazr_transaction_perk " + "WHERE id IN %s", + ((self.ffq_ftp_id, self.kit_ftp_id, self.sub_ftp_id), ) + ) + cur.execute( + "DELETE FROM campaign.transaction " + "WHERE id IN %s", + (( + FFQ_TRANSACTION_ID, + KIT_TRANSACTION_ID, + SUB_TRANSACTION_ID + ), ) + ) + t.commit() + + def test_find_perks_without_fulfillment_details(self): + with Transaction() as t: + cur = t.cursor() + + # Create a fake perk + cur.execute( + "INSERT INTO campaign.fundrazr_perk " + "(id, remote_campaign_id, title, price) " + "VALUES ('FAKEFAKE', '4Tqx5', 'Not a Perk', 6)" + ) + + ut = UserTransaction(t) + ut.add_transaction(TRANSACTION_FAKE_PERK) + pfr = PerkFulfillmentRepo(t) + res = pfr.find_perks_without_fulfillment_details() + + # Our result should contain at least one perk for which we don't + # have fulfillment details + self.assertTrue(len(res) > 0) + + # Verify that one of the perks is ours with a perk_id of FAKEFAKE + found_fake = False + for bad_perk in res: + if bad_perk['perk_id'] == "FAKEFAKE": + found_fake = True + + self.assertTrue(found_fake) + + def test_get_pending_fulfillments(self): + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + res = pfr.get_pending_fulfillments() + + # We created three transactions in setUp(), so we should observe + # a list of three ftp_ids + self.assertEqual(len(res), 3) + self.assertTrue(self.ffq_ftp_id in res) + self.assertTrue(self.kit_ftp_id in res) + self.assertTrue(self.sub_ftp_id in res) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + def test_process_pending_fulfillment_kit_succeed( + self, + test_daklapack_order_result + ): + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + } + + # res simulates what comes back from + # PerkFulfillmentRepo.get_pending_fulfillments() + res = [self.kit_ftp_id] + for ftp_id in res: + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, + desc, planned_send_date, + creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + returned_id = admin_repo.create_daklapack_order(input) + + pfr = PerkFulfillmentRepo(t) + _ = pfr.process_pending_fulfillment(ftp_id) + + cur = t.cursor() + + # Confirm that the order populated into + # fundrazr_daklapack_orders + cur.execute( + "SELECT COUNT(*) " + "FROM campaign.fundrazr_daklapack_orders " + "WHERE dak_order_id = %s", + (returned_id, ) + ) + res = cur.fetchone() + self.assertEqual(res[0], 1) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + def test_process_pending_fulfillments_one_kit_fail( + self, + test_daklapack_order_result + ): + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": False, + "daklapack_api_error_msg": "Some error message", + "daklapack_api_error_code": "Some error code" + } + + # res simulates what comes back from + # PerkFulfillmentRepo.get_pending_fulfillments() + res = [self.kit_ftp_id] + for ftp_id in res: + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + res = pfr.process_pending_fulfillment(ftp_id) + + # We should observe an error reflecting a Daklapack issue + found_dak_error = False + for e in res: + if e.startswith( + f"Error placing Daklapack order for ftp_id " + f"{ftp_id}" + ): + found_dak_error = True + self.assertTrue(found_dak_error) + + def test_process_pending_fulfillments_one_ffq(self): + res = [self.ffq_ftp_id] + for ftp_id in res: + with Transaction() as t: + ffq_r_c_count = self._count_ffq_registration_codes(t) + exp_ffq_r_c_count = ffq_r_c_count+1 + + fundrazr_ffq_count = self._count_fundrazr_ffq_codes(t) + exp_fundrazr_ffq_count = fundrazr_ffq_count+1 + + pfr = PerkFulfillmentRepo(t) + _ = pfr.process_pending_fulfillment(ftp_id) + + new_ffq_r_c_count = self._count_ffq_registration_codes(t) + self.assertEqual(new_ffq_r_c_count, exp_ffq_r_c_count) + + new_fundrazr_ffq_count = self._count_fundrazr_ffq_codes(t) + self.assertEqual( + new_fundrazr_ffq_count, + exp_fundrazr_ffq_count + ) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + def test_transaction_one_subscription( + self, + test_daklapack_order_result + ): + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + } + + # We're going to add a transaction for one subscription. + # We should observe one new record in the registration_codes, + # fundrazr_ffq_codes, fundrazr_daklapack_orders, and subscriptions + # tables. We should also observe eight new records in the + # subscriptions_fulfillment table. + + # We have to mock out the actual Daklapack order since it's an + # external resource. + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, desc, + planned_send_date, creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + returned_id = admin_repo.create_daklapack_order(input) + + pfr = PerkFulfillmentRepo(t) + _ = pfr.process_pending_fulfillment(self.sub_ftp_id) + + cur = t.dict_cursor() + + # We need to grab the subscription ID + cur.execute( + "SELECT s.subscription_id " + "FROM campaign.subscriptions s " + "INNER JOIN campaign.fundrazr_daklapack_orders fdo " + "ON s.fundrazr_transaction_perk_id = " + "fdo.fundrazr_transaction_perk_id AND fdo.dak_order_id = %s", + (returned_id, ) + ) + row = cur.fetchone() + subscription_id = row['subscription_id'] + + # Confirm that there's a fulfilled FFQ and a fulfilled kit + # for the subscription_id + cur.execute( + "SELECT COUNT(*) kit_count " + "FROM campaign.subscriptions_fulfillment " + "WHERE subscription_id = %s AND fulfillment_type = 'kit' " + "AND fulfilled = true", + (subscription_id, ) + ) + row = cur.fetchone() + self.assertEqual(row['kit_count'], 1) + cur.execute( + "SELECT COUNT(*) ffq_count " + "FROM campaign.subscriptions_fulfillment " + "WHERE subscription_id = %s AND fulfillment_type = 'ffq' " + "AND fulfilled = true", + (subscription_id, ) + ) + row = cur.fetchone() + self.assertEqual(row['ffq_count'], 1) + + # Confirm there are three unfulfilled FFQ and kit records + cur.execute( + "SELECT COUNT(*) kit_count " + "FROM campaign.subscriptions_fulfillment " + "WHERE subscription_id = %s AND fulfillment_type = 'kit' " + "AND fulfilled = false", + (subscription_id, ) + ) + row = cur.fetchone() + self.assertEqual(row['kit_count'], 3) + cur.execute( + "SELECT COUNT(*) ffq_count " + "FROM campaign.subscriptions_fulfillment " + "WHERE subscription_id = %s AND fulfillment_type = 'ffq' " + "AND fulfilled = false", + (subscription_id, ) + ) + row = cur.fetchone() + self.assertEqual(row['ffq_count'], 3) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + def test_get_subscription_by_id( + self, + test_daklapack_order_result + ): + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + } + + # We have to mock out the actual Daklapack order since it's an + # external resource. + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, desc, + planned_send_date, creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + returned_id = admin_repo.create_daklapack_order(input) + + pfr = PerkFulfillmentRepo(t) + pfr.process_pending_fulfillment(self.sub_ftp_id) + + cur = t.dict_cursor() + + # We need to grab the subscription ID + cur.execute( + "SELECT s.subscription_id " + "FROM campaign.subscriptions s " + "INNER JOIN campaign.fundrazr_daklapack_orders fdo " + "ON s.fundrazr_transaction_perk_id = " + "fdo.fundrazr_transaction_perk_id AND fdo.dak_order_id = %s", + (returned_id, ) + ) + row = cur.fetchone() + subscription_id = row['subscription_id'] + + subscription = pfr.get_subscription_by_id(subscription_id) + + # Confirm that we can retrieve the subscription by id + self.assertEqual(subscription.subscription_id, subscription_id) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + def test_get_unclaimed_subscriptions_by_email_and_claim( + self, + test_daklapack_order_result + ): + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + } + + # We have to mock out the actual Daklapack order since it's an + # external resource. + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, desc, + planned_send_date, creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + _ = admin_repo.create_daklapack_order(input) + + pfr = PerkFulfillmentRepo(t) + pfr.process_pending_fulfillment(self.sub_ftp_id) + + res = pfr.get_unclaimed_subscriptions_by_email("microbe@bar.com") + subscription_id = res[0] + # verify that we received a subscription_id back + self.assertEqual(len(res), 1) + + # create a dummy account + ar = AccountRepo(t) + acct_1 = Account.from_dict(DUMMY_ACCT_INFO_1, + ACCT_MOCK_ISS_1, + ACCT_MOCK_SUB_1) + ar.create_account(acct_1) + + # now let's claim it + res = pfr.claim_unclaimed_subscription(subscription_id, ACCT_ID_1) + self.assertEqual(res, 1) + + # and make sure get_subscription_by_account works + res = pfr.get_subscriptions_by_account(ACCT_ID_1) + self.assertEqual(subscription_id, res[0].subscription_id) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + def test_cancel_subscription( + self, + test_daklapack_order_result + ): + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + } + + # We have to mock out the actual Daklapack order since it's an + # external resource. + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, desc, + planned_send_date, creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + returned_id = admin_repo.create_daklapack_order(input) + + pfr = PerkFulfillmentRepo(t) + pfr.process_pending_fulfillment(self.sub_ftp_id) + + cur = t.dict_cursor() + + # We need to grab the subscription ID + cur.execute( + "SELECT s.subscription_id " + "FROM campaign.subscriptions s " + "INNER JOIN campaign.fundrazr_daklapack_orders fdo " + "ON s.fundrazr_transaction_perk_id = " + "fdo.fundrazr_transaction_perk_id AND fdo.dak_order_id = %s", + (returned_id, ) + ) + row = cur.fetchone() + subscription_id = row['subscription_id'] + + rowcount = pfr.cancel_subscription(subscription_id) + + # Confirm that we can retrieve the subscription by id + self.assertEqual(rowcount, 1) + + # Confirm that there are six cancelled and unfulfilled rows + cur.execute( + "SELECT * " + "FROM campaign.subscriptions_fulfillment " + "WHERE subscription_id = %s AND cancelled = TRUE " + "AND fulfilled = FALSE", + (subscription_id, ) + ) + cancelled_fulfillments = cur.rowcount + + self.assertEqual(cancelled_fulfillments, 6) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo.send_email" + ) + def test_check_for_shipping_updates( + self, + test_send_email_result, + test_daklapack_order_result + ): + test_send_email_result = True + test_daklapack_order_result.return_value = { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + } + + # res simulates what comes back from + # PerkFulfillmentRepo.get_pending_fulfillments() + res = [self.kit_ftp_id] + for ftp_id in res: + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, + desc, planned_send_date, + creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + _ = admin_repo.create_daklapack_order(input) + + cur = t.cursor() + + # To simulate a shipped order, we need to update the status, + # create a kit, and map the kit to the order + cur.execute( + "UPDATE barcodes.daklapack_order " + "SET last_polling_status = 'Sent' " + "WHERE dak_order_id = %s", + (DUMMY_ORDER_ID, ) + ) + cur.execute( + "INSERT INTO barcodes.kit (" + "kit_uuid, kit_id, outbound_fedex_tracking, box_id" + ") VALUES (" + "%s, %s, %s, 'ABOX'" + ")", + (DUMMY_KIT_UUID, DUMMY_KIT_ID, DUMMY_TRACKING) + ) + cur.execute( + "INSERT INTO barcodes.daklapack_order_to_kit (" + "dak_order_id, kit_uuid" + ") VALUES (" + "%s, %s" + ")", + (DUMMY_ORDER_ID, DUMMY_KIT_UUID) + ) + + pfr = PerkFulfillmentRepo(t) + _ = pfr.process_pending_fulfillment(ftp_id) + + # Need to do something with this variable to satisfy lint + self.assertEqual(test_send_email_result, True) + + emails_sent, error_report = pfr.check_for_shipping_updates() + + self.assertEqual(emails_sent, 1) + self.assertEqual(len(error_report), 0) + + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo." + "create_daklapack_order_internal" + ) + @patch( + "microsetta_private_api.repo.perk_fulfillment_repo.send_email" + ) + def test_get_subscription_fulfillments( + self, + test_send_email_result, + test_daklapack_order_result + ): + test_send_email_result = True + # Need to do something with this variable to satisfy lint + self.assertEqual(test_send_email_result, True) + + test_daklapack_order_result.side_effect = [ + { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID + }, + { + "order_address": "wedontcareaboutthis", + "order_success": True, + "order_id": DUMMY_ORDER_ID2 + } + ] + + # We're going to add a transaction for one subscription. + # Then we're going to set the first pending fulfillment to be due + # today so we can observe it as ready for processing. + + # We have to mock out the actual Daklapack order since it's an + # external resource. + with Transaction() as t: + # create a dummy Daklapack order + acct_repo = AccountRepo(t) + submitter_acct = acct_repo.get_account(SUBMITTER_ID) + + creation_timestamp = dateutil.parser.isoparse( + "2020-10-09T22:43:52.219328Z") + last_polling_timestamp = dateutil.parser.isoparse( + "2020-10-19T12:40:19.219328Z") + desc = "a description" + planned_send_date = datetime.date(2032, 2, 9) + last_status = "accepted" + + # create dummy daklapack order object for first shipment + input = DaklapackOrder(DUMMY_ORDER_ID, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER, desc, + planned_send_date, creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + admin_repo = AdminRepo(t) + returned_id = admin_repo.create_daklapack_order(input) + + # create dummy daklapack order object for second shipment + input2 = DaklapackOrder(DUMMY_ORDER_ID2, submitter_acct, + PROJECT_IDS, DUMMY_DAKLAPACK_ORDER2, desc, + planned_send_date, creation_timestamp, + last_polling_timestamp, last_status) + + # call create_daklapack_order + _ = admin_repo.create_daklapack_order(input2) + + pfr = PerkFulfillmentRepo(t) + _ = pfr.process_pending_fulfillment(self.sub_ftp_id) + + cur = t.dict_cursor() + + # We need to grab the subscription ID + cur.execute( + "SELECT s.subscription_id " + "FROM campaign.subscriptions s " + "INNER JOIN campaign.fundrazr_daklapack_orders fdo " + "ON s.fundrazr_transaction_perk_id = " + "fdo.fundrazr_transaction_perk_id AND fdo.dak_order_id = %s", + (returned_id,) + ) + row = cur.fetchone() + subscription_id = row['subscription_id'] + + # Grab the first two unfilfilled records for the subscription. + # Given the subscription setup, this will be one FFQ and one kit. + cur.execute( + "SELECT fulfillment_id " + "FROM campaign.subscriptions_fulfillment " + "WHERE subscription_id = %s AND fulfilled = FALSE " + "ORDER BY fulfillment_date LIMIT 2", + (subscription_id,) + ) + rows = cur.fetchall() + for row in rows: + # Update them to be due to fulfill today + cur.execute( + "UPDATE campaign.subscriptions_fulfillment " + "SET fulfillment_date = CURRENT_DATE " + "WHERE fulfillment_id = %s", + (row['fulfillment_id'], ) + ) + + # Get pending fulfillments + pending_ful = pfr.get_subscription_fulfillments() + + # Verify that there are two records + self.assertEqual(len(pending_ful), 2) + + # Process the pending fulfillments + for f_id in pending_ful: + error_list = pfr.process_subscription_fulfillment(f_id) + self.assertEqual(len(error_list), 0) + + def _count_ffq_registration_codes(self, t): + cur = t.cursor() + cur.execute( + "SELECT COUNT(*) FROM campaign.ffq_registration_codes" + ) + res = cur.fetchone() + return res[0] + + def _count_fundrazr_ffq_codes(self, t): + cur = t.cursor() + cur.execute( + "SELECT COUNT(*) FROM campaign.fundrazr_ffq_codes" + ) + res = cur.fetchone() + return res[0] + + +if __name__ == '__main__': + unittest.main() diff --git a/microsetta_private_api/repo/transaction.py b/microsetta_private_api/repo/transaction.py index 23268ca61..94648d89b 100644 --- a/microsetta_private_api/repo/transaction.py +++ b/microsetta_private_api/repo/transaction.py @@ -62,14 +62,14 @@ def cursor(self): if self._closed: raise RuntimeError("Cannot open cursor from closed Transaction") cur = self._conn.cursor() - cur.execute('SET search_path TO ag, barcodes, public') + cur.execute('SET search_path TO ag, barcodes, public, campaign') return cur def dict_cursor(self): if self._closed: raise RuntimeError("Cannot open cursor from closed Transaction") cur = self._conn.cursor(cursor_factory=psycopg2.extras.DictCursor) - cur.execute('SET search_path TO ag, barcodes, public') + cur.execute('SET search_path TO ag, barcodes, public, campaign') return cur @property diff --git a/microsetta_private_api/server_config.json b/microsetta_private_api/server_config.json index 162a99f20..0b64e47a3 100644 --- a/microsetta_private_api/server_config.json +++ b/microsetta_private_api/server_config.json @@ -41,5 +41,6 @@ "fundrazr_url": "fundrazr_url_placeholder", "fundrazr_organization": "fundrazr_org_placeholder", "polyphenol_ffq_url": "pffq_placeholder", - "spain_ffq_url": "sffq_placeholder" + "spain_ffq_url": "sffq_placeholder", + "fulfillment_account_id": "000fc4cd-8fa4-db8b-e050-8a800c5d81b7" } diff --git a/microsetta_private_api/templates/email/kit_tracking_number.jinja2 b/microsetta_private_api/templates/email/kit_tracking_number.jinja2 new file mode 100644 index 000000000..043e6c650 --- /dev/null +++ b/microsetta_private_api/templates/email/kit_tracking_number.jinja2 @@ -0,0 +1,249 @@ + +
+ + + + ++
+ {{ _("Hello") }} {{ first_name }}, +
++
+ {{ _("Thank you for your contribution to The Microsetta Initiative. Your collection kit has been shipped.") }} +
++ +
+
+ {{ _("Item: Collection Kit") }}
+ {{ _("Tracking number:") }} {{ tracking_number }}
+
+ +
+
+
+ {{ _("If you have any questions, please visit our FAQs and instructions pages, or you can email us at ") }}microsetta@ucsd.edu.
+
+
+ +
++
{{ _("Best wishes,") }}
+{{ _("The Microsetta Team") }}
+ + + diff --git a/microsetta_private_api/templates/email/subscription_ffq_code.jinja2 b/microsetta_private_api/templates/email/subscription_ffq_code.jinja2 new file mode 100644 index 000000000..ac8cbaa21 --- /dev/null +++ b/microsetta_private_api/templates/email/subscription_ffq_code.jinja2 @@ -0,0 +1,260 @@ + + + + + + ++
+ {{ _("Dear") }} {{ first_name }}, +
++
+ {{ _("Thank you for helping to drive research to discover more about the human microbiome. Once the next kit for your 'Follow Your Gut' ships, we will send you an email with a tracking number so you will know when it's on the way.") }} +
++ +
++ {{ _("Item: Collection Kit") }} +
++ +
++ {{ _("What's next: Your new food frequency questionnaire will be available to complete when you login to your account. Visit the 'My Nutrition' page and enter the following:") }} +
++ +
++ {{ _("Registration code:") }} {{ registration_code }} +
++ +
+
+
+ {{ _("Thank you for your participation and we hope you enjoy learning more about your gut microbiome! If you have any questions, please visit our FAQs and instructions pages, or you can email us at ") }}microsetta@ucsd.edu.
+
+
+ +
++
{{ _("Best wishes,") }}
+{{ _("The Microsetta Team") }}
+ + + diff --git a/microsetta_private_api/templates/email/thank_you_no_kit.jinja2 b/microsetta_private_api/templates/email/thank_you_no_kit.jinja2 new file mode 100644 index 000000000..6e8964433 --- /dev/null +++ b/microsetta_private_api/templates/email/thank_you_no_kit.jinja2 @@ -0,0 +1,266 @@ + + + + + + ++
+ {{ _("Dear") }} {{ first_name }}, +
++
+ {{ _("Thank you for your contribution to The Microsetta Initiative and for helping to drive research to discover more about the human microbiome.") }} +
++ +
++ {{ _("What's next: If you're joining us for the first time, visit the TMI website to create your account, profile, and fill in the initial forms and surveys. Your email address is linked to this information and must be the same email used to set up your account.") }} +
++ +
++ [{{ _("Create Account or Login") }}] +
++ +
++ {{ _("Once completed you will be ready to take your food frequency questionnaire. Visit the 'My Nutrition' page and enter the following:") }} +
++ +
++ {{ _("Registration code:") }} {{ registration_code }} +
++ +
+
+
+ {{ _("Thank you for your participation! If you have any questions, please visit our FAQs and instructions pages, or you can email us at ") }}microsetta@ucsd.edu.
+
+
+ +
++
{{ _("Best wishes,") }}
+{{ _("The Microsetta Team") }}
+ + + diff --git a/microsetta_private_api/templates/email/thank_you_with_kit.jinja2 b/microsetta_private_api/templates/email/thank_you_with_kit.jinja2 new file mode 100644 index 000000000..2ee7f6e9e --- /dev/null +++ b/microsetta_private_api/templates/email/thank_you_with_kit.jinja2 @@ -0,0 +1,268 @@ + + + + + + ++
+ {{ _("Dear") }} {{ first_name }}, +
++
+ {{ _("Thank you for helping to drive research to discover more about the human microbiome. Once your collection kit ships, we will send you an email with a tracking number so you will know when it's on the way.") }} +
++ +
++ {{ _("What's next: If you're joining us for the first time, visit the TMI website to create your account, profile, and fill in the initial forms and surveys. Your email address is linked to this information and must be the same email used to set up your account.") }} +
++ +
++ [{{ _("Create Account or Login") }}] +
++ +
++ {{ _("Once your profile is complete you will be ready to take your food frequency questionnaire. Visit the 'My Nutrition' page and enter the following:") }} +
++ +
++ {{ _("Registration code:") }} {{ registration_code }} +
++ +
+
+
+ {{ _("For helpful resources, please visit the links below:") }}
+ {{ _('Step-by-step instructions') }}
+ {{ _('FAQs') }}
+
+
+ +
++
{{ _("Best wishes,") }}
+{{ _("The Microsetta Team") }}
+ + + diff --git a/microsetta_private_api/util/fundrazr.py b/microsetta_private_api/util/fundrazr.py index af88acaf9..ce42bb28d 100644 --- a/microsetta_private_api/util/fundrazr.py +++ b/microsetta_private_api/util/fundrazr.py @@ -20,7 +20,6 @@ def _get_load(t): latest = tr.most_recent_transaction(transaction_source=tr.TRN_TYPE_FUNDRAZR, # noqa include_anonymous=True) - # if we do not have any transactions, we don't have anything recent if latest is None: unixtimestamp = None else: @@ -44,13 +43,14 @@ def _get_load(t): # otherwise, we assume we are in tests and we do not commit added, amount = _get_load(test_transaction) - payload = f"Number added: {added}\nTotaling: ${amount}" - - try: - send_email(SERVER_CONFIG['pester_email'], "pester_daniel", - {"what": "FundRazr transactions added", - "content": payload}, - EN_US) - except: # noqa - # try our best to email - pass + if added > 0: + payload = f"Number added: {added}\nTotaling: ${amount}" + + try: + send_email(SERVER_CONFIG['pester_email'], "pester_daniel", + {"what": "FundRazr transactions added", + "content": payload}, + EN_US) + except: # noqa + # try our best to email + pass diff --git a/microsetta_private_api/util/perk_fulfillment.py b/microsetta_private_api/util/perk_fulfillment.py new file mode 100644 index 000000000..a8d0b9cad --- /dev/null +++ b/microsetta_private_api/util/perk_fulfillment.py @@ -0,0 +1,102 @@ +from microsetta_private_api.celery_utils import celery +from microsetta_private_api.tasks import send_email +from microsetta_private_api.repo.transaction import Transaction +from microsetta_private_api.repo.perk_fulfillment_repo import\ + PerkFulfillmentRepo +from microsetta_private_api.localization import EN_US +from microsetta_private_api.config_manager import SERVER_CONFIG + + +@celery.task(ignore_result=True) +def fulfill_new_transactions(): + error_report = [] + + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + ftp_ids = pfr.get_pending_fulfillments() + + for ftp_id in ftp_ids: + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + error_list = pfr.process_pending_fulfillment(ftp_id) + if len(error_list) > 0: + for error in error_list: + error_report.append(error) + + t.commit() + + if len(error_report) > 0: + try: + send_email(SERVER_CONFIG['pester_email'], "pester_daniel", + {"what": "Perk Fulfillment Errors", + "content": error_report}, + EN_US) + except: # noqa + # try our best to email + pass + + +@celery.task(ignore_result=True) +def process_subscription_fulfillments(): + error_report = [] + + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + fulfillment_ids = pfr.get_subscription_fulfillments() + + for fulfillment_id in fulfillment_ids: + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + error_list = pfr.process_subscription_fulfillment(fulfillment_id) + + if len(error_list) > 0: + for error in error_list: + error_report.append(error) + + t.commit() + + if len(error_report) > 0: + try: + send_email(SERVER_CONFIG['pester_email'], "pester_daniel", + {"what": "Subscription Fulfillment Errors", + "content": error_report}, + EN_US) + except: # noqa + # try our best to email + pass + + +@celery.task(ignore_result=True) +def check_shipping_updates(): + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + emails_sent, error_report = pfr.check_for_shipping_updates() + + if emails_sent > 0 or len(error_report) > 0: + email_content = f"Emails sent: {emails_sent}\n"\ + f"Errors: {error_report}" + try: + send_email(SERVER_CONFIG['pester_email'], "pester_daniel", + {"what": "Automated Tracking Updates Output", + "content": email_content}, + EN_US) + except: # noqa + # try our best to email + pass + + +@celery.task(ignore_result=True) +def perks_without_fulfillment_details(): + with Transaction() as t: + pfr = PerkFulfillmentRepo(t) + perk_log = pfr.find_perks_without_fulfillment_details() + + if len(perk_log) > 0: + try: + send_email(SERVER_CONFIG['pester_email'], "pester_daniel", + {"what": "Perks Without Fulfillment Details", + "content": perk_log}, + EN_US) + except: # noqa + # try our best to email + pass