Flash sales in the Django ecommerce project

Flash sale (also often called "deal of the day") is a short-term sale of the specific product, usually for the period of 24 to 36 hours.

Product on the flash sale marked with a "sale" sticker and sometimes has a countdown timer, indicating time to the end of the deal. Great amount of the merchants perform flash sales at least once a year — on the well-known Black Friday.

Since we build absolute majority of the projects on top of the Django-Oscar, we used it for this show-case experiment as well.

Preliminarily, Oscar has extensive and flexible discounting system, which has next offer types:

  • site offers, available to all site users
  • voucher offers, which available only after discount voucher application
  • user offers, provides discount per user; for the instance, 15% discount on birthday
  • session offers, intended for user on the user session level, afte specific event triggered; suitable for providing affiliate program discounts

None of them satisfies our requirements, so that we added a new offer type:

class ConditionalOffer(AbstractConditionalOffer):
    SITE, FLASH_SALE, VOUCHER, USER, SESSION = "Site", "Flash Sale", "Voucher", "User", "Session"
    TYPE_CHOICES = (
        (SITE, _("Site offer - available to all users")),
        (FLASH_SALE, _("Flash Sale offer - short-term discount for the specific product")),
        (VOUCHER, _("Voucher offer - only available after entering the appropriate voucher code")),
        (USER, _("User offer - available to certain types of user")),
        (SESSION, _("Session offer - temporary offer, available for a user for the duration of their session")),
    )
    offer_type = models.CharField(_("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128)

The center of the discounting system is offer application, which contains various models, utils and Applicator class. The latter responsible in checking conditions and of course in applying discounts, which would be available on the Basket model instance as well. Hence, we added flash sale support to the applicator:

from itertools import chain

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


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


class Applicator(CoreApplicator):

    def get_sale_offers(self):
        qs = ConditionalOffer.active.filter(offer_type=ConditionalOffer.FLASH_SALE)
        return qs.select_related('condition', 'benefit')

    def get_offers(self, basket, user=None, request=None):
        site_offers = self.get_site_offers()
        basket_offers = self.get_basket_offers(basket, user)
        user_offers = self.get_user_offers(user)
        session_offers = self.get_session_offers(request)
        sale_offers = self.get_sale_offers()

        return list(
            sorted(chain(
                session_offers, basket_offers, user_offers, site_offers, sale_offers),
                key=lambda o: o.priority, reverse=True
            )
        )

In addition to existing benefit types we decided to introduce a subtype of absolute discount, which considers product quantity, thus each item in the basket, eligible for the discount, will have reduced price.

Currently, when basket eligible for the discount, absolute discount would reduce the whole basket by the value of the benefit. E.g. if you have in the basket 3 items for €50 and €20 voucher, the basket total after discounts would be: 50 × 3 - 20 = 130. Within the new benefit type, we aim to have next calculation: (50 - 20) × 3 = 90.

class CustomAbsoluteDiscountPerProductBenefit(AbsoluteDiscountBenefit):
    def apply(self, basket, condition, offer, discount_amount=None,
              max_total_discount=None, **kwargs):

        line_tuples = self.get_applicable_lines(offer, basket)
        max_affected_items = self._effective_max_affected_items()
        num_affected_items = 0
        lines_to_discount = []
        for price, line in line_tuples:
            if num_affected_items >= max_affected_items:
                break
            qty = min(
                line.quantity_without_offer_discount(offer),
                max_affected_items - num_affected_items,
                line.quantity
            )
            lines_to_discount.append((line, price, qty))
            num_affected_items += qty

        discount_amount = self.value * num_affected_items
        return super().apply(
            basket, condition, offer, discount_amount=discount_amount, max_total_discount=max_total_discount, **kwargs
        )

Finally, we need to add new benefit type to the benefit model, specifically to the proxy map — since in the Oscar all benefit classes (percentage, absolute, multibuy etc) implemented as descending proxy models, inherited from base benefit model class. Onwards, during discount value calculation Oscar loads proxy model and calls its apply method.

class Benefit(AbstractBenefit):
    PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE, FIXED_PER_PRODUCT = (
        "Percentage", "Absolute", "Multibuy", "Fixed price", "Fixed per product")
    SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = (
        'Shipping percentage', 'Shipping absolute', 'Shipping fixed price')

    TYPE_CHOICES = (
        (PERCENTAGE, _("Discount is a percentage off of the product's value")),
        (FIXED, _("Discount is a fixed amount off of the product's value")),
        (FIXED_PER_PRODUCT, _("Discount is a fixed amount off of each product's value that match condition")),
        (MULTIBUY, _("Discount is to give the cheapest product for free")),
        (FIXED_PRICE,
         _("Get the products that meet the condition for a fixed price")),
        (SHIPPING_ABSOLUTE,
         _("Discount is a fixed amount of the shipping cost")),
        (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")),
        (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping"
                                " cost")),
    )
    type = models.CharField(_("Type"), max_length=128, choices=TYPE_CHOICES, blank=True)

    @property
    def proxy_map(self):
        custom_proxy_map = super().proxy_map
        custom_proxy_map[self.FIXED_PER_PRODUCT] = get_class(
            'offer.benefits', 'CustomAbsoluteDiscountPerProductBenefit'
        )
        return custom_proxy_map

Dashboard

Technically, flash sale creation does not differ from other offers, although since it's tight to specific product, we decided to simplify and bypass offer creation step-by-step wizard and just compact it to simple form and view.


from django import forms
from django.utils.translation import gettext_lazy as _

from oscar.core.loading import get_model
from oscar.forms import widgets


Benefit = get_model('offer', 'Benefit')


class FlashSaleForm(forms.Form):
    TYPE_CHOICES = (
        (Benefit.PERCENTAGE, _("Discount is a percentage off of the product's value")),
        (Benefit.FIXED_PRICE, _("Get the products that meet the condition for a fixed price")),
        (Benefit.FIXED_PER_PRODUCT, _("Discount is a fixed amount off of each product's value that match condition")),
    )

    start_datetime = forms.DateTimeField(
        widget=widgets.DateTimePickerInput(), label=_("Start date"), required=False)
    end_datetime = forms.DateTimeField(
        widget=widgets.DateTimePickerInput(), label=_("End date"), required=False)
    benefit_type = forms.ChoiceField(choices=TYPE_CHOICES)
    benefit_value = forms.DecimalField()

Basically, in the FlashSaleForm we added not all benefit types and in particular left absolute discount and shipping discounts behind the scenes. While percentage discount is quite self-descriptive and often used for discounts in the ecommerce, Benefit.FIXED_PRICE needs some clarifications.

Benefit.FIXED_PRICE allows to sell particular product for the price, specified in the benefit value. So generally, if the product price is €20 and benefit value is €5, it will be sold for €5, thus discount value will be calculated as difference between product price and benefit value, which in our case is €15, so that unlike other benefit types discount amount is not constant, but dynamically calculated.

from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic import FormView

from oscar.core.loading import get_model

from .forms import FlashSaleForm


Product = get_model('catalogue', 'Product')
Range = get_model('offer', 'Range')
Condition = get_model('offer', 'Condition')
ConditionalOffer = get_model('offer', 'ConditionalOffer')
Benefit = get_model('offer', 'Benefit')


class FlashSaleCreateView(FormView):
    form_class = FlashSaleForm
    template_name = 'oscar/dashboard/offers/flash_sale_form.html'

    def dispatch(self, request, *args, **kwargs):
        product_pk = self.kwargs.get('product_pk', None)
        self.product = get_object_or_404(Product, pk=product_pk)
        return super().dispatch(request, *args, **kwargs)

    def form_valid(self, form):
        benefit_type = form.cleaned_data['benefit_type']
        benefit_value = form.cleaned_data['benefit_value']
        start_datetime = form.cleaned_data['start_datetime']
        end_datetime = form.cleaned_data['end_datetime']
        range_name = 'Product range for product "%s(#%s)"' % (self.product.get_title(), self.product.pk)
        product_range, created = Range.objects.get_or_create(name=range_name)
        if created:
            product_range.add_product(self.product)

        condition = Condition.objects.create(range=product_range, type=Condition.COUNT, value=1)
        benefit = Benefit.objects.create(range=product_range, type=benefit_type, value=benefit_value)
        offer_name = 'Flash sale for product "%s(#%s)" from %s till %s' % (
            self.product.get_title(),
            self.product.pk,
            start_datetime.strftime("%Y-%m-%d %H:%M:%S"),
            end_datetime.strftime("%Y-%m-%d %H:%M:%S"),
        )
        offer = ConditionalOffer.objects.create(
            name=offer_name, offer_type=ConditionalOffer.FLASH_SALE, condition=condition, benefit=benefit,
            start_datetime=start_datetime, end_datetime=end_datetime
        )
        url = reverse_lazy('dashboard:offer-detail', args=(offer.pk,))
        return HttpResponseRedirect(url)

So initially we create a product range, which contains only particular product, if it does not exist yet and then standard count condition, which will be satisfied if particular product in a single amount will be present in the basket. Also we add benefit with selected type and given value and conditional offer with the pre-defined type ConditionalOffer.FLASH_SALE and selected start and end datetime.

Since it's a standard offer, condition and benefit we can later on view and edit it in the offers section of the dashboard.

Frontend

Oscar allows to indicate discounts on the basket summary page, but does not allow to calculate discount for the product before we add it to the basket, which is opposite from our requirements.

First of all we implemented simple method which just applies benefit value to the particular product, not to the whole basket line. Nevertheless, for consistency we aimed to mimic standard offer application behaviour, thus return result wrapped with the ApplicationResult class. Hereby you can find the snippet based on the percentage discount benefit model:

class CustomFixedPriceBenefit(FixedPriceBenefit):
    def apply_to_product(self, price):
        discount = price - self.value
        return ProductDiscount(discount)

class CustomPercentageDiscountBenefit(PercentageDiscountBenefit):
    def apply_to_product(self, price):
        discount = self.round(self.value / D('100.0') * price)
        return ProductDiscount(discount)


class CustomAbsoluteDiscountPerProductBenefit(AbsoluteDiscountBenefit):
    def apply_to_product(self, price):
        discount = self.round(self.value)
        return ProductDiscount(discount)

We also implemented template tags to indicate product price after discount if particular flash sale offer is applicable. Besides that we also displayed how much time the offer will be relevant and put a sale icon for the active sales.

Below you can find customized stock record template with the changes listed above:

{% load currency_filters %}
{% load i18n %}
{% load purchase_info_tags %}
{% load flash_sale_tags %}

{% purchase_info_for_product request product as session %}

{% is_product_on_sale product as is_on_sale %}

{% if session.price.exists %}
    <p class="price_color">
        <span {% if is_on_sale %}style="color: silver; text-decoration: line-through"{% endif %}>
            {% if session.price.excl_tax == 0 %}
                {% trans "Free" %}
            {% elif session.price.is_tax_known %}
                {{ session.price.incl_tax|currency:session.price.currency }}
            {% else %}
                {{ session.price.excl_tax|currency:session.price.currency }}
            {% endif %}
        </span>
        {% if is_on_sale %}
            {% calculate_product_price_incl_discounts product session.price as sale_price %}
            {{ sale_price|currency:session.price.currency }}
        {% endif %}
    </p>
    <div style="font-size: 0.9em; margin-top:-10px;">
      {% if is_on_sale %}
         {% get_flash_sale_offer product as flash_sale_offer %}
         <b>Discount available till:</b><br>
         {{ flash_sale_offer.end_datetime|date:'Y-m-d H:i' }}
      {% endif %}
    </div>
{% else %}
    <p class="price_color"> </p>
{% endif %}
<p class="{{ session.availability.code }} availability">
    <i class="icon-{% if session.availability.is_available_to_buy %}ok{% else %}remove{% endif %}"></i>
    {% if verbose %}
        {{ session.availability.message }}
    {% else %}
        {{ session.availability.short_message }}
    {% endif %}
</p>

Applying discount to product price is also quite simple and performed in the calculate_product_price_incl_discounts function, where we extract flash sale benefit, calculate its discount value and extract it from the product price.

from decimal import Decimal as D

from django.utils.timezone import now

from oscar.core.loading import get_class, get_model


UnavailablePrice = get_class('partner.prices', 'Unavailable')
FixedPrice = get_class('partner.prices', 'FixedPrice')


def is_product_on_sale(product):
    ConditionalOffer = get_model('offer', 'ConditionalOffer')
    return product.includes.filter(
        benefit__offers__offer_type=ConditionalOffer.FLASH_SALE,
        benefit__offers__start_datetime__lt=now(),
        benefit__offers__end_datetime__gt=now(),
    ).exists()


def get_flash_sale_benefit(product):
    ConditionalOffer = get_model('offer', 'ConditionalOffer')
    if is_product_on_sale(product):
        range_ = product.includes.first()
        return range_.benefit_set.filter(
            offers__offer_type=ConditionalOffer.FLASH_SALE,
            offers__start_datetime__lt=now(),
            offers__end_datetime__gt=now()
        ).first()


def get_flash_sale_offer(product):
    benefit = get_flash_sale_benefit(product)
    return benefit.offers.first()


def calculate_product_price_incl_discounts(product, price_data):
    benefit = get_flash_sale_benefit(product)

    if not benefit:
        return D('0.00')

    price = price_data.incl_tax if price_data.is_tax_known else price_data.excl_tax

    result = benefit.apply_to_product(price)
    if result:
        return price - result.discount

    return price

Since we seamlessly integrated flash sales discount into the offer application, we can see our flash sale discount in the totals section on the basket summery page without any more further customizations.

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