Django digital store

In this article we'll go through process of creating combined store, which sells both digital and physical goods, which is not the niche case nowadays and quite commonly used: for instance, book stores sells printed copies and ebooks.

Same as in the previous posts, we'll use the usual stack: Django and Django-Oscar. The latter already has some basis under the hood: it allows on the product class level to make shipping optional and to disable tracking stock. We'll need to implement more end-user functionality, like order download.

There are several various types of digital goods, such as: software, photos, music, audiobooks, ebooks, fonts, HTML templates, PSD layouts etc. Each of them has its own specifics and would require slightly different functionality in the digital store.

Therefore, we decided to focus on the basic concepts and show how to implement simplest Django digital store with mandatory core functionality. It can be taken from there and integrate various desired functionality for the certain type of digital product.

Let's proceed and start from outlining the models:

  • Custom product class model will be needed to explicitly indicate whether the product of this type can be downloaded.
  • ProductFile model will allow to store files for each digital product and their metadata.

Also creating digital product record we'd not forget to set `track_stock` field as `False`.

Since default file storage stores files in the media folder, which is publicly available, we can't use it for obvious reasons. So, we'll use the same approach for storing private files as we did in the django-oscar-invoices — https://github.com/django-oscar/django-oscar-invoices/blob/master/oscar_invoices/storages.py, but give storage slightly different name, DigitalProductStorage.

from django.db import models
from django.utils.translation import gettext_lazy as _

from oscar.apps.catalogue.abstract_models import AbstractProductClass

from .storages import DigitalProductStorage


class ProductClass(AbstractProductClass):
    is_downloadable = models.BooleanField(_('Is downloadable?'), default=False)


class ProductFile(models.Model):
    product = models.ForeignKey('catalogue.Product', related_name='files', on_delete=models.CASCADE)
    date_created = models.DateTimeField(_("Date created"), auto_now_add=True, db_index=True)
    date_updated = models.DateTimeField(_("Date updated"), auto_now=True, db_index=True)
    file = models.FileField(storage=DigitalProductStorage())
    size = models.BigIntegerField()
    mimetype = models.CharField(max_length=255)
    filename = models.CharField(max_length=255)

    def __str__(self):
        return self.filename


from oscar.apps.catalogue.models import *  # noqa isort:skip

In order to automatically generate metadata on the product file upload, we'll implement an easy signal receiver:

from django.db.models.signals import pre_save
from django.dispatch import receiver

from .models import ProductFile


@receiver(pre_save, sender=ProductFile)
def determine_product_file_meta(sender, instance, *args, **kwargs):
    instance.size = instance.file.size
    instance.mimetype = instance.file._file.content_type
    instance.filename = instance.file.name

In order to allow staff manage files for digital products, we'd add modifications to the Oscar dashboard. First of all, we need to define a simple formset, ProductFileFormSet which wrapped around the same simple form, ProductFileForm and integrate the formset to the ProductCreateUpdateView. Finally, need to render formset on the dashboard product edit page and thus to modify product_update.html template. All the above changes are quite straightforward, you can find related code in the next repository — https://github.com/metaclassco/django_oscar_digital_store.

In the Oscar we determine availability of the physical product based on the stock record presence and its net stock level. However, as mentioned before, for digital products we don't even track stock. Basically, digital product would be always available if it has at least one file:

from django.utils.translation import gettext_lazy as _

from oscar.apps.partner.availability import Unavailable
from oscar.apps.partner.strategy import Default as CoreDefault


class DigitalProductUnavailable(Unavailable):
    message = _('Digital assets are not available.')


class Selector(object):
    def strategy(self, request=None, user=None, **kwargs):
        return Default(request)


class Default(CoreDefault):
    def availability_policy(self, product, stockrecord):
        if product.product_class.is_downloadable and product.files.count() == 0:
            return DigitalProductUnavailable()

        return super().availability_policy(product, stockrecord)

After the order was paid, we can allow customer to download order files. For security reason we can limit number of download attempts, so we'll need to log all attempts to achieve that. Also we'll need to check if order does have digital products and if it was paid already. Finally, we'll archive its files and stream to customer.

So at the first place, we'd implement DownloadAttempt model and couple of helper methods/properties for the Order model:

from django.conf import settings
from django.db import models

from oscar.apps.order.abstract_models import AbstractOrder


class Order(AbstractOrder):
    @property
    def is_paid(self):
        return self.status == settings.OSCAR_ORDER_PAID_STATUS

    def get_lines_with_digital_products(self):
        return self.lines.filter(product__product_class__is_downloadable=True)

    def has_digital_products(self):
        digital_products = self.get_lines_with_digital_products()
        return digital_products.exists()


