Non-standard discounts in the Django project: Referral program

Today we continue to look into the details of the discount system, in the previous chapters we reviewed Django flash sales and birthday discounts and this time we will implement referral program.

In general, referral program's goal is to increase loyalty of existing customers as well as to attract new ones. Let's see on example how to implement referral program which rewards both the users who recommends shop to another user (referrers) and the ones who register and buy using referral links (referees).

First of all, let's add couple of fields to the user model, to track referral code usages and store referral code itself:

from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    is_referral_code_used = models.BooleanField(default=False)
    referral_code = models.CharField(null=True, blank=True, max_length=10, unique=True)
    referrer = models.ForeignKey('User', null=True, blank=True, related_name='referees', on_delete=models.SET_NULL)

Automatically generating referral code on user model creation:

from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils.crypto import get_random_string

from .models import User


@receiver(pre_save, sender=User)
def generate_referral_code(instance, **kwargs):
    if not instance.referral_code:
        instance.referral_code = get_random_string(8)

Next we will integrate simple referral code application into the Oscar user registration view, where we will validate it and consequently store in the session and will keep it there until the customer places the first order. Firstly we'd check if current referral code exists, secondly — whether it wasn't used already and show error message otherwise.

class AccountRegistrationView(CoreAccountRegistrationView):
    referral_code = None

    def dispatch(self, request, *args, **kwargs):
        referral_code = request.GET.get('rc', None)
        if referral_code:
            if not User.objects.filter(referral_code=referral_code).exists():
                messages.error(request, "Referrer account not found.")
            elif User.objects.filter(referrer__referral_code=referral_code, is_referral_code_used=True):
                messages.error(request, "Referral code already used.")
            else:
                self.referral_code = referral_code
        return super().dispatch(request, *args, **kwargs)

    def form_valid(self, form):
        r = super().form_valid(form)
        if self.referral_code:
            self.request.session[settings.REFERRAL_SESSION_KEY] = self.referral_code
            messages.success(self.request, "Your referral code is valid, discount will be applied on checkout.")
        return r

Finally, we can customize order creator class and incorporate referral code usage tracking to the record_discount method, which

import logging

from django.conf import settings
from django.contrib.auth import get_user_model

from oscar.apps.order.utils import OrderCreator as OriginalOrderCreator
from oscar.core.loading import get_model


logger = logging.getLogger(__file__)
User = get_user_model()
Order = get_model('order', 'Order')


class OrderCreator(OriginalOrderCreator):
    user = None
    request = None

    def place_order(self, *args, **kwargs):
        self.user = kwargs.get('user', None)
        self.request = kwargs.get('request', None)
        order = super().place_order(*args, **kwargs)

        referral_code = self.request.session.get(settings.REFERRAL_SESSION_KEY, None)

        if referral_code:
            try:
                referrer = User.objects.get(referral_code=referral_code)
            except User.DoesNotExist:
                referrer = None
                logger.error("Could not retrieve referrer for referral code '%s'", referrer)

            self.user.is_referral_code_used = True
            self.user.referrer = referrer
            self.user.save()

            del self.request.session[settings.REFERRAL_SESSION_KEY]

        return order

Now that we have referral codes application and usage tracking integrated into the checkout flow, we can go to the discount conditions and offer application adjustments.

Following initial requirements, we want give a reward for referrers for each purchase, made using referrer's referral link. We can assume that we create dedicated offer, let's say "Referrer discount", which would have ReferrerCondition, so number of its usages would be equal to number of rewards for recommending the shop. Since we track referrer code issuer in the user model, user.referrees.count() would give us number of user code usages, which should always be greater than particular discount offer usages.

from django.conf import settings

from oscar.core.loading import get_model

from apps.offer.models import ConditionIncompatible

Condition = get_model('offer', 'Condition')
ConditionalOffer = get_model('offer', 'ConditionalOffer')


