Skip to content

Instantly share code, notes, and snippets.

@hakib
Last active January 22, 2024 15:18
Show Gist options
  • Star 39 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save hakib/ec462baef03a6146654e4c095142b5eb to your computer and use it in GitHub Desktop.
Save hakib/ec462baef03a6146654e4c095142b5eb to your computer and use it in GitHub Desktop.
How to Turn Django Admin Into a Lightweight Dashboard
# https://hakibenita.com/how-to-turn-django-admin-into-a-lightweight-dashboard
from django.contrib import admin
from django.db.models import Count, Sum, Min, Max, DateTimeField
from django.db.models.functions import Trunc
from . import models
def get_next_in_date_hierarchy(request, date_hierarchy):
if date_hierarchy + '__day' in request.GET:
return 'hour'
if date_hierarchy + '__month' in request.GET:
return 'day'
if date_hierarchy + '__year' in request.GET:
return 'week'
return 'month'
@admin.register(models.SaleSummary)
class LoadContractSummaryAdmin(admin.ModelAdmin):
change_list_template = 'admin/dashboard/sales_change_list.html'
actions = None
date_hierarchy = 'created'
# Prevent additional queries for pagination.
show_full_result_count = False
list_filter = (
'device',
)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def has_change_permission(self, request, obj=None):
return True
def has_module_permission(self, request):
return True
def changelist_view(self, request, extra_context=None):
response = super().changelist_view(request, extra_context=extra_context)
# self.get_queryset would return the base queryset. ChangeList
# apply the filters from the request so this is the only way to
# get the filtered queryset.
try:
qs = response.context_data['cl'].queryset
except (AttributeError, KeyError):
# See issue #172.
# When an invalid filter is used django will redirect. In this
# case the response is an http redirect response and so it has
# no context_data.
return response
# List view
metrics = {
'total': Count('id'),
'total_sales': Sum('price'),
}
response.context_data['summary'] = list(
qs
.values('sale__category__name')
.annotate(**metrics)
.order_by('-total_sales')
)
# List view summary
response.context_data['summary_total'] = dict(qs.aggregate(**metrics))
# Chart
period = get_next_in_date_hierarchy(request, self.date_hierarchy)
response.context_data['period'] = period
summary_over_time = qs.annotate(
period=Trunc('created', 'day', output_field=DateTimeField()),
).values('period')
.annotate(total=Sum('price'))
.order_by('period')
summary_range = summary_over_time.aggregate(
low=Min('total'),
high=Max('total'),
)
high = summary_range.get('high', 0)
low = summary_range.get('low', 0)
response.context_data['summary_over_time'] = [{
'period': x['period'],
'total': x['total'] or 0,
'pct': \
((x['total'] or 0) - low) / (high - low) * 100
if high > low else 0,
} for x in summary_over_time]
return response
# https://hakibenita.com/how-to-turn-django-admin-into-a-lightweight-dashboard
from app.models import Sale
class SaleSummary(Sale):
class Meta:
proxy = True
verbose_name = 'Sale Summary'
verbose_name_plural = 'Sales Summary'
{% extends "admin/change_list.html" %}
{% load i18n %}
{% load humanize %}
{% load mathtags %}
{% load tz %}
{% block content_title %}
<h1> {% trans 'Sales Summary' %} </h1>
{% endblock %}
{% block result_list %}
<div class="results">
<table>
<thead>
<tr>
<th> <div class="text"> <a href="#">Category </a> </div> </th>
<th> <div class="text"> <a href="#">Total </a> </div> </th>
<th> <div class="text"> <a href="#">Total Sales </a> </div> </th>
<th> <div class="text"> <a href="#"><strong>% Of Total Sales</strong></a> </div> </th>
</tr>
</thead>
<tbody>
{% for row in summary %}
<tr class="{% cycle 'row1' 'row2' %}">
<td> {{ row.category }} </td>
<td> {{ row.total }} </td>
<td> {{ row.total_sales | default:0 }} </td>
<td><strong> {{ row.total_sales | default:0 | percentof:summary_total.total_sales }} </strong> </td>
</tr>
{% endfor %}
<tr style="font-weight:bold; border-top:2px solid #DDDDDD;">
<td> Total </td>
<td> {{ summary_total.total | intcomma }} </td>
<td> {{ summary_total.total_sales | default:0 }} </td>
<td> 100% </td>
</tr>
</tbody>
</table>
</div>
<h2> {% blocktrans %} Sales time (by {{ period}}) {% endblocktrans %} </h2>
<style>
.bar-chart {
height: 160px;
padding-top: 60px;
display: flex;
justify-content: space-around;
overflow: hidden;
}
.bar-chart .bar {
background-color: #79aec8;
flex: 100%;
align-self: flex-end;
margin-right: 2px;
position: relative;
}
.bar-chart .bar:last-child {
margin: 0;
}
.bar-chart .bar:hover {
background-color: #417690;
}
.bar-chart .bar .bar-tooltip {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
position: relative;
z-index: 999;
}
.bar-chart .bar .bar-tooltip {
position: absolute;
top: -60px;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: bold;
opacity: 0;
}
.bar-chart .bar:first-child .bar-tooltip {
transform: initial;
text-align: initial;
left: 0;
}
.bar-chart .bar:last-child .bar-tooltip {
transform: initial;
text-align: right;
right: 0;
left: initial;
}
.bar-chart .bar:hover .bar-tooltip {
opacity: 1;
}
</style>
{% timezone 'UTC' %}
<div class="results">
<div class="bar-chart">
{% for x in summary_over_time %}
<div class="bar" style="height:{{x.pct}}%">
<div class="bar-tooltip">
{{x.total }}<br>
{{x.period | date:"d/m/Y H:i"}}
</div>
</div>
{% endfor %}
</div>
</div>
{% endtimezone %}
{% endblock %}
{% block pagination %}{% endblock %}
@hakib
Copy link
Author

