© Federico Marani 2019
Federico MaraniPractical Django 2 and Channels 2https://doi.org/10.1007/978-1-4842-4099-1_7

7. Making an Internal Dashboard for the Company

Federico Marani1 
(1)
London, UK
 

In this chapter we will build a dashboard for Booktime company employees on top of the Django admin interface. We will discuss why and how to do this, along with different types of users in the company.

We will cover these topics:
  • Configuring the admin interface

  • Adding admin views

  • Configuring users and permissions

  • Creating internal reports

  • Generating PDFs

Reasons to Use Django admin

In this chapter we are using the Django admin interface to demonstrate how customizable this app is. With only some basic customizations we are already able to manage our products and order data, as we have seen in the previous chapters. We are also already able to filter and search for products and orders in our system.

The Django admin interface comes with a built-in authentication and permission system. You can easily configure multiple users to be able to see and change only parts of your data. This interface also has a built-in log to track who changed what models in the database.

This app allows us to get an adequate state without much effort. In this chapter we will continue building upon the customizations that we already did by creating new views integrated in the admin interface, modifying the permissions of the users, integrating reporting functions, and customizing its look.

All of this is possible by mostly overriding the base classes, although this approach has limits. Given that, when customizing the admin interface, we are always overriding built-in behavior with additional code, it is advisable to not overcustomize it because your code will quickly become hard to read.

Another limitation of this approach is that you cannot fundamentally alter the user flow of the app. Just like the choice between class-based views and function-based views, if you spend more time overriding built-in behavior than codifying your own, customizing the admin interface is not the right approach.

In this chapter we will try to stretch this interface to its limits to achieve support for all the standard operations an e-commerce company should be able to do, or at least for the ones that our fictitious book-selling company requires.

Views in the admin interface

To list all exposed views in the admin, we can use the show_urls command from the django-extensions library . Here is a small snippet of its output:
...
/admin/
    django.contrib.admin.sites.index
    admin:index
/admin/<app_label>/
    django.contrib.admin.sites.app_index
    admin:app_list
/admin/auth/user/
    django.contrib.admin.options.changelist_view
    admin:auth_user_changelist
/admin/auth/user/<id>/password/
    django.contrib.auth.admin.user_change_password
    admin:auth_user_password_change
/admin/auth/user/<path:object_id>/
    django.views.generic.base.RedirectView
/admin/auth/user/<path:object_id>/change/
    django.contrib.admin.options.change_view
    admin:auth_user_change
/admin/auth/user/<path:object_id>/delete/
    django.contrib.admin.options.delete_view
    admin:auth_user_delete
/admin/auth/user/<path:object_id>/history/
    django.contrib.admin.options.history_view
    admin:auth_user_history
/admin/login/
    django.contrib.admin.sites.login
    admin:login
/admin/logout/
    django.contrib.admin.sites.logout
    admin:logout
...
As you can see, for an instance of Django admin, there are many pages (not all of which are shown in the preceding snippet):
  • Index view : The initial page, which lists all the Django apps and their models

  • App list view : The list of models of a single Django app

  • Change list view : The list of all entries for a Django model

  • Change view : A view to change a single entity of a Django model

  • Add view : A view to add a new entity of a Django model

  • Delete view : A confirmation view to delete a single entity of a Django model

  • History view : A list of all changes done through Django admin interface of a single entity

  • Support views : Login, logout, and change password views

Each of these views is customizable through overriding specific methods in the right admin class (we will explore an example of this). Each of these views also uses a template that can be customized:
  • Index view: admin/index.html

  • App list view: admin/app_index.html

  • Change list view: admin/change_list.html

  • Change item view: admin/change_form.html

  • Add item view: admin/change_form.html

  • Delete item view on one item: admin/delete_confirmation.html

  • Delete item view on multiple items: admin/delete_selected_confirmation.html

  • History view: admin/object_history.html

There are many more templates that represent specific sections of the screen of some of these views. I encourage you to explore these templates to understand their structure. You can find them in your Python virtualenv or online on GitHub1.

Django admin interface comes with a built-in set of views, but you can add new views. You can define views both at the top level and at the model level. The new views will inherit all the security checks and URL namespacing of the correspondent admin instance. This makes it possible to add, for instance, all the reporting views to our admin instance, with the proper authorization checks in place.

In addition to all the preceding features, it is possible to have multiple Django admin interfaces running on one site, each with its own customizations. Up to this point we have used django.contrib.admin.site, which is an instance of django.contrib.admin.AdminSite, but nothing stops us from having many instances of it.

Configuring User Types and Permissions for the Company