class ReferrerCondition(Condition):
    """
    Custom condition, which allows referrer (referrer code provider) to
    qualify for the discount. Referrers get discount every time someone
    purchases using their referral codes.
    """

    _description = "Someone purchased using user's referral code."

    @property
    def name(self):
        return self._description

    @property
    def description(self):
        return self._description

    def is_satisfied(self, offer, basket, request=None):
        user = basket.owner
        if user:
            num_offer_applications = offer.get_num_user_applications(user)
            num_referral_code_applications = user.referees.count()
            return num_referral_code_applications > num_offer_applications

Referee condition is quite simple in general — we only want to check if referral code is in the session, which means it's valid already, since validation done within the code application. In order to evaluate this condition, we'll need to pass request object to the is_satisfied, which we will review in the next snippet. Also, similar as in the birthday discount condition, we want to use this condition class exclusively with session offers, that's why we implemented check_compatibility method.

from django.conf import settings

from oscar.core.loading import get_model

from apps.offer.models import ConditionIncompatible

Condition = get_model('offer', 'Condition')
ConditionalOffer = get_model('offer', 'ConditionalOffer')


class ReferralCodeCondition(Condition):
    """
    Custom condition, which allows referee (referral code applicant) to
    qualify for the discount.
    """

    _description = "User used referral code."

    @property
    def name(self):
        return self._description

    @property
    def description(self):
        return self._description

    def is_satisfied(self, offer, basket, request=None):
        if request.user.is_authenticated and request.user.is_referral_code_used:
            return False

        referral_code = request.session.get(settings.REFERRAL_SESSION_KEY, None)
        return referral_code is not None

    def check_compatibility(self, offer):
        if offer.offer_type != ConditionalOffer.SESSION:
            raise ConditionIncompatible(
                "Referral code condition could be used only with the session type offer."
    )

In order to propagate request object to the custom condition class, we'll need to pass them to the condition and offer models methods:

from oscar.apps.offer.abstract_models import AbstractCondition, AbstractConditionalOffer


class ConditionalOffer(AbstractConditionalOffer):
    def apply_benefit(self, basket, request=None):
        if not self.is_condition_satisfied(basket, request=request):
            return ZERO_DISCOUNT
        return self.benefit.proxy().apply(basket, self.condition.proxy(), self)

    def is_condition_satisfied(self, basket, request=None):
        return self.condition.proxy().is_satisfied(self, basket, request=request)


class Condition(AbstractCondition):
    def is_satisfied(self, offer, basket, request=None):
    return super().is_satisfied(offer=offer, basket=basket)

Last but not least, applicator class passes request object only to the get_offers method, but not to the apply_offers, which we aim to change. Out of the box, apply_offers passes only basket instance to the offer model in order to evaluate condition, which is fine for the initial setup, since all basket has all necessary data to perform check if discount is applicable (customer, selected products etc). But not in our case — we need to access request session and can't store referral code on the basket level, since user could potentially have multiple baskets, baskets could be merged etc.

from oscar.apps.offer.applicator import Applicator as CoreApplicator
from oscar.core.loading import get_class, get_model


ConditionalOffer = get_model('offer', 'ConditionalOffer')
OfferApplications = get_class('offer.results', 'OfferApplications')


class Applicator(CoreApplicator):
    def apply(self, basket, user=None, request=None):
        offers = self.get_offers(basket, user, request)
        self.apply_offers(basket, offers, request)

    def get_session_offers(self, request):
        qs = ConditionalOffer.active.filter(offer_type=ConditionalOffer.USER)
		return qs.select_related('condition', 'benefit')

    def apply_offers(self, basket, offers, request):
        applications = OfferApplications()
        for offer in offers:
            num_applications = 0
            while num_applications < offer.get_max_applications(basket.owner):
                result = offer.apply_benefit(basket, request=request)
                num_applications += 1
                if not result.is_successful:
                    break
                applications.add(offer, result)
                if result.is_final:
                    break

    basket.offer_applications = applications

Finally, referral offer will be applied, which will be indicated on basket summary page:

All project code available in the Github repository — https://github.com/metaclassco/django_oscar_non_standard_dicounts.