-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
29 changed files
with
636 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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=<affiliate-code>`). | ||
- 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). |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
"""Apps config for affiliate tracking""" | ||
from django.apps import AppConfig | ||
|
||
|
||
class AffiliateConfig(AppConfig): | ||
"""Affiliate AppConfig""" | ||
|
||
name = "affiliate" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
"""Affiliate app constants""" | ||
|
||
AFFILIATE_QS_PARAM = "aid" | ||
AFFILIATE_CODE_SESSION_KEY = "affiliate_code" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.