Before writing any code, it is important to clarify the different types of users that you have in the system and the way each type is allowed to interact with the system. We have three types of users in the BookTime company:
  • Owners
    • Can see and operate on all useful models

  • Central office employees
    • Can flag orders as paid

    • Can change order data

    • Can see reports about the site’s performance

    • Can manage products and related information

  • Dispatch office
    • Can flag order lines as shipped (or canceled)

    • Can flag products as out of stock

In Django, we will store the membership information in this way:
  • Owners : Any user for whom the is_superuser field is set to True

  • Central office employees : Any user belonging to the “Employees” group

  • Dispatch office : Any user belonging to the “Dispatchers” group

To create these user types in the system we will use a data fixture, which is the same principle as a test fixture. Put this content in main/data/user_groups.json:
[
  {
    "model": "auth.group",
    "fields": {
      "name": "Employees",
      "permissions": [
        [ "add_address", "main", "address" ],
        [ "change_address", "main", "address" ],
        [ "delete_address", "main", "address" ],
        [ "change_order", "main", "order" ],
        [ "add_orderline", "main", "orderline" ],
        [ "change_orderline", "main", "orderline" ],
        [ "delete_orderline", "main", "orderline" ],
        [ "add_product", "main", "product" ],
        [ "change_product", "main", "product" ],
        [ "delete_product", "main", "product" ],
        [ "add_productimage", "main", "productimage" ],
        [ "change_productimage", "main", "productimage" ],
        [ "delete_productimage", "main", "productimage" ],
        [ "change_producttag", "main", "producttag" ]
      ]
    }
  },
  {
    "model": "auth.group",
    "fields": {
      "name": "Dispatchers",
      "permissions": [
        [ "change_orderline", "main", "orderline" ],
        [ "change_product", "main", "product" ]
      ]
    }
  }
]
To load the preceding code, type the following:
$ ./manage.py loaddata main/data/user_groups.json
Installed 2 object(s) from 1 fixture(s)
We are also going to add a few helper functions to our User model, to help us identify what type of user it is:
class User(AbstractUser):
    ...
    @property
    def is_employee(self):
        return self.is_active and (
            self.is_superuser
            or self.is_staff
            and self.groups.filter(name="Employees").exists()
        )
    @property
    def is_dispatcher(self):
        return self.is_active and (
            self.is_superuser
            or self.is_staff
            and self.groups.filter(name="Dispatchers").exists()
       )

Implementing Multiple admin interfaces for Users

We are going to start with a bunch of code that I will explain inline with code comments. Starting from main/admin.py, we are going to replace all we have with a more advanced version of it, supporting all the use cases that we listed.
from datetime import datetime, timedelta
import logging
from django.contrib import admin
from django.contrib.auth.admin import (
    UserAdmin as DjangoUserAdmin
)
from django.utils.html import format_html
from django.db.models.functions import TruncDay
from django.db.models import Avg, Count, Min, Sum
from django.urls import path
from django.template.response import TemplateResponse
from . import models
logger = logging.getLogger(__name__)
class ProductAdmin(admin.ModelAdmin):
    list_display = ("name", "slug", "in_stock", "price")
    list_filter = ("active", "in_stock", "date_updated")
    list_editable = ("in_stock",)
    search_fields = ("name",)
    prepopulated_fields = {"slug": ("name",)}
    autocomplete_fields = ("tags",)
    # slug is an important field for our site, it is used in
    # all the product URLs. We want to limit the ability to
    # change this only to the owners of the company.
    def get_readonly_fields(self, request, obj=None):
        if request.user.is_superuser:
            return self.readonly_fields
        return list(self.readonly_fields) + ["slug", "name"]
    # This is required for get_readonly_fields to work
    def get_prepopulated_fields(self, request, obj=None):
        if request.user.is_superuser:
            return self.prepopulated_fields
        else:
            return {}
class DispatchersProductAdmin(ProductAdmin):
    readonly_fields = ("description", "price", "tags", "active")
    prepopulated_fields = {}
    autocomplete_fields = ()
class ProductTagAdmin(admin.ModelAdmin):
    list_display = ("name", "slug")
    list_filter = ("active",)
    search_fields = ("name",)
    prepopulated_fields = {"slug": ("name",)}
    # tag slugs also appear in urls, therefore it is a
    # property only owners can change
    def get_readonly_fields(self, request, obj=None):
        if request.user.is_superuser:
            return self.readonly_fields
        return list(self.readonly_fields) + ["slug", "name"]
    def get_prepopulated_fields(self, request, obj=None):
        if request.user.is_superuser:
            return self.prepopulated_fields
        else:
            return {}
class ProductImageAdmin(admin.ModelAdmin):
    list_display = ("thumbnail_tag", "product_name")
    readonly_fields = ("thumbnail",)
    search_fields = ("product__name",)
    # this function returns HTML for the first column defined
    # in the list_display property above
    def thumbnail_tag(self, obj):
        if obj.thumbnail:
            return format_html(
                '<img src="%s"/>' % obj.thumbnail.url
            )
        return "-"
    # this defines the column name for the list_display
    thumbnail_tag.short_description = "Thumbnail"
    def product_name(self, obj):
        return obj.product.name
