-
-
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 %} |
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
thanks