Compare commits

...

5 Commits

6 changed files with 118 additions and 62 deletions

View File

@ -3,9 +3,18 @@ from django.db.models.signals import post_migrate
def post_migrate_callback(sender, **kwargs): def post_migrate_callback(sender, **kwargs):
from .tasks.scrapehidevents import schedule_tasks from django_q.models import Schedule
schedule_tasks() from cmsmanage.django_q2_helper import ensure_scheduled
from .tasks.scrapehidevents import q_getMessagesAllDoors
ensure_scheduled(
"Fetch HID Events",
q_getMessagesAllDoors,
schedule_type=Schedule.MINUTES,
minutes=15,
)
class DoorControlConfig(AppConfig): class DoorControlConfig(AppConfig):

View File

@ -1,8 +1,7 @@
from typing import Any from typing import Any
from django.urls import reverse
import dashboard import dashboard
from .views import REPORTS
@dashboard.register @dashboard.register
@ -13,16 +12,7 @@ class DoorControlDashboardFragment(dashboard.DashboardFragment):
@property @property
def context(self) -> Any: def context(self) -> Any:
return { return {
"links": { "links": dict(rt for report in REPORTS for rt in report._report_types())
"Access Per Day": reverse(
"doorcontrol:access-per-unit-time", kwargs={"unit_time": "day"}
),
"Access Per Month": reverse(
"doorcontrol:access-per-unit-time", kwargs={"unit_time": "month"}
),
"Access Failures": reverse("doorcontrol:denied-access"),
"Most Active Members": reverse("doorcontrol:most-active-members"),
}
} }
@property @property

View File

@ -4,9 +4,7 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from django_q.tasks import async_task from django_q.tasks import async_task
from django_q.models import Schedule
from cmsmanage.django_q2_helper import ensure_scheduled
from doorcontrol.models import Door, HIDEvent from doorcontrol.models import Door, HIDEvent
@ -45,12 +43,3 @@ def q_getMessagesAllDoors():
cluster="internal", cluster="internal",
group=f"Fetch HID Events - {door.name}", group=f"Fetch HID Events - {door.name}",
) )
def schedule_tasks():
ensure_scheduled(
"Fetch HID Events",
q_getMessagesAllDoors,
schedule_type=Schedule.MINUTES,
minutes=15,
)

View File

@ -1,23 +1,5 @@
from django.urls import path
from . import views from . import views
app_name = "doorcontrol" app_name = "doorcontrol"
urlpatterns = [ urlpatterns = [report._urlpattern() for report in views.REPORTS]
path(
"reports/access-per-<unit_time>",
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",
),
]

View File