class UserAdmin(DjangoUserAdmin):
    # User model has a lot of fields, which is why we are
    # reorganizing them for readability
    fieldsets = (
        (None, {"fields": ("email", "password")}),
        (
            "Personal info",
            {"fields": ("first_name", "last_name")},
        ),
        (
            "Permissions",
            {
                "fields": (
                    "is_active",
                    "is_staff",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                )
            },
        ),
        (
            "Important dates",
            {"fields": ("last_login", "date_joined")},
        ),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("email", "password1", "password2"),
            },
        ),
    )
    list_display = (
        "email",
        "first_name",
        "last_name",
        "is_staff",
    )
    search_fields = ("email", "first_name", "last_name")
    ordering = ("email",)
class AddressAdmin(admin.ModelAdmin):
    list_display = (
        "user",
        "name",
        "address1",
        "address2",
        "city",
        "country",
    )
    readonly_fields = ("user",)
class BasketLineInline(admin.TabularInline):
    model = models.BasketLine
    raw_id_fields = ("product",)
class BasketAdmin(admin.ModelAdmin):
    list_display = ("id", "user", "status", "count")
    list_editable = ("status",)
    list_filter = ("status",)
    inlines = (BasketLineInline,)
class OrderLineInline(admin.TabularInline):
    model = models.OrderLine
    raw_id_fields = ("product",)
class OrderAdmin(admin.ModelAdmin):
    list_display = ("id", "user", "status")
    list_editable = ("status",)
    list_filter = ("status", "shipping_country", "date_added")
    inlines = (OrderLineInline,)
    fieldsets = (
        (None, {"fields": ("user", "status")}),
        (
            "Billing info",
            {
                "fields": (
                    "billing_name",
                    "billing_address1",
                    "billing_address2",
                    "billing_zip_code",
                    "billing_city",
                    "billing_country",
                )
            },
        ),
        (
            "Shipping info",
            {
                "fields": (
                    "shipping_name",
                    "shipping_address1",
                    "shipping_address2",
                    "shipping_zip_code",
                    "shipping_city",
                    "shipping_country",
                )
            },
        ),
    )
# Employees need a custom version of the order views because
# they are not allowed to change products already purchased
# without adding and removing lines
class CentralOfficeOrderLineInline(admin.TabularInline):
    model = models.OrderLine
    readonly_fields = ("product",)
class CentralOfficeOrderAdmin(admin.ModelAdmin):
    list_display = ("id", "user", "status")
    list_editable = ("status",)
    readonly_fields = ("user",)
    list_filter = ("status", "shipping_country", "date_added")
    inlines = (CentralOfficeOrderLineInline,)
    fieldsets = (
        (None, {"fields": ("user", "status")}),
        (
            "Billing info",
            {
                "fields": (
                    "billing_name",
                    "billing_address1",
                    "billing_address2",
                    "billing_zip_code",
                    "billing_city",
                    "billing_country",
                )
            },
        ),
        (
            "Shipping info",
            {
                "fields": (
                    "shipping_name",
                    "shipping_address1",
                    "shipping_address2",
                    "shipping_zip_code",
                    "shipping_city",
                    "shipping_country",
                )
            },
        ),
    )
# Dispatchers do not need to see the billing address in the fields
class DispatchersOrderAdmin(admin.ModelAdmin):
    list_display = (
        "id",
        "shipping_name",
        "date_added",
        "status",
    )
    list_filter = ("status", "shipping_country", "date_added")
    inlines = (CentralOfficeOrderLineInline,)
    fieldsets = (
        (
            "Shipping info",
            {
                "fields": (
                    "shipping_name",
                    "shipping_address1",
                    "shipping_address2",
                    "shipping_zip_code",
                    "shipping_city",
                    "shipping_country",
                )
            },
        ),
    )
    # Dispatchers are only allowed to see orders that
    # are ready to be shipped
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.filter(status=models.Order.PAID)
# The class below will pass to the Django Admin templates a couple
# of extra values that represent colors of headings
class ColoredAdminSite(admin.sites.AdminSite):
    def each_context(self, request):
        context = super().each_context(request)
        context["site_header_color"] = getattr(
            self, "site_header_color", None
        )
        context["module_caption_color"] = getattr(
            self, "module_caption_color", None
        )
        return context
