-
-
Save hakib/ec462baef03a6146654e4c095142b5eb to your computer and use it in GitHub Desktop.
# 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 %} |
Thanks Man! I love with your blog.
😍
In the end of 4-th line has a not necessary closed parentheses
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?
admin.py
Thanks, updated!
This is great! Is this free to use and modify for commercial use or is there a license attached to this?
Hey @Naxaes, you can use this snippet. Be sure to buy me a beer after your first 1M$...
It's a deal! :D
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
.
@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:
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 %}
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.
my self.date_hierarchy + '__month' in request.GET
return always false, could you post your model that defines the 'created'?
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.
{% load mathtags %} is error
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
thanks
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?
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.
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))
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.
See if this helps https://hakibenita.com/optimizing-the-django-admin-paginator
This is the source code for the article How to Turn Django Admin Into a Lightweight Dashboard