Non-standard discounts in the Django project: Birthday discount
Discount system has very important place in the ecommerce project life cycle — it helps to retain existing clients, as well as acquire new ones. So, this time we continue to develop this topic further, but unlike the previous time when we showed how to introduce new offer type for the flash sales, we will be looking into customer loyalty program and using pre-defined discount types in the Django-Oscar. So to speak, Django-Oscar has 4 types of discounts in the conditional offer model:
- site
- voucher
- user
- session
Let's start sketching out our implementation from adding date_of_birth
field to the user model.
Most of the logic will sit in the custom condition class, which simply checks if user celebrates birthday day:
from django.conf import settings
from django.utils.timezone import now
from oscar.core.loading import get_model
Condition = get_model('offer', 'Condition')
class BirthdayCondition(Condition):
_description = "User has birthday today."
class Meta:
proxy = True
@property
def name(self):
return self._description
@property
def description(self):
return self._description
def is_satisfied(self, offer, basket, request=None):
if not basket.owner:
return False
current_date = now().date()
dob = basket.owner.date_of_birth
if dob:
return current_date.day == dob.day and current_date.month == dob.month
Also we'd check if the particular condition can be used with the selected offer, which is why we implemented check_compatibility
method.
In this case, we prohibit birthday condition usage with the offer, other then of the user type. If validation does not pass, we raise ConditionIncompatible
exception accordingly.
from django.conf import settings
class ConditionIncompatible(Exception):
pass
class BirthdayCondition(Condition):
def check_compatibility(self, offer):
if offer.offer_type != ConditionalOffer.USER:
raise ConditionIncompatible(
"Birthday condition could be used only with the user type offer."
)
Since custom conditions are implemented in the Oscar as proxy models, we need add method check_compatibility
to the Condition
model, load proxy model in it and call given method on its instance.
class Condition(AbstractCondition):
def is_satisfied(self, offer, basket, request=None):
return super().is_satisfied(offer=offer, basket=basket)
def check_compatibility(self, offer):
proxy_instance = self.proxy()
check_compatibility = getattr(proxy_instance, "check_compatibility", None)
if check_compatibility is not None and callable(check_compatibility):
proxy_instance.check_compatibility(offer)
In order to perform this validation, we need to tweak condition dashboard form slightly, but since the form does not have offer instance in the context, we'd fix this as well.
Basically, in the dashboard we can edit offer through the step-by-step wizard, which is set of Django views, where offer object is passed through the request session, so we only need to extract the latter from session and pass to the form:
from oscar.core.loading import get_class
CoreOfferConditionView = get_class("dashboard.offers.views", "OfferConditionView")
class OfferConditionView(CoreOfferConditionView):
def get_form_kwargs(self, *args, **kwargs):
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["session_offer"] = self._fetch_session_offer()
return form_kwargs
Now we can customize our dashboard form and incorporate compatibility validation:
from django import forms
from django.utils.translation import gettext_lazy as _
from oscar.core.loading import get_class, get_model
from apps.offer.models import ConditionIncompatible
Condition = get_model('offer', 'Condition')
CoreConditionForm = get_class('dashboard.offers.forms', 'ConditionForm')
class ConditionForm(CoreConditionForm):
def __init__(self, *args, **kwargs):
self.session_offer = kwargs.pop("session_offer", None)
super().__init__(*args, **kwargs)
def clean(self):
data = super().clean()
custom_condition = data.get("custom_condition", None)
if custom_condition:
condition = Condition.objects.get(id=custom_condition)
try:
condition.check_compatibility(offer=self.session_offer)
except ConditionIncompatible as e:
raise forms.ValidationError(e)
return data
Custom condition won't be available for the selection in the dashboard form by default, so we need to manually propagate it:
from oscar.apps.offer.custom import create_condition
from apps.offer.conditions import BirthdayCondition
create_condition(BirthdayCondition)
The last part would be to introduce user offers aggregation to the offer applicator class:
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 get_user_offers(self, user):
qs = ConditionalOffer.active.filter(offer_type=ConditionalOffer.USER)
return qs.select_related('condition', 'benefit')
Finally, after we create an offer with the newly introduced custom condition BirthdayCondition
, it will
be applied automatically if the custom celebrates birthday today (and thus it satisfies the condition):

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