hakib commented Dec 23, 2018

This is the source code for the article How to Turn Django Admin Into a Lightweight Dashboard

@dannluciano
Copy link

Thanks Man! I love with your blog.
😍

@dannluciano
Copy link

In the end of 4-th line has a not necessary closed parentheses

@hakib
Copy link
Author

hakib commented Sep 3, 2019

Thanks Man! I love with your blog.

That's awesome!

In the end of 4-th line has a not necessary closed parentheses

Which file?

@thinmy
Copy link

thinmy commented Sep 13, 2019

admin.py

@hakib
Copy link
Author

hakib commented Sep 14, 2019

Thanks, updated!

@Naxaes
Copy link

Naxaes commented Oct 3, 2019

This is great! Is this free to use and modify for commercial use or is there a license attached to this?

@hakib
Copy link
Author

hakib commented Oct 3, 2019

Hey @Naxaes, you can use this snippet. Be sure to buy me a beer after your first 1M$...

@Naxaes
Copy link

Naxaes commented Oct 3, 2019

It's a deal! :D

@hakib
Copy link
Author

hakib commented Dec 8, 2019

Hey @OmarGonD,
I'm not sure I understand your question. In your model admin you override changelist_view, so calling super() execute the function in ModelAdmin.

@OmarGonD
Copy link

OmarGonD commented Dec 8, 2019

@habik I've manage to solved my last question. But I'm having trouble rendering the percent that a particular sale represents of total.

I'm pasting my code, so hopefully you can take a look:

image

Code:

models.py:

