Non-standard discounts in the Django project: 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
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
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
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
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.