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