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
Out of the box, Django Oscar implements only "site" and "voucher" types, the latter ones are just reserved and not used. We will take them further and review birthday discount implementation in this blog post.

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.