doorcontrol: Use django-tables2 for access reports
All checks were successful
Ruff / ruff (push) Successful in 23s
All checks were successful
Ruff / ruff (push) Successful in 23s
This commit is contained in:
parent
bfe9fc7c2c
commit
8317ae83c2
@ -1,5 +1,7 @@
|
|||||||
{% extends "base.dj.html" %}
|
{% extends "base.dj.html" %}
|
||||||
|
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
{% block title %}{{ selected_report }} | Door Controls | CMS{% endblock %}
|
{% block title %}{{ selected_report }} | Door Controls | CMS{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="vstack align-items-center">
|
<div class="vstack align-items-center">
|
||||||
@ -11,9 +13,9 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<form method="get" class="container">
|
<form method="get" class="container-fluid">
|
||||||
<div class="row align-items-center">
|
<div class="row g-2 align-items-center justify-content-center">
|
||||||
<div class="col gy-1">
|
<div class="col-6 col-sm-auto">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="date"
|
<input type="date"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@ -23,7 +25,7 @@
|
|||||||
<label for="startDate">Start Date</label>
|
<label for="startDate">Start Date</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col gy-1">
|
<div class="col-6 col-sm-auto">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="date"
|
<input type="date"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@ -33,7 +35,7 @@
|
|||||||
<label for="endDate">End Date</label>
|
<label for="endDate">End Date</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md gy-1">
|
<div class="col-12 col-sm-auto">
|
||||||
<div class="form-floating">
|
<div class="form-floating">
|
||||||
<input type="number"
|
<input type="number"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@ -47,51 +49,15 @@
|
|||||||
<label for="itemsPerPage">Items Per Page</label>
|
<label for="itemsPerPage">Items Per Page</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-auto gy-1">
|
<div class="btn-group col-auto" role="group" aria-label="Form Controls">
|
||||||
<button type="submit" class="btn btn-primary w-100">Submit</button>
|
<button type="submit" class="btn btn-sm btn-primary">Submit</button>
|
||||||
|
<a href="?" class="btn btn-sm btn-warning">Reset</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-md-auto gy-1">
|
<div class="col-auto">
|
||||||
<a href="?" class="btn btn-warning w-100">Reset</a>
|
{% include "membershipworks/components/download_table.dj.html" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="table-responsive mw-100">
|
{% render_table table %}
|
||||||
<table class="table table-striped table-hover mb-2 w-auto">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{% for column in object_list.0.keys %}<th>{{ column|title }}</th>{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for object in object_list %}
|
|
||||||
<tr>
|
|
||||||
{% for field in object.values %}<td>{{ field }}</td>{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<nav aria-label="Page navigation">
|
|
||||||
<div class="text-center mb-2">Showing {{ page_obj.object_list|length }} of {{ paginator.count }} results.</div>
|
|
||||||
<ul class="pagination justify-content-center">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link"
|
|
||||||
href="?{{ query_params }}&page={{ page_obj.previous_page_number }}">Previous</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% for page_num in paginator_range %}
|
|
||||||
<li class="page-item {% if page_num == page_obj.number %} active {% elif page_num == paginator.ELLIPSIS %} disabled {% endif %}">
|
|
||||||
<a class="page-link" href="?{{ query_params }}&page={{ page_num }}">{{ page_num }}</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link"
|
|
||||||
href="?{{ query_params }}&page={{ page_obj.next_page_number }}">Next</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -8,11 +8,14 @@ from django.db.models import Count, F, FloatField, Window
|
|||||||
from django.db.models.functions import Lead, Trunc
|
from django.db.models.functions import Lead, Trunc
|
||||||
from django.urls import path, reverse_lazy
|
from django.urls import path, reverse_lazy
|
||||||
from django.utils import dateparse
|
from django.utils import dateparse
|
||||||
from django.utils.formats import date_format
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import localtime
|
from django.utils.timezone import localtime
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
|
|
||||||
|
import django_tables2 as tables
|
||||||
|
from django_tables2 import SingleTableMixin
|
||||||
|
from django_tables2.export.views import ExportMixin
|
||||||
|
|
||||||
from .models import HIDEvent
|
from .models import HIDEvent
|
||||||
|
|
||||||
REPORTS = []
|
REPORTS = []
|
||||||
@ -23,13 +26,17 @@ def register_report(cls: "BaseAccessReport"):
|
|||||||
return cls
|
return cls
|
||||||
|
|
||||||
|
|
||||||
class BaseAccessReport(PermissionRequiredMixin, ListView):
|
class BaseAccessReport(
|
||||||
|
ExportMixin, SingleTableMixin, PermissionRequiredMixin, ListView
|
||||||
|
):
|
||||||
model = HIDEvent
|
model = HIDEvent
|
||||||
permission_required = "doorcontrol.view_hidevent"
|
permission_required = "doorcontrol.view_hidevent"
|
||||||
paginate_by = 20
|
paginate_by = 20
|
||||||
context_object_name = "object_list"
|
context_object_name = "object_list"
|
||||||
template_name = "doorcontrol/access_report.dj.html"
|
template_name = "doorcontrol/access_report.dj.html"
|
||||||
|
|
||||||
|
export_formats = ("csv", "xlsx", "ods")
|
||||||
|
|
||||||
_report_name = None
|
_report_name = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -44,6 +51,10 @@ class BaseAccessReport(PermissionRequiredMixin, ListView):
|
|||||||
slug = slugify(cls._report_name)
|
slug = slugify(cls._report_name)
|
||||||
return path(f"reports/{slug}", cls.as_view(), name=slug)
|
return path(f"reports/{slug}", cls.as_view(), name=slug)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def export_name(self):
|
||||||
|
return slugify(self._report_name)
|
||||||
|
|
||||||
def _selected_report(self):
|
def _selected_report(self):
|
||||||
return self._report_name
|
return self._report_name
|
||||||
|
|
||||||
@ -95,8 +106,23 @@ class BaseAccessReport(PermissionRequiredMixin, ListView):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTimeTable(tables.Table):
|
||||||
|
members = tables.columns.Column()
|
||||||
|
members_delta = tables.columns.TemplateColumn(
|
||||||
|
"{{ value|floatformat:2}}%", verbose_name="Δ Members"
|
||||||
|
)
|
||||||
|
access_count = tables.columns.Column()
|
||||||
|
access_count_delta = tables.columns.TemplateColumn(
|
||||||
|
"{{ value|floatformat:2}}%", verbose_name="Δ Access Count"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
fields = ("members", "members_delta", "access_count", "access_count_delta")
|
||||||
|
|
||||||
|
|
||||||
@register_report
|
@register_report
|
||||||
class AccessPerUnitTime(BaseAccessReport):
|
class AccessPerUnitTime(BaseAccessReport):
|
||||||
|
table_class = UnitTimeTable
|
||||||
UNIT_TIMES = ["day", "week", "month", "year"]
|
UNIT_TIMES = ["day", "week", "month", "year"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -115,23 +141,41 @@ class AccessPerUnitTime(BaseAccessReport):
|
|||||||
name="access-per-unit-time",
|
name="access-per-unit-time",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _report_name(self):
|
||||||
|
unit_time = self.kwargs["unit_time"]
|
||||||
|
return "Access per " + unit_time.title()
|
||||||
|
|
||||||
def _selected_report(self) -> str:
|
def _selected_report(self) -> str:
|
||||||
return "Access per " + self.kwargs["unit_time"].title()
|
return "Access per " + self.kwargs["unit_time"].title()
|
||||||
|
|
||||||
def _format_date(self, date: datetime.datetime) -> str:
|
def get_table_kwargs(self):
|
||||||
unit_time = self.kwargs["unit_time"]
|
unit_time = self.kwargs["unit_time"]
|
||||||
if unit_time == "day":
|
if unit_time == "week":
|
||||||
return date_format(date, "DATE_FORMAT")
|
unit_time_column = tables.TemplateColumn(
|
||||||
elif unit_time == "week":
|
verbose_name=unit_time.title(),
|
||||||
return (
|
template_code=(
|
||||||
date_format(date, "DATE_FORMAT")
|
"{{ value|date|default:default }} - "
|
||||||
+ " - "
|
"{{ value|add:one_week|date|default:default }}"
|
||||||
+ date_format(date + datetime.timedelta(weeks=1), "DATE_FORMAT")
|
),
|
||||||
|
extra_context={"one_week": datetime.timedelta(weeks=1)},
|
||||||
)
|
)
|
||||||
elif unit_time == "month":
|
else:
|
||||||
return date_format(date, "N Y")
|
if unit_time == "day":
|
||||||
elif unit_time == "year":
|
date_format = "DATE_FORMAT"
|
||||||
return date_format(date, "Y")
|
elif unit_time == "month":
|
||||||
|
date_format = "N Y"
|
||||||
|
elif unit_time == "year":
|
||||||
|
date_format = "Y"
|
||||||
|
|
||||||
|
unit_time_column = tables.DateColumn(
|
||||||
|
date_format, verbose_name=unit_time.title()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sequence": ("unit_time", "..."),
|
||||||
|
"extra_columns": (("unit_time", unit_time_column),),
|
||||||
|
}
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
unit_time = self.kwargs["unit_time"]
|
unit_time = self.kwargs["unit_time"]
|
||||||
@ -141,7 +185,7 @@ class AccessPerUnitTime(BaseAccessReport):
|
|||||||
granted_event_types = [
|
granted_event_types = [
|
||||||
t for t in HIDEvent.EventType if t.name.startswith("GRANTED_ACCESS")
|
t for t in HIDEvent.EventType if t.name.startswith("GRANTED_ACCESS")
|
||||||
]
|
]
|
||||||
events = (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(event_type__in=granted_event_types)
|
.filter(event_type__in=granted_event_types)
|
||||||
@ -172,64 +216,58 @@ class AccessPerUnitTime(BaseAccessReport):
|
|||||||
)
|
)
|
||||||
.order_by("-unit_time")
|
.order_by("-unit_time")
|
||||||
)
|
)
|
||||||
return [
|
|
||||||
{
|
|
||||||
unit_time: self._format_date(event["unit_time"]),
|
class DeniedAccessTable(tables.Table):
|
||||||
"members": event["members"],
|
name = tables.TemplateColumn(
|
||||||
"Δ members": (
|
"{{ record.forename|default:'' }} {{ record.surname|default:'' }}"
|
||||||
f'{event["members_delta"]:.2f}%'
|
)
|
||||||
if event["members_delta"] is not None
|
|
||||||
else ""
|
class Meta:
|
||||||
),
|
model = HIDEvent
|
||||||
"access count": event["access_count"],
|
|
||||||
"Δ access count": (
|
fields = (
|
||||||
f'{event["access_count_delta"]:.2f}%'
|
"timestamp",
|
||||||
if event["access_count_delta"] is not None
|
"door",
|
||||||
else ""
|
"event_type",
|
||||||
),
|
"name",
|
||||||
}
|
"raw_card_number",
|
||||||
for event in events
|
"decoded_card_number",
|
||||||
]
|
)
|
||||||
|
|
||||||
|
|
||||||
@register_report
|
@register_report
|
||||||
class DeniedAccess(BaseAccessReport):
|
class DeniedAccess(BaseAccessReport):
|
||||||
_report_name = "Denied Access"
|
_report_name = "Denied Access"
|
||||||
|
table_class = DeniedAccessTable
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
denied_event_types = [
|
denied_event_types = [
|
||||||
t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS")
|
t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS")
|
||||||
]
|
]
|
||||||
events = (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.filter(event_type__in=denied_event_types)
|
.filter(event_type__in=denied_event_types)
|
||||||
.with_decoded_card_number()
|
.with_decoded_card_number()
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
class MostActiveMembersTable(tables.Table):
|
||||||
"timestamp": event.timestamp,
|
cardholder_id = tables.Column()
|
||||||
"door name": event.door.name,
|
name = tables.TemplateColumn(
|
||||||
"event type": HIDEvent.EventType(event.event_type).label,
|
"{{ record.forename|default:'' }} {{ record.surname|default:'' }}"
|
||||||
"name": " ".join(
|
)
|
||||||
n for n in [event.forename, event.surname] if n is not None
|
access_count = tables.Column()
|
||||||
),
|
|
||||||
"raw card number": (
|
|
||||||
event.raw_card_number if event.raw_card_number is not None else ""
|
|
||||||
),
|
|
||||||
"card number": event.decoded_card_number() or "",
|
|
||||||
}
|
|
||||||
for event in events
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@register_report
|
@register_report
|
||||||
class MostActiveMembers(BaseAccessReport):
|
class MostActiveMembers(BaseAccessReport):
|
||||||
_report_name = "Most Active Members"
|
_report_name = "Most Active Members"
|
||||||
|
table_class = MostActiveMembersTable
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
counts = (
|
return (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.values("cardholder_id", "forename", "surname")
|
.values("cardholder_id", "forename", "surname")
|
||||||
@ -238,45 +276,45 @@ class MostActiveMembers(BaseAccessReport):
|
|||||||
.order_by("-access_count")
|
.order_by("-access_count")
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
class BusiestDayOfWeekTable(tables.Table):
|
||||||
"cardholder id": count["cardholder_id"],
|
timestamp__week_day = tables.Column("Week Day")
|
||||||
"name": " ".join(
|
events = tables.Column()
|
||||||
n for n in [count["forename"], count["surname"]] if n is not None
|
|
||||||
),
|
def render_timestamp__week_day(self, value):
|
||||||
"access count": count["access_count"],
|
return calendar.day_name[(value - 2) % 7]
|
||||||
}
|
|
||||||
for count in counts
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@register_report
|
@register_report
|
||||||
class BusiestDayOfWeek(BaseAccessReport):
|
class BusiestDayOfWeek(BaseAccessReport):
|
||||||
_report_name = "Busiest Day of the Week"
|
_report_name = "Busiest Day of the Week"
|
||||||
|
table_pagination = False
|
||||||
|
table_class = BusiestDayOfWeekTable
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return [
|
return (
|
||||||
{
|
super()
|
||||||
"week day": calendar.day_name[(count["timestamp__week_day"] - 2) % 7],
|
|
||||||
"events": count["events"],
|
|
||||||
}
|
|
||||||
for count in super()
|
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.values("timestamp__week_day")
|
.values("timestamp__week_day")
|
||||||
.annotate(events=Count("timestamp"))
|
.annotate(events=Count("timestamp"))
|
||||||
]
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BusiestTimeOfDayTable(tables.Table):
|
||||||
|
timestamp__hour = tables.TemplateColumn("{{ value }}:00", verbose_name="Hour")
|
||||||
|
events = tables.Column()
|
||||||
|
|
||||||
|
|
||||||
@register_report
|
@register_report
|
||||||
class BusiestTimeOfDay(BaseAccessReport):
|
class BusiestTimeOfDay(BaseAccessReport):
|
||||||
_report_name = "Busiest Time of Day"
|
_report_name = "Busiest Time of Day"
|
||||||
paginate_by = 24
|
table_pagination = False
|
||||||
|
table_class = BusiestTimeOfDayTable
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return [
|
return (
|
||||||
{"hour": f'{count["timestamp__hour"]}:00', "events": count["events"]}
|
super()
|
||||||
for count in super()
|
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.values("timestamp__hour")
|
.values("timestamp__hour")
|
||||||
.annotate(events=Count("timestamp"))
|
.annotate(events=Count("timestamp"))
|
||||||
]
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user