@ -1,3 +1,4 @@
import calendar
import datetime import datetime
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
@ -5,21 +6,24 @@ from django.core.paginator import Page
from django.core.exceptions import BadRequest from django.core.exceptions import BadRequest
from django.db.models import Count from django.db.models import Count
from django.db.models.functions import Trunc from django.db.models.functions import Trunc
from django.urls import reverse_lazy from django.urls import reverse_lazy, path
from django.utils import dateparse from django.utils import dateparse
from django.utils.formats import date_format 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
from django.db.models import Window, F, FloatField
from django.db.models.functions import Lead
from .models import HIDEvent from .models import HIDEvent
REPORT_TYPES = [] REPORTS = []
def register_report(cls: "BaseAccessReport"): def register_report(cls: "BaseAccessReport"):
REPORT_TYPES.extend(cls._report_types()) REPORTS.append(cls)
return cls return cls
@ -39,6 +43,11 @@ class BaseAccessReport(PermissionRequiredMixin, ListView):
reverse_lazy("doorcontrol:" + slugify(cls._report_name)), reverse_lazy("doorcontrol:" + slugify(cls._report_name)),
] ]
@classmethod
def _urlpattern(cls):
slug = slugify(cls._report_name)
return path(f"reports/{slug}", cls.as_view(), name=slug)
def _selected_report(self): def _selected_report(self):
return self._report_name return self._report_name
@ -69,7 +78,9 @@ class BaseAccessReport(PermissionRequiredMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["report_types"] = REPORT_TYPES context["report_types"] = [
rt for report in REPORTS for rt in report._report_types()
]
page: Page = context["page_obj"] page: Page = context["page_obj"]
context["paginator_range"] = page.paginator.get_elided_page_range(page.number) context["paginator_range"] = page.paginator.get_elided_page_range(page.number)
@ -100,6 +111,14 @@ class AccessPerUnitTime(BaseAccessReport):
reverse_lazy("doorcontrol:access-per-unit-time", args=[unit_time]), reverse_lazy("doorcontrol:access-per-unit-time", args=[unit_time]),
) )
@classmethod
def _urlpattern(cls):
return path(
"reports/access-per-<unit_time>",
cls.as_view(),
name="access-per-unit-time",
)
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()
@ -133,16 +152,45 @@ class AccessPerUnitTime(BaseAccessReport):
.values(unit_time=Trunc("timestamp", unit_time)) .values(unit_time=Trunc("timestamp", unit_time))
.annotate( .annotate(
members=Count("cardholder_id", distinct=True), members=Count("cardholder_id", distinct=True),
members_delta=(
F("members")
/ Window(
Lead("members"),
order_by="-unit_time",
output_field=FloatField(),
)
* 100
- 100
),
access_count=Count("cardholder_id"), access_count=Count("cardholder_id"),
access_count_delta=(
F("access_count")
/ Window(
Lead("access_count"),
order_by="-unit_time",
output_field=FloatField(),
)
* 100
- 100
),
) )
.order_by("-unit_time") .order_by("-unit_time")
.values("unit_time", "members", "access_count")
) )
return [ return [
{ {
unit_time: self._format_date(event["unit_time"]), unit_time: self._format_date(event["unit_time"]),
"members": event["members"], "members": event["members"],
"Δ members": (
f'{event["members_delta"]:.2f}%'
if event["members_delta"] is not None
else ""
),
"access count": event["access_count"], "access count": event["access_count"],
"Δ access count": (
f'{event["access_count_delta"]:.2f}%'
if event["access_count_delta"] is not None
else ""
),
} }
for event in events for event in events
] ]
@ -204,3 +252,35 @@ class MostActiveMembers(BaseAccessReport):
} }
for count in counts for count in counts
] ]
@register_report
class BusiestDayOfWeek(BaseAccessReport):
_report_name = "Busiest Day of the Week"
def get_queryset(self):
return [
{
"week day": calendar.day_name[(count["timestamp__week_day"] - 2) % 7],
"events": count["events"],
}
for count in super()
.get_queryset()
.values("timestamp__week_day")
.annotate(events=Count("timestamp"))
]
@register_report
class BusiestTimeOfDay(BaseAccessReport):
_report_name = "Busiest Time of Day"
paginate_by = 24
def get_queryset(self):
return [
{"hour": f'{count["timestamp__hour"]}:00', "events": count["events"]}
for count in super()
.get_queryset()
.values("timestamp__hour")
.annotate(events=Count("timestamp"))
]

View File

@ -4,7 +4,8 @@ from typing import TypedDict, TYPE_CHECKING, Optional
from semver import VersionInfo from semver import VersionInfo
from django.db import models from django.db import models
from django.db.models import OuterRef, Q, ExpressionWrapper, Subquery from django.db.models import Q, Window
from django.db.models.functions import FirstValue
from django.conf import settings from django.conf import settings
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django_stubs_ext import WithAnnotations from django_stubs_ext import WithAnnotations
@ -139,18 +140,23 @@ class CertificationVersionAnnotations(TypedDict):
class CertificationVersionManager(models.Manager["CertificationVersion"]): class CertificationVersionManager(models.Manager["CertificationVersion"]):
def get_queryset(self) -> models.QuerySet["CertificationVersion"]: def get_queryset(self) -> models.QuerySet["CertificationVersion"]:
qs = super().get_queryset() window = {
latest = qs.filter(definition__pk=OuterRef("definition__pk")).reverse() "partition_by": "definition",
return qs.annotate( "order_by": [
is_latest=ExpressionWrapper( "-major",
Q(pk=Subquery(latest.values("pk")[:1])), "-minor",
output_field=models.BooleanField(), "-patch",
), "-prerelease",
],
}
return (
super()
.get_queryset()
.annotate(
is_latest=Q(pk=Window(FirstValue("pk"), **window)),
# TODO: should do a more correct comparison than just major version # TODO: should do a more correct comparison than just major version
is_current=ExpressionWrapper( is_current=Q(major=Window(FirstValue("major"), **window)),
Q(major=Subquery(latest.values("major")[:1])), )
output_field=models.BooleanField(),
),
) )