# The following will add reporting views to the list of
# available urls and will list them from the index page
class ReportingColoredAdminSite(ColoredAdminSite):
    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            path(
                "orders_per_day/",
                self.admin_view(self.orders_per_day),
            )
        ]
        return my_urls + urls
    def orders_per_day(self, request):
        starting_day = datetime.now() - timedelta(days=180)
        order_data = (
            models.Order.objects.filter(
                date_added__gt=starting_day
            )
            .annotate(
                day=TruncDay("date_added")
            )
             .values("day")
             .annotate(c=Count("id"))
         )
         labels = [
             x["day"].strftime("%Y-%m-%d") for x in order_data
         ]
         values = [x["c"] for x in order_data]
         context = dict(
             self.each_context(request),
             title="Orders per day",
             labels=labels,
             values=values,
        )
        return TemplateResponse(
            request, "orders_per_day.html", context
        )
    def index(self, request, extra_context=None):
        reporting_pages = [
            {
                "name": "Orders per day",
                "link": "orders_per_day/",
            }
        ]
        if not extra_context:
            extra_context = {}
        extra_context = {"reporting_pages": reporting_pages}
        return super().index(request, extra_context)
# Finally we define 3 instances of AdminSite, each with their own
# set of required permissions and colors
class OwnersAdminSite(ReportingColoredAdminSite):
    site_header = "BookTime owners administration"
    site_header_color = "black"
    module_caption_color = "grey"
    def has_permission(self, request):
        return (
            request.user.is_active and request.user.is_superuser
        )
class CentralOfficeAdminSite(ReportingColoredAdminSite):
    site_header = "BookTime central office administration"
    site_header_color = "purple"
    module_caption_color = "pink"
    def has_permission(self, request):
        return (
            request.user.is_active and request.user.is_employee
        )
class DispatchersAdminSite(ColoredAdminSite):
    site_header = "BookTime central dispatch administration"
    site_header_color = "green"
    module_caption_color = "lightgreen"
    def has_permission(self, request):
        return (
            request.user.is_active and request.user.is_dispatcher
        )
main_admin = OwnersAdminSite()
main_admin.register(models.Product, ProductAdmin)
main_admin.register(models.ProductTag, ProductTagAdmin)
main_admin.register(models.ProductImage, ProductImageAdmin)
main_admin.register(models.User, UserAdmin)
main_admin.register(models.Address, AddressAdmin)
main_admin.register(models.Basket, BasketAdmin)
main_admin.register(models.Order, OrderAdmin)
central_office_admin = CentralOfficeAdminSite(
    "central-office-admin"
)
central_office_admin.register(models.Product, ProductAdmin)
central_office_admin.register(models.ProductTag, ProductTagAdmin)
central_office_admin.register(
    models.ProductImage, ProductImageAdmin
)
central_office_admin.register(models.Address, AddressAdmin)
central_office_admin.register(
    models.Order, CentralOfficeOrderAdmin
)
dispatchers_admin = DispatchersAdminSite("dispatchers-admin")
dispatchers_admin.register(
    models.Product, DispatchersProductAdmin
)
dispatchers_admin.register(models.ProductTag, ProductTagAdmin)
dispatchers_admin.register(models.Order, DispatchersOrderAdmin)

That’s a lot of code! First of all, there are three instances of Django admin, one for each type of user we declared in the previous section. Each instance has a different set of models registered, depending on what is relevant for that type of user.

The Django admin sites will be color-coded. Colors are injected through some custom CSS. The owners’ and central office’s admin interfaces have also some extra views for reporting. The extra views are inserted in three steps: the actual view (orders_per_day), the URL mapping (in get_urls()), and the inclusion in the index template (index()).

Specifically to DispatchersAdminSite , we have special a version of ModelAdmin for Product and Order. DispatchersOrderAdmin overrides the get_queryset() method because the dispatch office only needs to see the orders that have been marked as paid already. On those, they only need to see the shipping address.

For anyone else other than owners, we are also limiting the ability to change the slugs because they are part of the URLs. If they were changed, Google or any other entity that links to our site would have broken links.

