doorcontrol: Add report for assigned NFC cards
All checks were successful
Ruff / ruff (push) Successful in 1m0s
Test / test (push) Successful in 8m52s

This commit is contained in:
Adam Goldsmith 2025-01-03 21:21:31 -05:00
parent 68a917f3f8
commit c8b3edcacf
7 changed files with 173 additions and 30 deletions

View File

@ -18,12 +18,13 @@ class DoorControlDashboardFragment(dashboard.LinksCardDashboardFragment):
for name, link in report._report_types()
] + [
Link(
"Assign NFC Card ",
"Assign NFC Card",
reverse("doorcontrol:assign-nfc-card-user-selector"),
permission="doorcontrol.assign_nfc_card",
)
),
Link(
"Assigned NFC Cards",
reverse("doorcontrol:assigned-nfc-cards"),
permission="doorcontrol.assign_nfc_card",
),
]
@property
def visible(self) -> bool:
return self.request.user.has_perm("doorcontrol.view_hidevent")

View File

@ -39,3 +39,14 @@ class AttributeScheduleRuleForm(forms.ModelForm):
class Meta:
model = AttributeScheduleRule
fields = "__all__"
class AssignedNfcCardsReportFilters(forms.Form):
has_mw_nfc_card = forms.NullBooleanField(
label="Has NFC card in MembershipWorks",
widget=forms.Select(choices=[("any", "Any"), (True, "Yes"), (False, "No")]),
)
has_access_nfc_card = forms.NullBooleanField(
label="Has NFC card in UniFi Access",
widget=forms.Select(choices=[("any", "Any"), (True, "Yes"), (False, "No")]),
)

View File

@ -63,3 +63,25 @@ class BusiestTimeOfDayTable(tables.Table):
timestamp__hour = tables.TemplateColumn("{{ value }}:00", verbose_name="Hour")
events = tables.Column()
members = tables.Column()
class AssignedNfcCardsTable(tables.Table):
member__account_name = tables.Column("Name")
member__nfc_card_number = tables.Column("MW NFC Card")
access_user__nfc_cards = tables.TemplateColumn(
"""
{% if value %}
<ul>
{% for card in value %}
<li>{{ card.type }}: {{ card.id }}</li>
{% endfor %}
</ul>
{% else %}
???
{% endif %}
""",
empty_values=[[]],
verbose_name="UniFi Access Cards",
)
member__access_wood_shop = tables.BooleanColumn(verbose_name="Access Wood Shop")
member__access_metal_shop = tables.BooleanColumn(verbose_name="Access Metal Shop")

View File

@ -5,7 +5,7 @@ from django.conf import settings
from django.db.models import Q
from unifi_access import AccessClient
from unifi_access.schemas import AccessPolicy, DoorResource, UserStatus
from unifi_access.schemas import AccessPolicy, DoorResource, FullUser, UserStatus
from unifi_access.schemas import User as AccessUser
from cmsmanage.django_q2_helper import q_task_group
@ -172,3 +172,11 @@ def update_access():
)
sync_members(access_client)
@q_task_group("Update Access Users")
def update_access_users() -> list[FullUser]:
access_client = AccessClient(
settings.UNIFI_ACCESS_HOST, settings.UNIFI_ACCESS_API_TOKEN, verify=False
)
return list(access_client.fetch_all_users__unpaged())

View File

@ -0,0 +1,40 @@
{% extends "base.dj.html" %}
{% load render_table from django_tables2 %}
{% load widget_tweaks %}
{% block title %}Assigned NFC Cards{% endblock %}
{% block content %}
<div class="vstack align-items-center">
<form hx-get="{{ request.path }}{% querystring has_mw_nfc_card=None has_access_nfc_card=None refresh=None %}"
hx-include="this"
hx-target="body"
hx-push-url="true"
hx-trigger="change">
<div class="row justify-content-center d-print-none">
{% for field in form %}
<div class="col-12 col-sm-3 mb-2">
{{ field.label_tag }} {% render_field field class+="form-select form-select-sm" %}
</div>
{% endfor %}
<div class="col-auto">
<button class="btn btn-sm btn-secondary"
type="button"
hx-get="{{ request.path }}{% querystring has_mw_nfc_card=None has_access_nfc_card=None refresh=None %}"
hx-push-url="false"
hx-indicator="find .htmx-indicator"
hx-vals='{"refresh": true}'>
<span class="spinner-border spinner-border-sm htmx-indicator"
aria-hidden="true"></span>
Refresh Access Data
</button>
</div>
<div class="col-auto">{% include "cmsmanage/components/download_table.dj.html" %}</div>
</div>
</form>
{% render_table table %}
</div>
{% endblock %}

View File

