Django Oscar - Digital Products (eBooks) - Part 23

In this part, we will create digital products in Django Oscar.

Digital products can represent many different things: booking services, online courses, licenses, templates, or downloadable files.

For this tutorial, we will focus on a simple downloadable file product: an ebook.

 

Product type

The first thing to do is to create a new product type named eBook.

Dashboard > Catalogue > Product Types > Create new product type

  • Name: eBook
  • Requires shipping: No
  • Track stock: No

Then add a new attribute to this product type:

  • Name: File
  • Code: file
  • Type: File

Save the product type and create a new product using this new eBook type.

You should notice that:

  • The product no longer requires stock records
  • The product form now asks you to upload a file

 

One important detail: we should not expose this file attribute directly on the public product page.

The file should only be served after checking if the customer bought the product.

{% for av in product.get_attribute_values %}
    {% if av.attribute.name != "File" %}
        <tr>
            <th>{{ av.attribute.name }}</th>
            <td>{{ av.value_as_html }}</td>
        </tr>
    {% endif %}
{% endfor %}

 

Order status

An ebook does not need to be shipped. Once the payment is confirmed, the order can be considered complete.

To support this, we will add a new Complete status to the order pipeline.

OSCAR_ORDER_STATUS_PIPELINE = {
    'Pending': ('Being processed', 'Cancelled', 'Complete'),
    'Being processed': ('Shipped', 'Cancelled'),
    'Shipped': ('Delivered', 'Cancelled'),
    'Delivered': (),
    'Complete': (),
    'Cancelled': (),
}

 

Now we need to automatically move digital-only orders from Pending to Complete.

from django.dispatch import receiver
from oscar.apps.order.signals import order_placed


def get_product_class(product):
    if not product:
        return None

    if product.product_class:
        return product.product_class

    if product.parent:
        return product.parent.product_class

    return None


def order_requires_shipping(order):
    for line in order.lines.all():
        product_class = get_product_class(line.product)

        if not product_class:
            return True

        if product_class.requires_shipping:
            return True

    return False


@receiver(order_placed)
def set_status_for_digital_orders(order, user, **kwargs):
    if order.status != "Pending":
        return

    if order_requires_shipping(order):
        return

    order.set_status("Complete")

This assumes that your order is created after payment confirmation.

If your project creates orders before payment confirmation, update the order status only after the payment gateway confirms the payment.

 

Customer downloads page

There are multiple approaches to deliver digital products. For this tutorial, we will keep it simple and make purchased digital products available in the customer account area.

We will create a new page called My Downloads.

from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from django.views.generic import TemplateView
from oscar.core.loading import get_model


Product = get_model("catalogue", "Product")
Line = get_model("order", "Line")


class CustomerDownloadsView(LoginRequiredMixin, TemplateView):
    template_name = "Store/downloads.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["active_tab"] = "downloads"
        context["page_title"] = "My Downloads"

        order_lines = (
            Line.objects
            .filter(
                order__user=self.request.user,
                order__status="Complete",
                product__product_class__requires_shipping=False,
            )
            .select_related(
                "order",
                "product",
                "product__product_class",
            )
            .order_by("product_id", "-order__date_placed")
        )

        downloads = []
        seen_products = set()

        for line in order_lines:
            if line.product_id in seen_products:
                continue

            seen_products.add(line.product_id)
            downloads.append(line)

        context["downloads"] = downloads
        return context

    def post(self, request, *args, **kwargs):
        product_id = request.POST.get("product_id")

        if not product_id:
            raise Http404("Product not found.")

        product = get_object_or_404(Product, id=product_id)

        has_access = Line.objects.filter(
            order__user=request.user,
            order__status="Complete",
            product=product,
            product__product_class__requires_shipping=False,
        ).exists()

        if not has_access:
            raise Http404("You do not have access to this file.")

        try:
            ebook_file = product.attr.file
        except AttributeError:
            raise Http404("No file available for this product.")

        if not ebook_file:
            raise Http404("No file available for this product.")

        return FileResponse(
            ebook_file.open("rb"),
            as_attachment=True,
            filename=ebook_file.name.split("/")[-1],
        )

 

URL

from django.urls import path
from .views import CustomerDownloadsView


urlpatterns = [
    ...

    path("accounts/downloads/", CustomerDownloadsView.as_view(), name="customer-downloads"),
]

 

Account menu

Now we need to add a new item to the customer account menu.

mysite/templates/oscar/customer/partials/standard_tabs.html

<li class="nav-item">
    <a href="{% url 'customer-downloads' %}" class="nav-link{% if active_tab == 'downloads' %} active{% endif %}">
        My Downloads
    </a>
</li>

The active_tab variable is used by Oscar to highlight the current tab.

context["active_tab"] = "downloads"

 

Template

Now create the template:

mysite/Store/templates/Store/downloads.html

{% extends "oscar/customer/baseaccountpage.html" %}
{% load i18n %}

{% block tabcontent %}

    {% if downloads %}
        <table class="table table-striped table-bordered">
            <thead>
                <tr>
                    <th>{% trans "Image" %}</th>
                    <th>{% trans "Product" %}</th>
                    <th>{% trans "Order" %}</th>
                    <th>{% trans "Date purchased" %}</th>
                    <th>{% trans "Download" %}</th>
                </tr>
            </thead>
            <tbody>
                {% for line in downloads %}
                    <tr>
                        <td style="width: 90px;">
                            {% with image=line.product.primary_image %}
                                {% if image and image.original %}
                                    <img src="{{ image.original.url }}"
                                         alt="{{ line.product.get_title }}"
                                         style="max-width: 70px; height: auto;">
                                {% else %}
                                    -
                                {% endif %}
                            {% endwith %}
                        </td>
                        <td>{{ line.product.get_title }}</td>
                        <td>{{ line.order.number }}</td>
                        <td>{{ line.order.date_placed }}</td>
                        <td>
                            <form method="post" action="{% url 'customer-downloads' %}">
                                {% csrf_token %}
                                <input type="hidden" name="product_id" value="{{ line.product.id }}">

                                <button type="submit" class="btn btn-primary">
                                    {% trans "Download" %}
                                </button>
                            </form>
                        </td>
                    </tr>
                {% endfor %}
            </tbody>
        </table>
    {% else %}
        <table class="table table-striped table-bordered">
            <tbody>
                <tr>
                    <th>{% trans "Downloads" %}</th>
                    <td>{% trans "You do not have any digital products available for download." %}</td>
                </tr>
            </tbody>
        </table>
    {% endif %}

{% endblock %}

 

How it works

The template expects a variable named downloads. This variable contains the purchased digital products available for the current user.

In our view, we are filtering order lines where:

  • The order belongs to the current user
  • The order status is Complete
  • The product does not require shipping

The download button sends a POST request to the same view. This means that the file URL is not exposed in the template.

Even if someone changes the hidden product_id in the HTML, the file will only be returned if the current user has a completed order containing that product.

 

Production notes

This implementation keeps the tutorial simple. The download is protected by login, CSRF, and by checking the customer's completed orders.

However, the file is still stored as a normal Oscar product attribute. Depending on your media configuration, this file may still live inside your public media folder.

In production, you may want to improve this with:

  • Private file storage
  • Temporary signed download links
  • Download counters
  • Access revocation after refunds
  • S3 private storage or presigned URLs

 

At this point, customers can buy an ebook like any other Oscar product.

After payment, the order is automatically marked as complete, and the ebook becomes available in the customer account area.

Account > My Downloads