The new instances of Django admin interfaces now need an entry in urlpatterns in main/urls.py, as follows. Do not forget to remove the old entry for admin/ in booktime/urls.py. If you forget to remove it, you will have some problems with clashing path names.
...
from main import admin
urlpatterns = [
    ...
    path("admin/", admin.main_admin.urls),
    path("office-admin/", admin.central_office_admin.urls),
    path("dispatch-admin/", admin.dispatchers_admin.urls),
]
To finish this setup, we need to override a couple of admin templates. First, we are going to add a directory named templates to the top folder, for overridden templates. This implies a change in booktime/settings.py:
TEMPLATES = [
    ...
    {
        "DIRS": [os.path.join(BASE_DIR, 'templates')],
    ...
Then we override the templates. This is our new admin base template, which will take care of setting the colors in the CSS. Place the following in templates/admin/base_site.html:
{% extends "admin/base.html" %}
{% block title %}
  {{ title }} | {{ site_title|default:_('Django site admin') }}
{% endblock %}
{% block extrastyle %}
  <style type="text/css" media="screen">
    #header {
      background: {{site_header_color}};
    }
    .module caption {
      background: {{module_caption_color}};
    }
  </style>
{% endblock extrastyle %}
{% block branding %}
  <h1 id="site-name">
    <a href="{% url 'admin:index' %}">
      {{ site_header|default:_('Django administration') }}
    </a>
  </h1>
{% endblock %}
{% block nav-global %}{% endblock %}

In the code we have seen above the index view had some extra template variables. To display those, a new template is required. We will place this file in templates/admin/index.html, and it will be a customization of a built-in admin template.

Let’s take a copy of this admin template for our project. From our top-level folder, run the following command.
cp $VIRTUAL_ENV/lib/python3.6/site-packages/django/contrib/admin/templates/admin/index
The new template will need to be changed, at the beginning of the content block. Here is the modified template content:
{% extends "admin/base_site.html" %}
...
{% block content %}
<div id="content-main">
  {% if reporting_pages %}
    <div class="module">
      <table>
        <caption>
          <a href="#" class="section">Reports</a>
        </caption>
        {% for page in reporting_pages %}
          <tr>
            <th scope="row">
                <a href="{{ page.link }}">
                    {{ page.name }}
                </a>
            </th>
            <td>&nbsp;</td>
            <td>&nbsp;</td>
          </tr>
        {% endfor %}
      </table>
    </div>
  {% else %}
    <p>No reports</p>
  {% endif %}
  ...
{% endblock %}
...

We now have the three dashboards that we want to give to our internal team. Please go ahead and open the URLs in your browser, after having logged in as a superuser. Bear in mind that the reporting section is not finished yet.

In the next section we will talk more about reporting with the Django ORM. The code was included above (orders_per_day()), but given how important it is, it deserves to be explained in its own section.

Reporting on Orders

When it comes to reporting, SQL queries tend to become a bit more complicated, using aggregate functions, GROUP BY clauses, etc. The purpose of the Django ORM is to map database rows to Model objects. It can be used to do reports, but that is not its primary function. This can lead to some difficult-to-understand ORM expressions, so be warned.

In Django, there are two classes of aggregation: one that acts on all entries in a QuerySet and another that acts on each entry of it. The first uses the aggregate() method , and the second annotate(). Another way to explain it is that aggregate() returns a Python dictionary, while annotate() returns a QuerySet where each entry is annotated with additional information.

There is an exception to this rule, and that is when the annotate() function is used with values(). In that case, annotations are not generated for each item of the QuerySet, but rather on each unique combination of the fields specified in the values() method .

When in doubt, you can see the SQL that the ORM is generating by checking the property query on any QuerySet.

The next few sections present some reports, and break down the ORM queries.

Numbers of Orders Per Day

In the code above there is a view called orders_per_day that runs this aggregation query:
order_data = (
    models.Order.objects.filter(
        date_added__gt=starting_day
    )
    .annotate(
        day=TruncDay("date_added")
    )
    .values("day")
    .annotate(c=Count("id"))
)
The query that comes out in Postgres is as follows:
SELECT DATE_TRUNC('day', "main_order"."date_added" AT TIME ZONE 'UTC') AS "day",
    COUNT("main_order"."id") AS "c" FROM "main_order"
    WHERE "main_order"."date_added" > 2018-01-16 19:20:01.262472+00:00
    GROUP BY DATE_TRUNC('day', "main_order"."date_added" AT TIME ZONE 'UTC')
The preceding ORM code does a few things:
  • Creates a temporary/annotated day field, populating it with data based on the date_added field

  • Uses the new day field as a unit for aggregation

  • Counts orders for specific days

The preceding query includes two annotate() calls. The first acts on all rows in the order table. The second, instead of acting on all rows, acts on the result of the GROUP BY clause, which is generated by the values() call.

To finish the reporting functionality presented in the previous section, we need to create a template in main/templates/orders_per_day.html:
{% extends "admin/base_site.html" %}
{% block extrahead %}
  <script
     src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.min.js"
     integrity="sha256-XF29CBwU1MWLaGEnsELogU6Y6rcc5nCkhhx89nFMIDQ="
     crossorigin="anonymous"></script>
{% endblock extrahead %}
{% block content %}
  <canvas id="myChart" width="900" height="400"></canvas>
  <script>
    var ctx = document.getElementById("myChart");
    var myChart = new Chart(ctx, {
      type: 'bar',
      data: {
        labels: {{ labels|safe }},
        datasets: [
          {
            label: 'No of orders',
            backgroundColor: 'blue',
            data: {{ values|safe }}
          }
        ]
      },
      options: {
        responsive: false,
        scales: {
          yAxes: [
            {
              ticks: {
                beginAtZero: true
              }
            }
          ]
        }
      }
    });
  </script>
{% endblock %}

In the template we are using an open source library called Chart.js . Using a charting library for reporting views is a common theme, and you should familiarize yourself with a few charting libraries and the format they require the data to be in.

Viewing the Most Bought Products

We are going to add another view that shows what are the most bought products. Differently from orders_per_day(), we are going to do this with a bit more customization, and with integration tests, to show that you can apply the same concepts of normal views to admin views.

These are the bits of main/admin.py that we will add for this view:
from django import forms
...
class PeriodSelectForm(forms.Form):
    PERIODS = ((30, "30 days"), (60, "60 days"), (90, "90 days"))
    period = forms.TypedChoiceField(
        choices=PERIODS, coerce=int, required=True
    )
class ReportingColoredAdminSite(ColoredAdminSite):
    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            ...
            path(
                "most_bought_products/",
                self.admin_view(self.most_bought_products),
                name="most_bought_products",
            ),
        ]
        return my_urls + urls
    ...
    def most_bought_products(self, request):
        if request.method == "POST":
            form = PeriodSelectForm(request.POST)
            if form.is_valid():
                days = form.cleaned_data["period"]
                starting_day = datetime.now() - timedelta(
                    days=days
                )
             data = (
                 models.OrderLine.objects.filter(
                     order__date_added__gt=starting_day
                 )
                 .values("product__name")
                 .annotate(c=Count("id"))
             )
             logger.info(
                "most_bought_products query: %s", data.query
            )
            labels = [x["product__name"] for x in data]
            values = [x["c"] for x in data]
    else:
        form = PeriodSelectForm()
        labels = None
        values = None
    context = dict(
        self.each_context(request),
        title="Most bought products",
        form=form,
        labels=labels,
        values=values,
    )
    return TemplateResponse(
        request, "most_bought_products.html", context
    )
