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.