From 18cfc46d75882d79e859b47e57e6ea92d420c283 Mon Sep 17 00:00:00 2001 From: Gavin Sidebottom Date: Tue, 27 Oct 2020 11:12:22 -0400 Subject: [PATCH] Added support for affiliate links --- affiliate/README.md | 31 +++++++ affiliate/__init__.py | 0 affiliate/admin.py | 52 ++++++++++++ affiliate/api.py | 66 +++++++++++++++ affiliate/api_test.py | 70 ++++++++++++++++ affiliate/apps.py | 8 ++ affiliate/constants.py | 4 + affiliate/factories.py | 30 +++++++ affiliate/middleware.py | 21 +++++ affiliate/middleware_test.py | 39 +++++++++ .../0001_affiliate_initial_models.py | 82 +++++++++++++++++++ affiliate/migrations/__init__.py | 0 affiliate/models.py | 44 ++++++++++ authentication/pipeline/user.py | 9 +- authentication/pipeline/user_test.py | 31 +++++++ ecommerce/api.py | 8 +- ecommerce/api_test.py | 12 +++ ecommerce/models.py | 3 +- ecommerce/views.py | 4 +- ecommerce/views_test.py | 27 ++++++ mail/verification_api.py | 6 ++ mail/verification_api_test.py | 34 +++++++- mitxpro/settings.py | 2 + .../pages/register/RegisterConfirmPage.js | 17 ++-- static/js/lib/queries/auth.js | 7 +- users/models.py | 11 ++- users/models_test.py | 18 ++++ users/serializers.py | 6 +- users/serializers_test.py | 22 +++++ 29 files changed, 636 insertions(+), 28 deletions(-) create mode 100644 affiliate/README.md create mode 100644 affiliate/__init__.py create mode 100644 affiliate/admin.py create mode 100644 affiliate/api.py create mode 100644 affiliate/api_test.py create mode 100644 affiliate/apps.py create mode 100644 affiliate/constants.py create mode 100644 affiliate/factories.py create mode 100644 affiliate/middleware.py create mode 100644 affiliate/middleware_test.py create mode 100644 affiliate/migrations/0001_affiliate_initial_models.py create mode 100644 affiliate/migrations/__init__.py create mode 100644 affiliate/models.py diff --git a/affiliate/README.md b/affiliate/README.md new file mode 100644 index 000000000..2bcd1c3ed --- /dev/null +++ b/affiliate/README.md @@ -0,0 +1,31 @@ +# Affiliate Tracking + +This app supports the tracking of affiliate links to xPRO. The basic idea is that we +advertise xPRO on other websites ("affiliates") and pay them for the inbound traffic under +certain conditions. + +### Scenarios + +We intend to credit our affiliates for traffic if they refer a user to us, and the user +does one of the following: + +1. Creates a new account +1. Completes an order + +A database record is created when the app detects that any of the above scenarios has occurred. We can then +run a BI query that creates a report showing what we owe each affiliate based on those records. + +### Implementation Details + +The app will create a database record for those user actions under the following conditions: + +- The user first arrives on the site with a querystring parameter that has an affiliate code (`?aid=`). +- The above code matches the affiliate code for an affiliate in our database. +- The action is completed before the session expires. + - We store the affiliate code in the session when the user arrives on the site with the affiliate querystring param. + - Django's default session expiration period is 2 weeks. +- For account creation, the user verifies their email address and completes at least the first page of personal details. +- For order completion: + 1. The order is fully paid for via enrollment code, or the Cybersource transaction completes successfully; + 1. The user does not log out in the period between arriving on the site with the affiliate querystring param and + completing the order (logging out flushes the session, which will clear the affiliate code). diff --git a/affiliate/__init__.py b/affiliate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/affiliate/admin.py b/affiliate/admin.py new file mode 100644 index 000000000..613138d5b --- /dev/null +++ b/affiliate/admin.py @@ -0,0 +1,52 @@ +"""Admin classes for affiliate models""" + +from django.contrib import admin + +from affiliate import models +from mitxpro.admin import TimestampedModelAdmin + + +class AffiliateAdmin(TimestampedModelAdmin): + """Admin for Affiliate""" + + model = models.Affiliate + list_display = ["id", "code", "name"] + + +class AffiliateReferralActionAdmin(TimestampedModelAdmin): + """Admin for AffiliateReferralAction""" + + model = models.AffiliateReferralAction + include_created_on_in_list = True + list_display = [ + "id", + "get_affiliate_name", + "get_affiliate_code", + "created_user_id", + "created_order_id", + ] + raw_id_fields = ["affiliate", "created_user", "created_order"] + list_filter = ["affiliate__name"] + ordering = ["-created_on"] + + def get_queryset(self, request): + """Overrides base method""" + return self.model.objects.select_related("affiliate") + + def get_affiliate_name(self, obj): + """Returns the related Affiliate name""" + return obj.affiliate.name + + get_affiliate_name.short_description = "Affiliate Name" + get_affiliate_name.admin_order_field = "affiliate__name" + + def get_affiliate_code(self, obj): + """Returns the related Affiliate code""" + return obj.affiliate.code + + get_affiliate_name.short_description = "Affiliate Code" + get_affiliate_name.admin_order_field = "affiliate__code" + + +admin.site.register(models.Affiliate, AffiliateAdmin) +admin.site.register(models.AffiliateReferralAction, AffiliateReferralActionAdmin) diff --git a/affiliate/api.py b/affiliate/api.py new file mode 100644 index 000000000..ee5d1de09 --- /dev/null +++ b/affiliate/api.py @@ -0,0 +1,66 @@ +"""Affiliate tracking logic""" +from affiliate.constants import AFFILIATE_QS_PARAM +from affiliate.models import Affiliate +from mitxpro.utils import first_or_none + + +def get_affiliate_code_from_qstring(request): + """ + Gets the affiliate code from the querystring if one exists + + Args: + request (django.http.request.HttpRequest): A request + + Returns: + Optional[str]: The affiliate code (or None) + """ + if request.method != "GET": + return None + affiliate_code = request.GET.get(AFFILIATE_QS_PARAM) + return affiliate_code + + +def get_affiliate_code_from_request(request): + """ + Helper method that gets the affiliate code from a request object if it exists + + Args: + request (django.http.request.HttpRequest): A request + + Returns: + Optional[str]: The affiliate code (or None) + """ + return getattr(request, "affiliate_code", None) + + +def get_affiliate_id_from_code(affiliate_code): + """ + Helper method that fetches the Affiliate id from the database that matches the affiliate code + + Args: + affiliate_code (str): The affiliate code + + Returns: + Optional[Affiliate]: The id of the Affiliate that matches the given code (if it exists) + """ + return first_or_none( + Affiliate.objects.filter(code=affiliate_code).values_list("id", flat=True) + ) + + +def get_affiliate_id_from_request(request): + """ + Helper method that fetches the Affiliate id from the database that matches the affiliate code from the request + + Args: + request (django.http.request.HttpRequest): A request + + Returns: + Optional[Affiliate]: The Affiliate object that matches the affiliate code in the request (or None) + """ + affiliate_code = get_affiliate_code_from_request(request) + return ( + get_affiliate_id_from_code(affiliate_code) + if affiliate_code is not None + else None + ) diff --git a/affiliate/api_test.py b/affiliate/api_test.py new file mode 100644 index 000000000..326349f20 --- /dev/null +++ b/affiliate/api_test.py @@ -0,0 +1,70 @@ +"""Affiliate tracking logic""" +import pytest +from django.test.client import RequestFactory + +from affiliate.api import ( + get_affiliate_code_from_qstring, + get_affiliate_code_from_request, + get_affiliate_id_from_code, + get_affiliate_id_from_request, +) +from affiliate.constants import AFFILIATE_QS_PARAM +from affiliate.factories import AffiliateFactory + + +def test_get_affiliate_code_from_qstring(): + """ + get_affiliate_code_from_qstring should get the affiliate code from the querystring + """ + affiliate_code = "abc" + request = RequestFactory().post(f"/?{AFFILIATE_QS_PARAM}={affiliate_code}", data={}) + code = get_affiliate_code_from_qstring(request) + assert code is None + request = RequestFactory().get("/") + code = get_affiliate_code_from_qstring(request) + assert code is None + request = RequestFactory().get(f"/?{AFFILIATE_QS_PARAM}={affiliate_code}") + code = get_affiliate_code_from_qstring(request) + assert code == affiliate_code + + +def test_get_affiliate_code_from_request(): + """ + get_affiliate_code_from_request should get the affiliate code from a request object + """ + affiliate_code = "abc" + request = RequestFactory().get("/") + code = get_affiliate_code_from_request(request) + assert code is None + setattr(request, "affiliate_code", affiliate_code) + code = get_affiliate_code_from_request(request) + assert code == affiliate_code + + +@pytest.mark.django_db +def test_get_affiliate_id_from_code(): + """ + get_affiliate_id_from_code should fetch the Affiliate id from the database that matches the affiliate code + """ + affiliate_code = "abc" + affiliate_id = get_affiliate_id_from_code(affiliate_code) + assert affiliate_id is None + affiliate = AffiliateFactory.create(code=affiliate_code) + affiliate_id = get_affiliate_id_from_code(affiliate_code) + assert affiliate_id == affiliate.id + + +@pytest.mark.django_db +def test_get_affiliate_id_from_request(): + """ + get_affiliate_id_from_request should fetch the Affiliate id from the database that matches the + affiliate code from the request + """ + affiliate_code = "abc" + request = RequestFactory().get(f"/") + setattr(request, "affiliate_code", affiliate_code) + affiliate_id = get_affiliate_id_from_request(request) + assert affiliate_id is None + affiliate = AffiliateFactory.create(code=affiliate_code) + affiliate_id = get_affiliate_id_from_request(request) + assert affiliate_id == affiliate.id diff --git a/affiliate/apps.py b/affiliate/apps.py new file mode 100644 index 000000000..18eeff1ac --- /dev/null +++ b/affiliate/apps.py @@ -0,0 +1,8 @@ +"""Apps config for affiliate tracking""" +from django.apps import AppConfig + + +class AffiliateConfig(AppConfig): + """Affiliate AppConfig""" + + name = "affiliate" diff --git a/affiliate/constants.py b/affiliate/constants.py new file mode 100644 index 000000000..52ca71e5b --- /dev/null +++ b/affiliate/constants.py @@ -0,0 +1,4 @@ +"""Affiliate app constants""" + +AFFILIATE_QS_PARAM = "aid" +AFFILIATE_CODE_SESSION_KEY = "affiliate_code" diff --git a/affiliate/factories.py b/affiliate/factories.py new file mode 100644 index 000000000..3edd60728 --- /dev/null +++ b/affiliate/factories.py @@ -0,0 +1,30 @@ +"""Affiliate app factories""" + +import factory +from factory import fuzzy +from factory.django import DjangoModelFactory + +from affiliate import models +from ecommerce.factories import OrderFactory +from users.factories import UserFactory + + +class AffiliateFactory(DjangoModelFactory): + """Factory for Affiliate""" + + code = factory.Sequence("affiliate-code-{0}".format) + name = fuzzy.FuzzyText(prefix="Affiliate ", length=30) + + class Meta: + model = models.Affiliate + + +class AffiliateReferralActionFactory(DjangoModelFactory): + """Factory for AffiliateReferralAction""" + + affiliate = factory.SubFactory(AffiliateFactory) + created_user = factory.SubFactory(UserFactory) + created_order = factory.SubFactory(OrderFactory) + + class Meta: + model = models.AffiliateReferralAction diff --git a/affiliate/middleware.py b/affiliate/middleware.py new file mode 100644 index 000000000..9c413a4d6 --- /dev/null +++ b/affiliate/middleware.py @@ -0,0 +1,21 @@ +"""Middleware for affiliate tracking""" +from affiliate.api import get_affiliate_code_from_qstring +from affiliate.constants import AFFILIATE_CODE_SESSION_KEY + + +class AffiliateMiddleware: + """Middleware that adds an affiliate code to the request object if one is found in the querystring or the session""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request.affiliate_code = None + session = getattr(request, "session") + if session is None: + return self.get_response(request) + qs_affiliate_code = get_affiliate_code_from_qstring(request) + if qs_affiliate_code is not None: + session[AFFILIATE_CODE_SESSION_KEY] = qs_affiliate_code + request.affiliate_code = session.get(AFFILIATE_CODE_SESSION_KEY) + return self.get_response(request) diff --git a/affiliate/middleware_test.py b/affiliate/middleware_test.py new file mode 100644 index 000000000..d4c0df0b6 --- /dev/null +++ b/affiliate/middleware_test.py @@ -0,0 +1,39 @@ +"""Affiliate middleware tests""" +from django.contrib.sessions.middleware import SessionMiddleware +from django.test.client import RequestFactory + +from affiliate.constants import AFFILIATE_QS_PARAM +from affiliate.middleware import AffiliateMiddleware + + +def test_affiliate_middleware(mocker): + """ + AffiliateMiddleware should add the affiliate code to the session if a code was passed in the querystring, + and add an attribute to the request object + """ + affiliate_code = "abc" + request = RequestFactory().get(f"/?{AFFILIATE_QS_PARAM}={affiliate_code}") + + # Add session capability to the request + SessionMiddleware().process_request(request) + request.session.save() + + middleware = AffiliateMiddleware(get_response=mocker.Mock()) + middleware(request) + assert request.affiliate_code == affiliate_code + assert request.session["affiliate_code"] == affiliate_code + + +def test_affiliate_middleware_session(mocker): + """AffiliateMiddleware should add add an attribute to the request object if a code exists in the session""" + affiliate_code = "abc" + request = RequestFactory().get("/") + + # Add session capability to the request and add the affiliate code to the session + SessionMiddleware().process_request(request) + request.session["affiliate_code"] = affiliate_code + request.session.save() + + middleware = AffiliateMiddleware(get_response=mocker.Mock()) + middleware(request) + assert request.affiliate_code == affiliate_code diff --git a/affiliate/migrations/0001_affiliate_initial_models.py b/affiliate/migrations/0001_affiliate_initial_models.py new file mode 100644 index 000000000..1784a6c42 --- /dev/null +++ b/affiliate/migrations/0001_affiliate_initial_models.py @@ -0,0 +1,82 @@ +# Generated by Django 2.2.10 on 2020-10-22 16:42 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("ecommerce", "0030_linerunselection"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Affiliate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("code", models.CharField(db_index=True, max_length=20, unique=True)), + ("name", models.CharField(max_length=50, unique=True)), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="AffiliateReferralAction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "affiliate", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="affiliate_referral_actions", + to="affiliate.Affiliate", + ), + ), + ( + "created_order", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="affiliate_order_actions", + to="ecommerce.Order", + ), + ), + ( + "created_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="affiliate_user_actions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"abstract": False}, + ), + ] diff --git a/affiliate/migrations/__init__.py b/affiliate/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/affiliate/models.py b/affiliate/models.py new file mode 100644 index 000000000..a2ca63903 --- /dev/null +++ b/affiliate/models.py @@ -0,0 +1,44 @@ +"""Model definitions for affiliate tracking""" +from django.conf import settings +from django.db import models + +from mitxpro.models import TimestampedModel + + +class Affiliate(TimestampedModel): + """Model that represents an affiliate""" + + code = models.CharField(max_length=20, db_index=True, unique=True) + name = models.CharField(max_length=50, unique=True) + + def __str__(self): + return "Affiliate: id={}, code={}, name={}".format( + self.id, self.code, self.name + ) + + +class AffiliateReferralAction(TimestampedModel): + """Model that represents some action that was taken by a user""" + + affiliate = models.ForeignKey( + Affiliate, on_delete=models.CASCADE, related_name="affiliate_referral_actions" + ) + created_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="affiliate_user_actions", + null=True, + blank=True, + ) + created_order = models.ForeignKey( + "ecommerce.Order", + on_delete=models.CASCADE, + related_name="affiliate_order_actions", + null=True, + blank=True, + ) + + def __str__(self): + return "AffiliateReferralAction: code={}, created_user_id={}, created_order_id={}".format( + self.affiliate.code, self.created_user_id, self.created_order_id + ) diff --git a/authentication/pipeline/user.py b/authentication/pipeline/user.py index f78b8a8f1..e150a1eff 100644 --- a/authentication/pipeline/user.py +++ b/authentication/pipeline/user.py @@ -9,6 +9,7 @@ from django.conf import settings from django.db import IntegrityError +from affiliate.api import get_affiliate_id_from_request from authentication.exceptions import ( InvalidPasswordException, RequirePasswordException, @@ -93,6 +94,7 @@ def create_user_via_email( if user is not None: raise UnexpectedExistingUserException(backend, current_partial) + context = {} data = strategy.request_data().copy() if "name" not in data or "password" not in data: raise RequirePasswordAndPersonalInfoException(backend, current_partial) @@ -106,7 +108,12 @@ def create_user_via_email( data["email"] = kwargs.get("email", kwargs.get("details", {}).get("email")) username = usernameify(data["name"], email=data["email"]) data["username"] = username - serializer = UserSerializer(data=data) + + affiliate_id = get_affiliate_id_from_request(strategy.request) + if affiliate_id is not None: + context["affiliate_id"] = affiliate_id + + serializer = UserSerializer(data=data, context=context) if not serializer.is_valid(): raise RequirePasswordAndPersonalInfoException( diff --git a/authentication/pipeline/user_test.py b/authentication/pipeline/user_test.py index d5c80e201..833ab9095 100644 --- a/authentication/pipeline/user_test.py +++ b/authentication/pipeline/user_test.py @@ -6,6 +6,7 @@ from social_core.backends.email import EmailAuth from social_django.utils import load_strategy, load_backend +from affiliate.factories import AffiliateFactory from users.factories import UserFactory from authentication.pipeline import user as user_actions from authentication.exceptions import ( @@ -43,6 +44,7 @@ def mock_email_backend(mocker, backend_settings): def mock_create_user_strategy(mocker): """Fixture that returns a valid strategy for create_user_via_email""" strategy = mocker.Mock() + strategy.request = mocker.Mock(affiliate_code=None) strategy.request_data.return_value = { "name": "Jane Doe", "password": "password1", @@ -381,6 +383,35 @@ def test_create_user_via_email_create_fail( patched_create_user.assert_called_once() +@pytest.mark.django_db +def test_create_user_via_email_affiliate( + mocker, mock_create_user_strategy, mock_email_backend +): + """ + create_user_via_email passes an affiliate id into the user serializer if the affiliate code exists + on the request object + """ + affiliate = AffiliateFactory.create() + mock_create_user_strategy.request.affiliate_code = affiliate.code + patched_create_user = mocker.patch( + "authentication.pipeline.user.create_user_with_generated_username", + return_value=UserFactory.build(), + ) + user_actions.create_user_via_email( + mock_create_user_strategy, + mock_email_backend, + details=dict(email="someuser@example.com"), + pipeline_index=0, + flow=SocialAuthState.FLOW_REGISTER, + ) + patched_create_user.assert_called_once() + # Confirm that a UserSerializer object was passed to create_user_with_generated_username, and + # that it was instantiated with the affiliate id in the context. + serializer = patched_create_user.call_args_list[0][0][0] + assert "affiliate_id" in serializer.context + assert serializer.context["affiliate_id"] == affiliate.id + + @pytest.mark.django_db @pytest.mark.parametrize("hubspot_key", [None, "fake-key"]) def test_create_profile( diff --git a/ecommerce/api.py b/ecommerce/api.py index e0d1ba0ee..14cc9d3f9 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -18,6 +18,7 @@ from django.urls import reverse from rest_framework.exceptions import ValidationError +from affiliate.models import AffiliateReferralAction from courses.api import create_run_enrollments, create_program_enrollments from courses.constants import ( CONTENT_TYPE_MODEL_PROGRAM, @@ -637,13 +638,14 @@ def get_order_programs(order): ] -def create_unfulfilled_order(validated_basket): +def create_unfulfilled_order(validated_basket, affiliate_id=None): """ Create a new Order which is not fulfilled for a purchasable Product. Note that validation should be done in the basket REST API so the validation is not done here (different from MicroMasters). Args: validated_basket (ValidatedBasket): The validated Basket and related objects + affiliate_id (Optional[int]): The id of the Affiliate record to associate with this order Returns: Order: A newly created Order for the Product in the basket @@ -674,6 +676,10 @@ def create_unfulfilled_order(validated_basket): ) if validated_basket.coupon_version: redeem_coupon(coupon_version=validated_basket.coupon_version, order=order) + if affiliate_id is not None: + AffiliateReferralAction.objects.create( + affiliate_id=affiliate_id, created_order=order + ) sync_hubspot_deal(order) return order diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py index e0f101373..61197faa4 100644 --- a/ecommerce/api_test.py +++ b/ecommerce/api_test.py @@ -15,6 +15,7 @@ from rest_framework.exceptions import ValidationError import pytest +from affiliate.factories import AffiliateFactory from courses.models import CourseRunEnrollment, ProgramEnrollment, CourseRun, Program from courses.factories import ( CourseFactory, @@ -664,6 +665,17 @@ def test_create_unfulfilled_order_program_run(validated_basket, has_program_run) line.programrunline # pylint: disable=pointless-statement +def test_create_unfulfilled_order_affiliate(validated_basket): + """ + create_unfulfilled_order should add a database record tracking the order creation if an affiliate id is passed in + """ + affiliate = AffiliateFactory.create() + order = create_unfulfilled_order(validated_basket, affiliate_id=affiliate.id) + affiliate_referral_action = order.affiliate_order_actions.first() + assert affiliate_referral_action.affiliate == affiliate + assert affiliate_referral_action.created_order == order + + def test_get_product_courses(): """ Verify that the correct list of courses for a product is returned diff --git a/ecommerce/models.py b/ecommerce/models.py index 34d89fbbf..d7f7672eb 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -20,7 +20,6 @@ PrefetchGenericQuerySet, ) from mitxpro.utils import serialize_model_object, first_or_none -from users.models import User from mail.constants import MAILGUN_EVENT_CHOICES log = logging.getLogger() @@ -695,7 +694,7 @@ class DataConsentUser(TimestampedModel): User required to sign an agreement, and the signing date if any. """ - user = models.ForeignKey(User, on_delete=models.PROTECT) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) agreement = models.ForeignKey(DataConsentAgreement, on_delete=models.PROTECT) coupon = models.ForeignKey(Coupon, on_delete=models.PROTECT) consent_date = models.DateTimeField(null=True) diff --git a/ecommerce/views.py b/ecommerce/views.py index 3273cfd8f..f243f027d 100644 --- a/ecommerce/views.py +++ b/ecommerce/views.py @@ -22,6 +22,7 @@ from rest_framework.views import APIView from rest_framework.viewsets import ReadOnlyModelViewSet +from affiliate.api import get_affiliate_id_from_request from b2b_ecommerce.api import fulfill_b2b_order from b2b_ecommerce.models import B2BOrder from courses.models import CourseRun, ProgramRun, Program, Course @@ -173,7 +174,8 @@ def post(self, request, *args, **kwargs): # pylint: disable=too-many-locals and return information used to submit to CyberSource. """ validated_basket = validate_basket_for_checkout(request.user) - order = create_unfulfilled_order(validated_basket) + affiliate_id = get_affiliate_id_from_request(request) + order = create_unfulfilled_order(validated_basket, affiliate_id=affiliate_id) base_url = request.build_absolute_uri("/") text_id = validated_basket.product_version.product.content_object.text_id receipt_url = make_receipt_url(base_url=base_url, readable_id=text_id) diff --git a/ecommerce/views_test.py b/ecommerce/views_test.py index b5f7c32be..1fe66c598 100644 --- a/ecommerce/views_test.py +++ b/ecommerce/views_test.py @@ -15,6 +15,8 @@ from rest_framework.test import APIClient import factory +from affiliate.constants import AFFILIATE_QS_PARAM +from affiliate.factories import AffiliateFactory from courses.factories import ( CourseRunFactory, CourseRunEnrollmentFactory, @@ -300,6 +302,31 @@ def test_order_fulfilled( assert mock_hubspot_syncs.order.not_called() +def test_order_affiliate(basket_client, mocker, basket_and_coupons): + """ + The order view should pass an affiliate id into the order creation API function if an affiliate id exists + in the session. + """ + user = basket_and_coupons.basket_item.basket.user + line = LineFactory.create( + order__status=Order.CREATED, + order__purchaser=user, + order__total_price_paid=0, + product_version=basket_and_coupons.product_version, + ) + order = line.order + mocker.patch("ecommerce.api.enroll_user_in_order_items", autospec=True) + create_order_mock = mocker.patch( + "ecommerce.views.create_unfulfilled_order", autospec=True, return_value=order + ) + affiliate = AffiliateFactory.create() + # Make an initial request to get the affiliate code in the session + basket_client.get(f"/?{AFFILIATE_QS_PARAM}={affiliate.code}") + resp = basket_client.post(reverse("checkout")) + assert resp.status_code == status.HTTP_200_OK + assert create_order_mock.call_args_list[0][1] == dict(affiliate_id=affiliate.id) + + def test_missing_fields(basket_client, mocker): """ If CyberSource POSTs with fields missing, we should at least save it in a receipt. diff --git a/mail/verification_api.py b/mail/verification_api.py index 3c35d1652..c12171f8c 100644 --- a/mail/verification_api.py +++ b/mail/verification_api.py @@ -3,6 +3,8 @@ from django.urls import reverse +from affiliate.api import get_affiliate_code_from_request +from affiliate.constants import AFFILIATE_QS_PARAM from mail import api from mail.constants import EMAIL_VERIFICATION, EMAIL_CHANGE_EMAIL @@ -25,6 +27,10 @@ def send_verification_email( quote_plus(partial_token), ) + affiliate_code = get_affiliate_code_from_request(strategy.request) + if affiliate_code is not None: + url = f"{url}&{AFFILIATE_QS_PARAM}={affiliate_code}" + api.send_message( api.message_for_recipient( code.email, diff --git a/mail/verification_api_test.py b/mail/verification_api_test.py index d1ffc9c3e..05e6bec89 100644 --- a/mail/verification_api_test.py +++ b/mail/verification_api_test.py @@ -6,7 +6,10 @@ from django.contrib.sessions.middleware import SessionMiddleware from django.shortcuts import reverse from django.test.client import RequestFactory +from social_core.backends.email import EmailAuth +from social_django.utils import load_backend, load_strategy +from affiliate.constants import AFFILIATE_QS_PARAM from mail import verification_api from mitxpro.test_utils import any_instance_of from users.models import ChangeEmailRequest @@ -16,13 +19,10 @@ def test_send_verification_email(mocker, rf): """Test that send_verification_email sends an email with the link in it""" - from social_core.backends.email import EmailAuth - from social_django.utils import load_backend, load_strategy - send_messages_mock = mocker.patch("mail.api.send_messages") email = "test@localhost" request = rf.post(reverse("social:complete", args=("email",)), {"email": email}) - # social_django depends on request.sesssion, so use the middleware to set that + # social_django depends on request.session, so use the middleware to set that SessionMiddleware().process_request(request) strategy = load_strategy(request) backend = load_backend(strategy, EmailAuth.name, None) @@ -37,6 +37,32 @@ def test_send_verification_email(mocker, rf): ) +def test_send_verification_email_affiliate(mocker, rf): + """ + send_verification_email should send a verification link with an affiliate code in the URL if there is an + affiliate code attached to the request + """ + send_messages_mock = mocker.patch("mail.api.send_messages") + code = mocker.Mock(code="abc") + request = rf.post( + reverse("social:complete", args=("email",)), {"email": "test@example.com"} + ) + # social_django depends on request.session, so use the middleware to set that + SessionMiddleware().process_request(request) + strategy = load_strategy(request) + backend = load_backend(strategy, EmailAuth.name, None) + + affiliate_code = "affiliate-123" + request.affiliate_code = affiliate_code + verification_api.send_verification_email(strategy, backend, code, "def") + + email_body = send_messages_mock.call_args[0][0][0].body + assert ( + f"/create-account/confirm/?verification_code=abc&partial_token=def&{AFFILIATE_QS_PARAM}={affiliate_code}" + in email_body + ) + + def test_send_verify_email_change_email(mocker, user): """Test email change request verification email sends with a link in it""" request = RequestFactory().get(reverse("account-settings")) diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 18417a3e5..d5128763d 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -189,6 +189,7 @@ "compliance", "courseware", "sheets", + "affiliate", # must be after "users" to pick up custom user model "compat", "hijack", @@ -212,6 +213,7 @@ MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "affiliate.middleware.AffiliateMiddleware", "oauth2_provider.middleware.OAuth2TokenMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", diff --git a/static/js/containers/pages/register/RegisterConfirmPage.js b/static/js/containers/pages/register/RegisterConfirmPage.js index c659a4222..967241834 100644 --- a/static/js/containers/pages/register/RegisterConfirmPage.js +++ b/static/js/containers/pages/register/RegisterConfirmPage.js @@ -23,7 +23,8 @@ import { import { authSelector } from "../../../lib/queries/auth" import { qsVerificationCodeSelector, - qsPartialTokenSelector + qsPartialTokenSelector, + qsSelector } from "../../../lib/selectors" import type { RouterHistory, Location } from "react-router" @@ -86,18 +87,12 @@ export class RegisterConfirmPage extends React.Component { } const mapStateToProps = createStructuredSelector({ - auth: authSelector, - params: createStructuredSelector({ - verificationCode: qsVerificationCodeSelector, - partialToken: qsPartialTokenSelector - }) + auth: authSelector, + qsParams: qsSelector }) -const registerConfirmEmail = (code: string, partialToken: string) => - mutateAsync(queries.auth.registerConfirmEmailMutation(code, partialToken)) - -const mapPropsToConfig = ({ params: { verificationCode, partialToken } }) => - registerConfirmEmail(verificationCode, partialToken) +const mapPropsToConfig = ({ qsParams }) => + mutateAsync(queries.auth.registerConfirmEmailMutation(qsParams)) const mapDispatchToProps = { addUserNotification diff --git a/static/js/lib/queries/auth.js b/static/js/lib/queries/auth.js index d80f8928c..96af72035 100644 --- a/static/js/lib/queries/auth.js +++ b/static/js/lib/queries/auth.js @@ -53,13 +53,12 @@ export default { body: { email, recaptcha, next, flow: FLOW_REGISTER } }), - registerConfirmEmailMutation: (code: string, partialToken: string) => ({ + registerConfirmEmailMutation: (qsParams: Object) => ({ ...DEFAULT_OPTIONS, url: "/api/register/confirm/", body: { - verification_code: code, - partial_token: partialToken, - flow: FLOW_REGISTER + flow: FLOW_REGISTER, + ...qsParams } }), diff --git a/users/models.py b/users/models.py index d60b4f89f..3a915f80e 100644 --- a/users/models.py +++ b/users/models.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext_lazy as _ import pycountry +from affiliate.models import AffiliateReferralAction from mitxpro.models import TimestampedModel from mitxpro.utils import now_in_utc @@ -63,7 +64,7 @@ ) -def _post_create_user(user): +def _post_create_user(user, affiliate_id=None): """ Create records related to the user @@ -72,6 +73,10 @@ def _post_create_user(user): """ LegalAddress.objects.create(user=user) Profile.objects.create(user=user) + if affiliate_id is not None: + AffiliateReferralAction.objects.create( + affiliate_id=affiliate_id, created_user=user + ) class UserManager(BaseUserManager): @@ -86,11 +91,11 @@ def _create_user(self, username, email, password, **extra_fields): fields = {**extra_fields, "email": email} if username is not None: fields["username"] = username - + affiliate_id = fields.pop("affiliate_id", None) user = self.model(**fields) user.set_password(password) user.save(using=self._db) - _post_create_user(user) + _post_create_user(user, affiliate_id=affiliate_id) return user def create_user(self, username, email=None, password=None, **extra_fields): diff --git a/users/models_test.py b/users/models_test.py index 0ff9d0873..49f35023c 100644 --- a/users/models_test.py +++ b/users/models_test.py @@ -5,6 +5,7 @@ from django.db import transaction import pytest +from affiliate.factories import AffiliateFactory from courseware.factories import OpenEdxApiAuthFactory, CoursewareUserFactory from users.factories import UserFactory from users.models import LegalAddress, User @@ -43,6 +44,23 @@ def test_create_user( assert LegalAddress.objects.filter(user=user).exists() +def test_create_user_affiliate(): + """create_user should create a new affiliate tracking record if an affiliate id was passed in the kwargs""" + affiliate = AffiliateFactory.create() + with transaction.atomic(): + user = User.objects.create_user( + "username1", + email="a@b.com", + name="Jane Doe", + password="asdfghjkl1", + affiliate_id=affiliate.id, + ) + affiliate_referral_action = user.affiliate_user_actions.first() + assert affiliate_referral_action is not None + assert affiliate_referral_action.affiliate == affiliate + assert affiliate_referral_action.created_user == user + + @pytest.mark.parametrize( "kwargs", [ diff --git a/users/serializers.py b/users/serializers.py index c10e18b99..373d111b1 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -250,7 +250,11 @@ def create(self, validated_data): with transaction.atomic(): user = User.objects.create_user( - username, email=email, password=password, **validated_data + username, + email=email, + password=password, + affiliate_id=self.context.get("affiliate_id", None), + **validated_data, ) # this side-effects such that user.legal_address and user.profile are updated in-place diff --git a/users/serializers_test.py b/users/serializers_test.py index ed74107bc..f1f0ec07d 100644 --- a/users/serializers_test.py +++ b/users/serializers_test.py @@ -3,6 +3,7 @@ from rest_framework.exceptions import ValidationError +from affiliate.factories import AffiliateFactory from users.factories import UserFactory from users.models import ChangeEmailRequest from users.serializers import ChangeEmailRequestUpdateSerializer @@ -180,6 +181,27 @@ def test_create_user_serializer( mock_user_sync.assert_not_called() +@pytest.mark.django_db +def test_create_user_serializer_affiliate(sample_address): + """UserSerializer should create a new affiliate tracking record if an affiliate id was passed in the context""" + affiliate = AffiliateFactory.create() + serializer = UserSerializer( + data={ + "username": "fakename", + "email": "fake@fake.edu", + "password": "fake", + "legal_address": sample_address, + }, + context={"affiliate_id": affiliate.id}, + ) + assert serializer.is_valid() + user = serializer.save() + affiliate_referral_action = user.affiliate_user_actions.first() + assert affiliate_referral_action is not None + assert affiliate_referral_action.affiliate == affiliate + assert affiliate_referral_action.created_user == user + + def test_update_email_change_request_existing_email(user): """Test that update change email request gives validation error for existing user email""" new_user = UserFactory.create()