def index(self, request, extra_context=None):
    reporting_pages = [
            ...
            {
                "name": "Most bought products",
                "link": "most_bought_products/",
            },
        ]
        ...

As you can see, we can use forms inside this view. We created a simple form to select how far back we want the report for.

Additionally, we are going to create main/templates/most_bought_products.html:
{% extends "admin/base_site.html" %}
{% block extrahead %}
  <script
    src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.min.js"
    integrity="sha256-XF29CBwU1MWLaGEnsELogU6Y6rcc5nCkhhx89nFMIDQ="
    crossorigin="anonymous"></script>
{% endblock extrahead %}
{% block content %}
  <p>
    <form method="POST">
      {% csrf_token %}
      {{ form }}
      <input type="submit" value="Set period" />
    </form>
  </p>
  {% if labels and values %}
    <canvas id="myChart" width="900" height="400"></canvas>
    <script>
      var ctx = document.getElementById("myChart");
      var myChart = new Chart(ctx, {
        type: 'bar',
        data: {
          labels: {{ labels|safe }},
          datasets: [
            {
              label: 'No of purchases',
              backgroundColor: 'blue',
              data: {{ values|safe }}
            }
          ]
        },
        options: {
          responsive: false,
          scales: {
            yAxes: [
              {
                ticks: {
                  beginAtZero: true
                }
              }
            ]
          }
        }
      });
    </script>
  {% endif %}
{% endblock %}
The preceding template is very similar to the previous one, the only difference being that we are rendering the graph only when the form has been submitted. The selected period is needed for the query. The resulting page is shown in the Figure 7-1.
../images/466106_1_En_7_Chapter/466106_1_En_7_Fig1_HTML.png
Figure 7-1

Most bought products view

To conclude this functionality, we are going to add our first admin views tests. We will create a new file called main/tests/test_admin.py:
from django.test import TestCase
from django.urls import reverse
from main import factories
from main import models
class TestAdminViews(TestCase):
    def test_most_bought_products(self):
        products = [
            factories.ProductFactory(name="A", active=True),
            factories.ProductFactory(name="B", active=True),
            factories.ProductFactory(name="C", active=True),
        ]
        orders = factories.OrderFactory.create_batch(3)
        factories.OrderLineFactory.create_batch(
            2, order=orders[0], product=products[0]
        )
        factories.OrderLineFactory.create_batch(
            2, order=orders[0], product=products[1]
        )
        factories.OrderLineFactory.create_batch(
            2, order=orders[1], product=products[0]
        )
        factories.OrderLineFactory.create_batch(
            2, order=orders[1], product=products[2]
        )
        factories.OrderLineFactory.create_batch(
            2, order=orders[2], product=products[0]
        )
        factories.OrderLineFactory.create_batch(
            1, order=orders[2], product=products[1]
        )
        user = models.User.objects.create_superuser(
            "user2", "pw432joij"
        )
        self.client.force_login(user)
        response = self.client.post(
            reverse("admin:most_bought_products"),
            {"period": "90"},
        )
        self.assertEqual(response.status_code, 200)
        data = dict(
            zip(
               response.context["labels"],
               response.context["values"],
            )
        )
        self.assertEqual(data,  {"B": 3, "C": 2, "A": 6})
