feat: initial dashboard
This commit is contained in:
@@ -133,12 +133,15 @@ table th {
|
|||||||
background-color: var(--bg-dim);
|
background-color: var(--bg-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
thead>tr {
|
thead>tr, tr.thead {
|
||||||
border: 1px solid #DDD;
|
border: 1px solid #DDD;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 4rem;
|
top: 4rem;
|
||||||
z-index: 98;
|
z-index: 98;
|
||||||
}
|
}
|
||||||
|
tr.thead>th {
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
/* .detail-table {
|
/* .detail-table {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@@ -161,6 +164,10 @@ tbody>tr {
|
|||||||
border: 1px solid #DDD;
|
border: 1px solid #DDD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tr.blank {
|
||||||
|
height: 3ex;
|
||||||
|
}
|
||||||
|
|
||||||
td.table-actions {
|
td.table-actions {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -295,6 +302,12 @@ table.clickable tbody>tr:hover {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.div-by-side>div {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
#page-content-wrapper {
|
#page-content-wrapper {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
63
app/base/templates/dashboard.html
Executable file
63
app/base/templates/dashboard.html
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load utils %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="div-by-side">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th><h4 class="mb-0">{{ head.traits }}</h4></th><th>Personen</th></tr>
|
||||||
|
</thead>
|
||||||
|
{% for stat in trait.by_type %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ trait.labels|get_item:stat.trait }}</th>
|
||||||
|
<td class="text-center"><a href="{% url 'trait-mapping:list' %}?trait={{stat.trait}}">{{ stat.count }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th><h4 class="mb-0">{{ head.booking_types }}</h4></th><th>Buchungen</th><th>Dauer (ø)</th><th>Gesamt</th></tr>
|
||||||
|
</thead>
|
||||||
|
{% for stat in booking.by_type %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ booking.labels|get_item:stat.type }}</th>
|
||||||
|
<td class="text-center">{{ stat.count }}</td>
|
||||||
|
<td class="text-right">{{ stat.sum|divide:stat.count|floatformat:1 }} Min.</td>
|
||||||
|
<td class="text-right">{{ stat.sum }} Min.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ head.bookings }}</h3>
|
||||||
|
<table class="table table-sm">
|
||||||
|
{% for year, stat in booking.by_month.items reversed %}
|
||||||
|
<tr class="thead">
|
||||||
|
<th><h4 class="mb-0">{{ year }}</h4></th>
|
||||||
|
<th>∑</th><th>Jan</th><th>Feb</th><th>Mär</th><th>Apr</th><th>Mai</th><th>Jun</th><th>Jul</th><th>Aug</th><th>Sep</th><th>Okt</th><th>Nov</th><th>Dez</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Buchungen</th>
|
||||||
|
{% for sum, count in stat %}<td>{{ count }}</td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Minuten (ø)</th>
|
||||||
|
{% for sum, count in stat %}<td>{{ sum|divide:count|floatformat:1 }}</td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Minuten</th>
|
||||||
|
{% for sum, count in stat %}<td>{{ sum }}</td>{% endfor %}
|
||||||
|
</tr>
|
||||||
|
<tr class="blank"></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock content %}
|
||||||
@@ -25,6 +25,11 @@ def invert(number):
|
|||||||
return - number
|
return - number
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def divide(number: 'int|float', other: 'int|float'):
|
||||||
|
return number / (other or 1)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_item(dictionary, key):
|
def get_item(dictionary, key):
|
||||||
return dictionary and dictionary.get(key)
|
return dictionary and dictionary.get(key)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.urls import include, path
|
|||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
from app.base.views.settings import SettingsView
|
from app.base.views.settings import SettingsView
|
||||||
|
from app.base.views.dashboard import DashboardView
|
||||||
from app.base.views.trigger.toggle_checkin import ToggleCheckinView
|
from app.base.views.trigger.toggle_checkin import ToggleCheckinView
|
||||||
from app.base.views.trigger.update_note import UpdateNoteView
|
from app.base.views.trigger.update_note import UpdateNoteView
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ urlpatterns = [
|
|||||||
path('login/', LoginView.as_view(), name='login'),
|
path('login/', LoginView.as_view(), name='login'),
|
||||||
path('logout/', LogoutView.as_view(next_page='login'), name='logout'),
|
path('logout/', LogoutView.as_view(next_page='login'), name='logout'),
|
||||||
path('settings/', SettingsView.as_view(), name='settings'),
|
path('settings/', SettingsView.as_view(), name='settings'),
|
||||||
|
path('dashboard/', DashboardView.as_view(), name='dashboard'),
|
||||||
path('users/<int:user_id>/toggle-checkin/',
|
path('users/<int:user_id>/toggle-checkin/',
|
||||||
ToggleCheckinView.as_view(), name='toggle-checkin'),
|
ToggleCheckinView.as_view(), name='toggle-checkin'),
|
||||||
path('users/<int:user_id>/update-note/',
|
path('users/<int:user_id>/update-note/',
|
||||||
|
|||||||
83
app/base/views/dashboard.py
Executable file
83
app/base/views/dashboard.py
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
from django.db.models import Q, F, Sum, Count, ExpressionWrapper, IntegerField
|
||||||
|
from django.db.models.functions import Substr
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from app.base.models import Booking, BookingType
|
||||||
|
from app.base.models.trait import Trait
|
||||||
|
from app.base.models.trait_mapping import TraitMapping
|
||||||
|
from app.base.views.login import LoginRequired
|
||||||
|
from app.base.views.model_views.base import ViewOptions
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOptions(ViewOptions, LoginRequired):
|
||||||
|
icon = 'chart-line'
|
||||||
|
title = 'Dashboard'
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView(DashboardOptions, TemplateView):
|
||||||
|
template_name = 'dashboard.html'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs) -> 'dict[str, Any]':
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
book_types = dict(BookingType.objects.values_list('key', 'label'))
|
||||||
|
trait_types = dict(Trait.objects.values_list('key', 'label'))
|
||||||
|
|
||||||
|
# Booking stats
|
||||||
|
|
||||||
|
book_by_type = stats_for_booking('type')
|
||||||
|
book_by_month = {}
|
||||||
|
|
||||||
|
for stat in stats_for_booking('month'):
|
||||||
|
year, month = stat['month'].split('-')
|
||||||
|
if year not in book_by_month:
|
||||||
|
book_by_month[year] = [[0, 0] for x in range(13)]
|
||||||
|
book_by_month[year][0][0] += stat['sum']
|
||||||
|
book_by_month[year][0][1] += stat['count']
|
||||||
|
book_by_month[year][int(month)][0] += stat['sum']
|
||||||
|
book_by_month[year][int(month)][1] += stat['count']
|
||||||
|
|
||||||
|
context['booking'] = {
|
||||||
|
'labels': book_types,
|
||||||
|
'by_type': book_by_type,
|
||||||
|
'by_month': book_by_month,
|
||||||
|
}
|
||||||
|
context['trait'] = {
|
||||||
|
'labels': trait_types,
|
||||||
|
'by_type': stats_for_traits(),
|
||||||
|
}
|
||||||
|
context['head'] = {
|
||||||
|
'bookings': Booking._meta.verbose_name_plural,
|
||||||
|
'booking_types': BookingType._meta.verbose_name_plural,
|
||||||
|
'traits': Trait._meta.verbose_name_plural,
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def stats_for_booking(groupby: str):
|
||||||
|
_Q = Booking.objects.filter(Q(end_time__isnull=False)).annotate(
|
||||||
|
# for whatever reason django uses nano seconds
|
||||||
|
diff=ExpressionWrapper(
|
||||||
|
(F('end_time') - F('begin_time')) / 60_000_000,
|
||||||
|
output_field=IntegerField()),
|
||||||
|
).filter(diff__lt=1440) # filter 24+ hours. Probably wrong checkout
|
||||||
|
|
||||||
|
if groupby == 'month':
|
||||||
|
_Q = _Q.annotate(month=Substr('begin_time', 1, 7)) # YYYY-MM
|
||||||
|
|
||||||
|
return _Q.values(groupby).annotate(
|
||||||
|
sum=Sum('diff', output_field=IntegerField()),
|
||||||
|
count=Count(1),
|
||||||
|
).values(groupby, 'sum', 'count').order_by(groupby)
|
||||||
|
|
||||||
|
|
||||||
|
def stats_for_traits():
|
||||||
|
return TraitMapping.objects.filter(
|
||||||
|
Q(valid_from__lte=date.today()),
|
||||||
|
Q(valid_until=None) | Q(valid_until__gte=date.today()),
|
||||||
|
).values('trait').annotate(
|
||||||
|
count=Count(1),
|
||||||
|
).values('trait', 'count').order_by('trait')
|
||||||
Reference in New Issue
Block a user