diff --git a/cmsmanage/urls.py b/cmsmanage/urls.py
index a7d7940..d46cfd5 100644
--- a/cmsmanage/urls.py
+++ b/cmsmanage/urls.py
@@ -34,6 +34,7 @@ urlpatterns = [
path("rentals/", include("rentals.urls")),
path("membershipworks/", include("membershipworks.urls")),
path("paperwork/", include("paperwork.urls")),
+ path("doorcontrol/", include("doorcontrol.urls")),
path("api/v1/", include((router.urls, "api"), namespace="v1")),
path("admin/", admin.site.urls),
path(
diff --git a/doorcontrol/templates/doorcontrol/access_report.dj.html b/doorcontrol/templates/doorcontrol/access_report.dj.html
new file mode 100644
index 0000000..466eec3
--- /dev/null
+++ b/doorcontrol/templates/doorcontrol/access_report.dj.html
@@ -0,0 +1,94 @@
+{% extends "base.dj.html" %}
+
+{% block title %}{{ selected_report }} | Door Controls | CMS{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% for column in object_list.0.keys %}{{ column|title }} | {% endfor %}
+
+
+
+ {% for object in object_list %}
+
+ {% for field in object.values %}{{ field }} | {% endfor %}
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/doorcontrol/urls.py b/doorcontrol/urls.py
new file mode 100644
index 0000000..f8702ab
--- /dev/null
+++ b/doorcontrol/urls.py
@@ -0,0 +1,23 @@
+from django.urls import path
+
+from . import views
+
+app_name = "doorcontrol"
+
+urlpatterns = [
+ path(
+ "reports/access-per-",
+ views.AccessPerUnitTime.as_view(),
+ name="access-per-unit-time",
+ ),
+ path(
+ "reports/denied-access",
+ views.DeniedAccess.as_view(),
+ name="denied-access",
+ ),
+ path(
+ "reports/most-active-members",
+ views.MostActiveMembers.as_view(),
+ name="most-active-members",
+ ),
+]
diff --git a/doorcontrol/views.py b/doorcontrol/views.py
index 91ea44a..4900716 100644
--- a/doorcontrol/views.py
+++ b/doorcontrol/views.py
@@ -1,3 +1,203 @@
-from django.shortcuts import render
+import datetime
-# Create your views here.
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.core.paginator import Page
+from django.core.exceptions import BadRequest
+from django.db.models import Count
+from django.db.models.functions import Trunc
+from django.urls import reverse_lazy
+from django.utils import dateparse
+from django.utils.formats import date_format
+from django.utils.text import slugify
+from django.utils.timezone import localtime
+from django.views.generic.list import ListView
+
+from .models import HIDEvent
+
+
+REPORT_TYPES = []
+
+
+def register_report(cls: "BaseAccessReport"):
+ REPORT_TYPES.extend(cls._report_types())
+ return cls
+
+
+class BaseAccessReport(PermissionRequiredMixin, ListView):
+ model = HIDEvent
+ permission_required = "doorcontrol.view_hidevent"
+ paginate_by = 20
+ context_object_name = "object_list"
+ template_name = "doorcontrol/access_report.dj.html"
+
+ _report_name = None
+
+ @classmethod
+ def _report_types(cls):
+ yield [
+ cls._report_name,
+ reverse_lazy("doorcontrol:" + slugify(cls._report_name)),
+ ]
+
+ def _selected_report(self):
+ return self._report_name
+
+ def _get_timestamp_range(self):
+ timestamp__gte = dateparse.parse_datetime(
+ self.request.GET.get("timestamp__gte") or "2019-01-01"
+ )
+ timestamp__lte = self.request.GET.get("timestamp__lte")
+ if timestamp__lte:
+ timestamp__lte = dateparse.parse_datetime(timestamp__lte)
+ else:
+ timestamp__lte = localtime()
+
+ return timestamp__gte, timestamp__lte
+
+ def get_paginate_by(self, queryset) -> int:
+ if "items_per_page" in self.request.GET:
+ return int(self.request.GET.get("items_per_page"))
+ return super().get_paginate_by(queryset)
+
+ def get_queryset(self):
+ return (
+ super().get_queryset().filter(timestamp__range=self._get_timestamp_range())
+ )
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["report_types"] = REPORT_TYPES
+
+ page: Page = context["page_obj"]
+ context["paginator_range"] = page.paginator.get_elided_page_range(page.number)
+ context["selected_report"] = self._selected_report()
+ context["items_per_page"] = self.get_paginate_by(None)
+
+ timestamp__gte, timestamp__lte = self._get_timestamp_range()
+ context["timestamp__gte"] = timestamp__gte
+ context["timestamp__lte"] = timestamp__lte
+
+ query_params = self.request.GET.copy()
+ if "page" in query_params:
+ query_params.pop("page")
+ context["query_params"] = query_params.urlencode()
+
+ return context
+
+
+@register_report
+class AccessPerUnitTime(BaseAccessReport):
+ UNIT_TIMES = ["day", "week", "month", "year"]
+
+ @classmethod
+ def _report_types(cls):
+ for unit_time in cls.UNIT_TIMES:
+ yield (
+ "Access per " + unit_time.title(),
+ reverse_lazy("doorcontrol:access-per-unit-time", args=[unit_time]),
+ )
+
+ def _selected_report(self) -> str:
+ return "Access per " + self.kwargs["unit_time"].title()
+
+ def _format_date(self, date: datetime.datetime) -> str:
+ unit_time = self.kwargs["unit_time"]
+ if unit_time == "day":
+ return date_format(date, "DATE_FORMAT")
+ elif unit_time == "week":
+ return (
+ date_format(date, "DATE_FORMAT")
+ + " - "
+ + date_format(date + datetime.timedelta(weeks=1), "DATE_FORMAT")
+ )
+ elif unit_time == "month":
+ return date_format(date, "N Y")
+ elif unit_time == "year":
+ return date_format(date, "Y")
+
+ def get_queryset(self):
+ unit_time = self.kwargs["unit_time"]
+ if unit_time not in self.UNIT_TIMES:
+ raise BadRequest("unit time must be one of day, week, month, or year")
+
+ granted_event_types = [
+ t for t in HIDEvent.EventType if t.name.startswith("GRANTED_ACCESS")
+ ]
+ events = (
+ super()
+ .get_queryset()
+ .filter(event_type__in=granted_event_types)
+ .values(unit_time=Trunc("timestamp", unit_time))
+ .annotate(
+ members=Count("cardholder_id", distinct=True),
+ access_count=Count("cardholder_id"),
+ )
+ .order_by("-unit_time")
+ .values("unit_time", "members", "access_count")
+ )
+ return [
+ {
+ unit_time: self._format_date(event["unit_time"]),
+ "members": event["members"],
+ "access count": event["access_count"],
+ }
+ for event in events
+ ]
+
+
+@register_report
+class DeniedAccess(BaseAccessReport):
+ _report_name = "Denied Access"
+
+ def get_queryset(self):
+ denied_event_types = [
+ t for t in HIDEvent.EventType if t.name.startswith("DENIED_ACCESS")
+ ]
+ events = (
+ super()
+ .get_queryset()
+ .filter(event_type__in=denied_event_types)
+ .with_decoded_card_number()
+ )
+
+ return [
+ {
+ "timestamp": event.timestamp,
+ "door name": event.door_name,
+ "event type": HIDEvent.EventType(event.event_type).label,
+ "name": " ".join(
+ (n for n in [event.forename, event.surname] if n is not None)
+ ),
+ "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
+class MostActiveMembers(BaseAccessReport):
+ _report_name = "Most Active Members"
+
+ def get_queryset(self):
+ counts = (
+ super()
+ .get_queryset()
+ .values("cardholder_id", "forename", "surname")
+ .order_by()
+ .annotate(access_count=Count("cardholder_id"))
+ .order_by("-access_count")
+ )
+
+ return [
+ {
+ "cardholder id": count["cardholder_id"],
+ "name": " ".join(
+ (n for n in [count["forename"], count["surname"]] if n is not None)
+ ),
+ "access count": count["access_count"],
+ }
+ for count in counts
+ ]