@ -15,4 +15,9 @@ urlpatterns = [report._urlpattern() for report in views.REPORTS] + [
views.AssignNfcCardView.as_view(),
name="assign-nfc-card",
),
path(
"assigned-nfc-cards/",
views.AssignedNfcCardsReport.as_view(),
name="assigned-nfc-cards",
),
]

View File

@ -20,6 +20,7 @@ import django_filters
import django_q.tasks as q2_tasks
import django_tables2 as tables
from django_filters.views import BaseFilterView
from django_q.signing import BadSignature
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
from pydantic import BaseModel, Field, ValidationError
@ -34,8 +35,13 @@ from unifi_access.schemas import (
UserStatus,
)
from doorcontrol.forms import AssignedNfcCardsReportFilters
from doorcontrol.tasks.update_unifi_access import update_access_users
from membershipworks.models import Member
from .models import Door, HIDEvent
from .tables import (
AssignedNfcCardsTable,
BusiestDayOfWeekTable,
BusiestTimeOfDayTable,
DeniedAccessTable,
@ -314,39 +320,36 @@ class BusiestTimeOfDay(BaseAccessReport):
)
def update_access_users() -> list[FullUser]:
access_client = AccessClient(
settings.UNIFI_ACCESS_HOST, settings.UNIFI_ACCESS_API_TOKEN, verify=False
)
return list(access_client.fetch_all_users__unpaged())
def fetch_access_users_results(force_refresh: bool = False) -> list[FullUser] | None:
task_group = update_access_users.q_task_group
try:
if force_refresh:
q2_tasks.delete_group(task_group)
refresh_task_id = q2_tasks.async_task(
update_access_users, group=task_group, cached=5 * 60
)
return q2_tasks.result(refresh_task_id, wait=-1, cached=True)
update_users_results = q2_tasks.result_group(task_group, cached=True)
if update_users_results and len(update_users_results) > 0:
return update_users_results[0]
# TODO: this could be better
except BadSignature:
return None
@login_required
@permission_required("doorcontrol.assign_nfc_card", raise_exception=True)
def assign_nfc_card_user_selector(request: HttpRequest):
template_name = "doorcontrol/assign_nfc_card_user_selector.dj.html"
task_group = "update_access_users"
all_users: list[FullUser] | None = None
refresh_task_id = None
update_users_results = q2_tasks.result_group(task_group, cached=True)
if (
update_users_results
and len(update_users_results) > 0
and not request.POST.get("force_refresh")
):
all_users = update_users_results[0]
else:
q2_tasks.delete_group(task_group)
refresh_task_id = q2_tasks.async_task(
update_access_users, group=task_group, cached=5 * 60
)
filtered_users = []
if request.method == "POST":
if refresh_task_id:
all_users = q2_tasks.result(refresh_task_id, wait=-1, cached=True)
all_users = fetch_access_users_results(
request.POST.get("force_refresh") == "true"
)
template_name += "#results"
all_filtered_users = (
@ -514,3 +517,56 @@ class AssignNfcCardView(PermissionRequiredMixin, TemplateView):
).session_id
return super().get(request, *args, **kwargs)
class AssignedNfcCardsReport(
ExportMixin, SingleTableMixin, PermissionRequiredMixin, TemplateView
):
permission_required = "doorcontrol.assign_nfc_card"
template_name = "doorcontrol/assigned_nfc_cards_report.dj.html"
table_class = AssignedNfcCardsTable
export_formats = ("csv", "xlsx", "ods")
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
if "form" not in kwargs:
kwargs["form"] = AssignedNfcCardsReportFilters(self.request.GET)
return super().get_context_data(**kwargs)
def get_table_data(self):
access_users = fetch_access_users_results(
force_refresh=("refresh" in self.request.GET)
)
if access_users:
access_users_by_employee_number = {
user.employee_number: user for user in access_users
}
else:
access_users_by_employee_number = {}
form = AssignedNfcCardsReportFilters(self.request.GET)
def get_filtered_members():
members = Member.objects.with_is_active().filter(is_active=True)
if form.is_valid() and form.cleaned_data["has_mw_nfc_card"] is not None:
members = members.alias(
has_nfc_card_number=(
Q(nfc_card_number__isnull=False) & ~Q(nfc_card_number="")
)
).filter(has_nfc_card_number=form.cleaned_data["has_mw_nfc_card"])
for member in members.all():
access_user = access_users_by_employee_number.get(member.uid, None)
if (
form.is_valid()
and form.cleaned_data["has_access_nfc_card"] is not None
and access_user
and (
bool(access_user.nfc_cards)
!= form.cleaned_data["has_access_nfc_card"]
)
):
continue
yield {"member": member, "access_user": access_user}
return list(get_filtered_members())