This test makes heavy use of factories to create enough data for the report to contain some useful information. Here are the new factories we added in main/factories.py:
...
class OrderLineFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = models.OrderLine
class OrderFactory(factory.django.DjangoModelFactory):
    user = factory.SubFactory(UserFactory)
    class Meta:
        model = models.Order

Bulk Updates to Products

In the Django admin interface it is possible to apply actions in bulk. An example of this is deletion: from the change list view we can select multiple items and then select the delete action from the drop-down menu at the top of the table.

These are called “actions,” and it is possible to add custom actions to specific instances of ModelAdmin. We are going to add a couple to mark products as active or inactive.

To do so, let’s change main/admin.py:
...
def make_active(self, request, queryset):
    queryset.update(active=True)
make_active.short_description = "Mark selected items as active"
def make_inactive(self, request, queryset):
    queryset.update(active=False)
make_inactive.short_description = (
    "Mark selected items as inactive"
)
class ProductAdmin(admin.ModelAdmin):
    ...
    actions = [make_active, make_inactive]

As you can see, it is a very simple change. The result of this can be seen in the product list page by clicking the drop-down button on the left before the column names, as shown in Figure 3-5.

Printing Order Invoices (As PDFs)

The last thing we are going to tackle is a common occurrence for e-commerce shops: printing invoices. In Django there is no facility to generate PDFs, so we will need to install a third-party library.

There are multiple Python PDF libraries available online; in our case we will choose WeasyPrint . This library allows us to create PDFs out of HTML pages, and that is how we will start here. If you want more flexibility, perhaps you should rely on a different library.

WeasyPrint requires a couple of system libraries installed in the system: Cairo and Pango. They are both used for rendering the document. You can install those with your package manager. You also require the fonts required to render the CSS properly.

Let’s install WeasyPrint:
$ pipenv install WeasyPrint
We will create an admin view for this that we can add to the relevant AdminSite classes:
...
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
import tempfile
...
class InvoiceMixin:
    def get_urls(self):
        urls = super().get_urls()
        my_urls = [
            path(
                "invoice/<int:order_id>/",
                self.admin_view(self.invoice_for_order),
                name="invoice",
            )
        ]
        return my_urls + urls
    def invoice_for_order(self, request, order_id):
        order = get_object_or_404(models.Order, pk=order_id)
        if request.GET.get("format") == "pdf":
            html_string = render_to_string(
                "invoice.html", {"order": order}
            )
            html = HTML(
                string=html_string,
                base_url=request.build_absolute_uri(),
            )
            result = html.write_pdf()
            response = HttpResponse(
                content_type="application/pdf"
            )
            response[
                "Content-Disposition"
            ] = "inline; filename=invoice.pdf"
            response["Content-Transfer-Encoding"] = "binary"
            with tempfile.NamedTemporaryFile(
                delete=True
            ) as output:
                output.write(result)
                output.flush()
                output = open(output.name, "rb")
                binary_pdf = output.read()
                response.write(binary_pdf)
            return response
        return render(request, "invoice.html", {"order": order})
# This mixin will be used for the invoice functionality, which is
# only available to owners and employees, but not dispatchers
class OwnersAdminSite(InvoiceMixin, ReportingColoredAdminSite):
    ...
class CentralOfficeAdminSite(
    InvoiceMixin, ReportingColoredAdminSite
):
    ...

This Django view has two rendering modes, HTML and PDF. Both modes use the same invoice.html template, but in the case of PDF, WeasyPrint is used to post-process the output of the templating engine.

When generating PDFs, instead of using the normal render() call, we use the render_to_string() method and store the result in memory. The PDF library will then use this to generate the PDF body, which we will store in a temporary file. In our case, the temporary file will be deleted, but we could persist this in a FileField if we wanted to.