class DownloadAttempt(models.Model):
    date_created = models.DateTimeField(auto_now_add=True)
    order = models.ForeignKey('Order', on_delete=models.CASCADE)
    ip_address = models.GenericIPAddressField()
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)


from oscar.apps.order.models import *  # noqa isort:skip

As long as we have models in place, we can now implement a view, which serves order files download:

from io import BytesIO
import os
import zipfile

from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View

from oscar.core.loading import get_model


DownloadAttempt = get_model('order', 'DownloadAttempt')
Order = get_model('order', 'Order')


class DownloadOrderProductFiles(LoginRequiredMixin, View):
    model = get_model('order', 'Order')

    def get(self, request, *args, **kwargs):
        order = get_object_or_404(self.model, user=self.request.user, number=self.kwargs['order_number'])
        if not order.has_digital_products():
            raise PermissionDenied(_('Order does not have digital products.'))

        if not order.is_paid:
            raise PermissionDenied(_('Order files will be available for download after the order is paid.'))

        download_attempts = DownloadAttempt.objects.filter(user=self.request.user, order=order)
        if download_attempts.count() == settings.OSCAR_MAX_ORDER_DOWNLOAD_ATTEMPTS:
            raise PermissionDenied(_('Download attempts for this order has exceeded.'))

        DownloadAttempt.objects.create(user=self.request.user, order=order, ip_address=request.META.get('REMOTE_ADDR'))
        f = BytesIO()
        zf = zipfile.ZipFile(f, "w")
        response = FileResponse(f, content_type='application/zip')
        response['Content-Disposition'] = 'attachment; filename=order_%s.zip' % order.number

        for line in order.get_lines_with_digital_products():
            product = line.product
            for product_file in product.files.all():
                path, filename = os.path.split(product_file.file.path)
                os.chdir(path)
                zf.write(filename)
        zf.close()

        return response

We intentionally did not include order download link to the order place notification email in order to avoid customer confusion, since we only allow to download after order is fully paid. So instead, we'll implement signal receiver, which will track order status and immediately send email notification when order status becomes "paid". It's better to send notification using Celery, but we haven't included it to the article for simplicity.

from django.conf import settings
from django.contrib.sites.models import Site
from django.dispatch import receiver

from oscar.apps.order.signals import order_status_changed
from oscar.core.loading import get_class, get_model


CommunicationEventType = get_model('customer', 'CommunicationEventType')
Dispatcher = get_class('customer.utils', 'Dispatcher')

ORDER_PAID_TYPE_CODE = 'ORDER_PAID'


@receiver(order_status_changed)
def send_order_paid_notifications(sender, order, old_status, new_status, **kwargs):
    if new_status == settings.OSCAR_ORDER_PAID_STATUS:
        ctx = {
            'order': order, 'site': Site.objects.get_current()
        }
        messages = CommunicationEventType.objects.get_and_render(code=ORDER_PAID_TYPE_CODE, context=ctx)
        dispatcher = Dispatcher()
        dispatcher.dispatch_order_messages(order, messages, **kwargs)

Consequently, in the notification itself we'll list order digital product and their metadata and provide full absolute download URL:

{% extends "oscar/customer/emails/base.html" %}
{% load currency_filters i18n %}

{% block tbody %}
<tr>
    <td class="content-block">
        <p xmlns="http://www.w3.org/1999/html">{% trans 'Hello,' %}</p>
        <p>{% blocktrans with order_number=order.number %}We have received your payment for the order {{ order_number }}, thank you.{% endblocktrans %}</p>
    </td>
</tr>

{% if order.has_digital_products %}
<tr>
    <td class="content-block">
        <table class="order">
            <tbody><tr>
                <td>{% trans 'Digital products of your order:' %}</td>
            </tr>
            <tr>
                <td>
                    <table class="order-items" cellpadding="0" cellspacing="0">
                        <tbody>
                            {% for line in order.get_lines_with_digital_products %}
                                <tr>
                                    <td><b>{{ line.title }}</b></td>
                                </tr>
                                <tr>
                                    <td>
                                      {% with product=line.product %}
                                          Files:<br>
                                          {% for file in product.files.all %}
                                              {{ file.filename }} — {{ file.size|filesizeformat }}<br>
                                          {% endfor %}
                                      {% endwith %}
                                    </td>
                                </tr>
                            {% endfor %}
                        </tbody>

                    </table>
                </td>
            </tr>
        </tbody></table>
    </td>
</tr>
<tr>
    <td class="content-block">
        <a href="http://{{ site.domain }}{% url 'download_order_files' order.number %}" class="btn-primary">Download order files</a>
    </td>
</tr>
{% endif %}

{% endblock %}

This is how the payment confirmation email would look like:

Also as Django-oscar documentation suggests, you can use shipping events for digital goods as well in order to indicate that order was downloaded.

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