class Order(models.Model):
    ORDER_STATUS = (
        ('recibido_pagado', 'Recibido y pagado'),
        ('recibido_no_pagado', 'Recibido pero no pagado'),
        ('en_proceso', 'En proceso'),
        ('en_camino', 'En camino'),
        ('entregado', 'Entregado'),
        ('cancelado', 'Cancelado por no pagar' )
    )
    token = models.CharField(max_length=100, blank=True, null=True)
    first_name = models.CharField(max_length=50, blank=True, null=True)
    last_name = models.CharField(max_length=50, blank=True, null=True)
    phone_number = models.CharField(max_length=30, blank=True)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    stickers_price = models.DecimalField(max_digits=10, decimal_places=2)
    discount = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
    shipping_cost = models.DecimalField(max_digits=10, decimal_places=2)
    email = models.EmailField(max_length=250, blank = True, verbose_name= 'Correo electrónico')
    last_four = models.CharField(max_length=100, blank=True, null=True)
    created = models.DateTimeField(auto_now_add=True)
    shipping_address = models.CharField(max_length=100, blank=True, null=True)
    shipping_address1 = models.CharField(max_length=100, blank=True, null=True)
    reference = models.CharField(max_length=100, blank=True, null=True)
    shipping_department = models.CharField(max_length=100, blank=True, null=True)
    shipping_province = models.CharField(max_length=100, blank=True, null=True)
    shipping_district = models.CharField(max_length=100, blank=True, null=True)
    reason = models.CharField(max_length=400, blank=True, null=True, default='')
    status = models.CharField(max_length=20, choices=ORDER_STATUS, default='recibido_pagado')
    comments = models.CharField(max_length=400, blank=True, null=True, default='')
    cupon = models.ForeignKey('marketing.Cupons', blank=True, null=True, default=None, on_delete=models.SET_NULL)

class OrderSummary(Order): #Extends funcs of model without creating a table in DB
    class Meta:
        proxy = True #important A proxy model extends the functionality of another model without creating an actual table in the database
        verbose_name = 'Order Summary'
        verbose_name_plural = 'Orders Summary'

admin.py:



@admin.register(OrderSummary)
class OrderSummaryAdmin(admin.ModelAdmin):
    change_list_template = 'admin/order_summary_change_list.html'
    date_hierarchy = 'created'

    def changelist_view(self, request, extra_context=None):
        response = super().changelist_view(
            request,
            extra_context=extra_context,
        )
        try:
            qs = response.context_data['cl'].queryset
        except (AttributeError, KeyError):
            return response
        
        metrics = {
            'num': Count('id'),
            'total_sales': Sum('total'),
            'total_shipping_cost': Sum('shipping_cost'),
            'total_no_shipping_cost': Sum(F('total') - F('shipping_cost')),
            'percent_of_total': Sum(F('total') - F('shipping_cost')/ F('total')),
        }

        response.context_data['summary'] = list(
            qs
            .values('id','total', 'shipping_cost')
            .annotate(**metrics)
            .order_by('-created')
        )

        response.context_data['summary_total'] = dict(
            qs.aggregate(**metrics)
        )
       
        return response

template.html:

{% extends "admin/change_list.html" %}
{% load humanize %}

{% block content_title %}
    <h1> Sales Summary </h1>
{% endblock %}
{% block result_list %}
<div class=”results”>
    <table>
        
    <thead>
      <tr>
        <th>
          <div class=”text”>
            <a href=”#”># ÓRDENES</a>
          </div>
        </th>
        <th>
          <div class=”text”>
            <a href=”#”>TOTAL VENTAS</a>
          </div>
        </th>
        <th>
            <div class=”text”>
              <a href=”#”>
                <strong>TOTAL NO SHIPPING</strong>
              </a>
            </div>
          </th>
        <th>
          <div class=”text”>
            <a href=”#”>
              <strong>SHIPPING COST</strong>
            </a>
          </div>
        </th>
        <th>
                <div class=”text”>
                  <a href=”#”>
                    <strong>PORCENTAJE DEL TOTAL</strong>
                  </a>
                </div>
              </th>
      </tr>
    </thead>
    <tbody>
        {% for row in summary %}
        <tr class="{% cycle 'row1' 'row2' %}">
          <td> {{ row.id }}  </td>
          <td> {{ row.total | intcomma }} </td>
          <td> S/ {{ row.total_no_shipping_cost | default:0 | intcomma }} </td>
          <td> S/ {{ row.shipping_cost | default:0 | intcomma }} </td>
          <td>
              <strong>
            {{ row.total | 
              default:0 | 
              percentof:row.total }} 
          </strong>
          </td>
        </tr>

        {% endfor %}
        <tr style="font-weight:bold; border-top:2px solid #DDDDDD;">
            <td> # {{ summary_total.num | intcomma }} </td>
            <td> S/ {{ summary_total.total_sales | intcomma }} </td>
            <td> S/ {{ summary_total.total_no_shipping_cost | default:0 }} </td>
            <td> S/ {{ summary_total.total_shipping_cost | default:0 }} </td>
            <td>  
                <strong>
                100%
                </strong> 
            </td>
        </tr>
      </tbody>

      
    
  </table>
