Skip to content

Commit

Permalink
Added support for affiliate links
Browse files Browse the repository at this point in the history
  • Loading branch information
gsidebo committed Oct 27, 2020
1 parent 908af43 commit 18cfc46
Show file tree
Hide file tree
Showing 29 changed files with 636 additions and 28 deletions.
31 changes: 31 additions & 0 deletions affiliate/README.md
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 added affiliate/__init__.py
Empty file.
52 changes: 52 additions & 0 deletions affiliate/admin.py
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)
66 changes: 66 additions & 0 deletions affiliate/api.py
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
)
70 changes: 70 additions & 0 deletions affiliate/api_test.py
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
8 changes: 8 additions & 0 deletions affiliate/apps.py
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"
4 changes: 4 additions & 0 deletions affiliate/constants.py
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"
30 changes: 30 additions & 0 deletions affiliate/factories.py
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
21 changes: 21 additions & 0 deletions affiliate/middleware.py
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)
39 changes: 39 additions & 0 deletions affiliate/middleware_test.py
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
Loading

0 comments on commit 18cfc46

Please sign in to comment.