The template used in our case is main/templates/invoice.html:
{% load static %}
<!doctype html>
<html lang="en">
  <head>
    <link
      rel="stylesheet"
      href="{% static "css/bootstrap.min.css" %}">
    <title>Invoice</title>
  </head>
  <body>
    <div class="container-fluid">
      <div class="row">
        <div class="col">
          <h1>BookTime</h1>
          <h2>Invoice</h2>
        </div>
      </div>
      <div class="row">
        <div class="col-8">
          Invoice number BT{{ order.id }}
          <br/>
          Date:
          {{ order.date_added|date }}
        </div>
        <div class="col-4">
          {{ order.billing_name }}<br/>
          {{ order.billing_address1  }}<br/>
          {{ order.billing_address2  }}<br/>
          {{ order.billing_zip_code }}<br/>
          {{ order.billing_city }}<br/>
          {{ order.billing_country }}<br/>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <table
            class="table"
            style="width: 95%; margin: 50px 0px 50px 0px">
            <tr>
              <th>Product name</th>
              <th>Price</th>
            </tr>
            {% for line in order.lines.all %}
              <tr>
                <td>{{ line.product.name }}</td>
                <td>{{ line.product.price }}</td>
              </tr>
            {% endfor %}
          </table>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <p>
            Please pay within 30 days
          </p>
          <p>
            BookTime inc.
          </p>
        </div>
      </div>
    </div>
  </body>
</html>
This is enough to have the functionality working, but it is not visible in the admin interface yet. To make it visible, we will add buttons to the change order view, as shown in Figure 7-2.
../images/466106_1_En_7_Chapter/466106_1_En_7_Fig2_HTML.png
Figure 7-2

Invoice buttons

Django admin allows us to override templates that are specific to a model view. We have already overridden some admin templates by creating new ones in the templates/ folder, and we will follow a similar approach. We are going to create templates/admin/main/order/change_form.html:
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
  {% url 'admin:invoice' original.pk as invoice_url %}
  {% if invoice_url %}
    <li>
      <a href="{{ invoice_url }}">View Invoice</a>
    </li>
    <li>
      <a href="{{ invoice_url }}?format=pdf">
        Download Invoice as PDF
      </a>
    </li>
  {% endif %}
  {{ block.super }}
{% endblock %}

At this point, please go ahead and try to retrieve and view the PDF. If it does not generate correctly, you may have to go on the WeasyPrint forums and work out why. You will find most times that the issue is a missing dependency in your system.

Testing Invoice Generation

The last thing this functionality needs is a test. We want to make sure that, given an order with some specific data, the results for both HTML and PDF versions are exactly what we expect.

This test relies on two fixtures, the HTML invoice and the PDF version. Before running this test, create an order with the test data as shown next and download both the invoices to the right folders.
from datetime import datetime
from decimal import Decimal
from unittest.mock import patch
...
class TestAdminViews(TestCase):
    ...
    def test_invoice_renders_exactly_as_expected(self):
        products = [
            factories.ProductFactory(
                name="A", active=True, price=Decimal("10.00")
            ),
            factories.ProductFactory(
                name="B", active=True, price=Decimal("12.00")
            ),
        ]
        with patch("django.utils.timezone.now") as mock_now:
            mock_now.return_value = datetime(
                2018, 7, 25, 12, 00, 00
            )
            order = factories.OrderFactory(
                id=12,
                billing_name="John Smith",
                billing_address1="add1",
                billing_address2="add2",
                billing_zip_code="zip",
                billing_city="London",
                billing_country="UK",
            )
        factories.OrderLineFactory.create_batch(
            2, order=order, product=products[0]
        )
        factories.OrderLineFactory.create_batch(
            2, order=order, product=products[1]
        )
        user = models.User.objects.create_superuser(
            "user2", "pw432joij"
        )
        self.client.force_login(user)
        response = self.client.get(
            reverse(
                "admin:invoice", kwargs={"order_id": order.id}
            )
        )
        self.assertEqual(response.status_code, 200)
        content = response.content.decode("utf8")
        with open(
            "main/fixtures/invoice_test_order.html", "r"
        ) as fixture:
            expected_content = fixture.read()
        self.assertEqual(content, expected_content)
        response = self.client.get(
            reverse(
                "admin:invoice",  kwargs={"order_id":  order.id}
            ),
            {"format": "pdf"}
        )
        self.assertEqual(response.status_code, 200)
        content = response.content
        with open(
            "main/fixtures/invoice_test_order.pdf", "rb"
        ) as fixture:
            expected_content = fixture.read()
        self.assertEqual(content, expected_content)

Summary

This chapter was a deep dive into the Django admin interface. We saw how customizable this app can be. We also talked about the dangers of pushing this app too far: if the user flow we want to offer is different from the simple create/edit/delete approach, customizing the admin may not be worth it, and instead adding custom views outside of the admin may be better.

We also talked about reporting and how to structure ORM queries for this. Django documentation goes a lot deeper on this, and I encourage you to research it for more advanced queries.

We also covered PDF generation. In our case, this is done only for people in the back office. Some sites offer invoice generation directly available to website users. In that case, it would be easy to adapt the code in this chapter to offer it in a normal (non-admin) view.

In the next chapter we will talk about an extension of Django called Channels, and how we can use this to build a chat page to interact with our customers.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.149.254.35