</div>
{% endblock %}
{% block pagination %}{% endblock %}

@hakib
Copy link
Author

hakib commented Dec 8, 2019

My guess is that you are unable render the percent because you put the expression in multiple lines:

{{ row.total | 
              default:0 | 
              percentof:row.total }} 

Also, the templatetag percentof is not a built-in, you need to implement it your self.

@mascDriver
Copy link

my self.date_hierarchy + '__month' in request.GET return always false, could you post your model that defines the 'created'?

@hakib
Copy link
Author

hakib commented Jan 28, 2020

You need to set date_hierarchy on the model admin (as the example above shows) to a date field on your model + you need the actually apply the date hierarchy filter for it to return True.

@cyh-dev
Copy link

cyh-dev commented Apr 12, 2020

{% load mathtags %} is error

@hakib
Copy link
Author

hakib commented Apr 12, 2020

That's right @yuhao,

You need to create it and register it yourself:

from django import template

register = template.Library()


@register.filter()
def divide(n1, n2):
    try:
        return n1 / n2
    except (ZeroDivisionError, TypeError):
        return None


@register.filter()
def floor_divide(n1, n2):
    try:
        return n1 // n2
    except (ZeroDivisionError, TypeError):
        return None


@register.filter()
def multiply(n1, n2):
    try:
        return n1 * n2
    except TypeError:
        return None

@cyh-dev
Copy link

cyh-dev commented Apr 12, 2020

thanks

@alfonsrv
Copy link

alfonsrv commented Mar 7, 2021

The code above seems to only consider categories, if they are currently part of a sales instance in the current queryset. So if you don't have an "Electronics" sale on a given date, the category won't show up at all. Any way of having them show as well with 0 sales?

@hakib
Copy link
Author

hakib commented Mar 8, 2021

Hey, that's right. If you have gaps in the data the chart will not show them.

I wrote about an SQL solution here but it won't be easy to implement this in Django, so what you can do it fetch the results and attach them to an axis in the admin code.

@alfonsrv
Copy link

alfonsrv commented Mar 8, 2021

Interesting. I solved this by adding a simple dict "enrichment" as follows in line 63 of admin.py.

Also note that my Categories are an IntegerEnum, thus vid__category only returns numbers and the names are manually added in an additional step.

Not the most beautiful, but also not the most horrendous workaround imo.

        # annotation-metrics
        metrics = {
            'visits': Count('vid', distinct=True),
            'visitors': Count('uid', distinct=True),
            'hits': Count('pk'),
        }

        # instead of adding it to the context directly, we 
        # adjust the dict beforehand
        summary_dicts = list(
            qs
                .values('vid__category')  # Clustering by Category
                .annotate(**metrics)
                .order_by('-visits')
        )

        # Getting Category Name by Integer
        for summary_dict in summary_dicts:
            summary_dict.setdefault('category', VisitCategory(
                    summary_dict['vid__category']
                ).name.capitalize()
            )

        # adding categories with 0 visits
        for category in VisitCategory.choices:
            if not any(d['vid__category'] == category[0] for d in summary_dicts):
                # this could probably be improved by dynamically getting all available 
                # `metrics` / keys from an already existing dict
                summary_dicts.append(
                    {
                        'vid__category': category[0],
                        'category': category[1].capitalize(),
                        'visits': 0,
                        'visitors': 0,
                        'hits': 0
                    }
                )

        summary_dicts = sorted(summary_dicts, key=lambda k: k['vid__category'])  # sort by category rq
        response.context_data['summary'] = summary_dicts

        # List view summary
        response.context_data['summary_total'] = dict(qs.aggregate(**metrics))

@drn8
Copy link

drn8 commented Jan 3, 2023

Hello everyone,

would there be a way to make pagination work and thus reduce page loading times when you have many rows ?
I have tested removing the pagination block at the bottom but without success.

@hakib
Copy link
Author

hakib commented Jan 3, 2023

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment