doorcontrol: Add interface for adding/assigning UniFi Access NFC Cards
All checks were successful
Ruff / ruff (push) Successful in 1m2s
Test / test (push) Successful in 6m25s

This commit is contained in:
Adam Goldsmith 2024-12-11 13:03:37 -05:00
parent df4abbbe2f
commit 638db1c0b7
9 changed files with 413 additions and 2 deletions

View File

@ -52,6 +52,7 @@ class Base(Configuration):
"rest_framework",
"rest_framework.authtoken",
"django_vite",
"template_partials",
"django_q",
"django_nh3",
"django_tables2",
@ -242,6 +243,7 @@ class NonCIBase(Base):
UNIFI_ACCESS_HOST = values.Value(environ_prefix=None)
UNIFI_ACCESS_API_TOKEN = values.SecretValue(environ_prefix=None)
UNIFI_ACCESS_CARD_ASSIGNMENT_DEVICE = values.Value(environ_prefix=None)
# TODO: should validate emails (but EmailValidator doesn't handle name parts)
INVOICE_HANDLERS = values.ListValue(

View File

@ -1,3 +1,5 @@
from django.urls import reverse
import dashboard
from dashboard import Link
@ -14,6 +16,12 @@ class DoorControlDashboardFragment(dashboard.LinksCardDashboardFragment):
Link(name, link, permission="doorcontrol.view_hidevent")
for report in REPORTS
for name, link in report._report_types()
] + [
Link(
"Assign NFC Card ",
reverse("doorcontrol:assign-nfc-card-user-selector"),
permission="doorcontrol.assign_nfc_card",
)
]
@property

View File

@ -0,0 +1,94 @@
{% extends "base.dj.html" %}
{% load partials %}
{% block title %}Assign NFC Card{% endblock %}
{% block content %}
<style>
.poll-indicator .spinner-grow {
animation: 0.7s linear 0s var(--bs-spinner-animation-name);
}
.htmx-request.poll-indicator .spinner-grow {
animation: none;
}
</style>
<div id="inner-content"
class="container-sm"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-indicator="#polling-indicator">
{% partialdef content inline=True %}
<h2>Assigning NFC Card for {{ user.full_name }}</h2>
<form method="post"
hx-post=""
hx-target="#inner-content"
hx-vals='{"part": "content"}'>
{% csrf_token %}
<button class="btn btn-primary mx-auto" type="submit">
<span id="polling-indicator" class="poll-indicator">
<span class="spinner-grow spinner-grow-sm">
<span class="visually-hidden" role="status">Polling...</span>
</span>
</span>
Start
{% if session_id %}new{% endif %}
card adding session
</button>
</form>
{% if session_id %}
<div class="text-secondary">
The reader should now be pulsing blue. If it stops pulsing, the session has failed or timed
out and you will need to click the button to start a new session.
</div>
{% endif %}
{% if session_id %}
{% partialdef status inline=True %}
<div id="poll-status">
{% if not card %}
<div hx-get="?part=status"
hx-trigger="load delay:1s"
hx-swap="outerHTML"
hx-target="#poll-status"></div>
{% else %}
<div id="card-data">
<h3>Card found: {{ card.display_id }}</h3>
<ul>
<li>Status: {{ card.status }}</li>
<li>Type: {{ card.card_type }}</li>
{% if card.user.id %}<li>Assigned to: {{ card.user.name }}</li>{% endif %}
</ul>
{% if card.user.id != user.id %}
<form method="post"
hx-post=""
hx-target="#poll-status"
hx-vals='{"part": "status"}'>
{% csrf_token %}
<input hidden name="assign" value="1" />
<input hidden name="id" value="{{ last_status.id }}" />
<button class="btn btn-primary" type="submit">Assign Card</button>
</form>
{% else %}
<p>Already assigned to this user</p>
{% endif %}
</div>
{% endif %}
{% if errors %}
<h3>Errors</h3>
<div>
{% for error in errors %}
<div>
{% if error.code %}{{ error.code.name }}:{% endif %}
{{ error.msg }}
{% if error.count > 1 %}<span class="text-body-secondary">x{{ error.count }}</span>{% endif %}
{% if error.extra_details %}<div class="text-secondary">{{ error.extra_details }}</div>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endpartialdef %}
{% endif %}
{% endpartialdef %}
</div>
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends "base.dj.html" %}
{% load partials %}
{% block title %}Assign NFC Card{% endblock %}
{% block content %}
<div id="inner-content"
class="container-sm"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div class="input-group mb-3">
<span class="input-group-text">
<div class="spinner-border spinner-border-sm htmx-indicator" role="status">
<span class="visually-hidden">Searching...</span>
</div>
</span>
<input class="form-control"
type="search"
name="search"
placeholder="Begin Typing To Search Users..."
hx-post=""
hx-trigger="input changed delay:200ms, search, load"
hx-sync="next button:queue"
hx-target="#search-results"
hx-indicator="closest .input-group">
<button class="btn btn-outline-secondary"
type="button"
hx-post=""
hx-include="previous [name='search']"
hx-vals='{"force_refresh": true}'
hx-target="#search-results"
hx-indicator="closest .input-group">Force Refresh</button>
</div>
<table class="table table-hover">
<thead>
<tr>
<th>Full Name</th>
<th>Current cards</th>
</tr>
</thead>
<tbody id="search-results">
{% partialdef results inline=True %}
{% for user in users|slice:":10" %}
<tr>
<td>
<a href="{% url 'doorcontrol:assign-nfc-card' user.id %}">{{ user.full_name }}</a>
</td>
<td>
<ul>
{% for card in user.nfc_cards %}<li>{{ card.type }}: {{ card.id }}</li>{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
{% if users|length > 10 %}
<tr>
<td colspan="2" class="text-center">
<span class="text-body-secondary">more results, type to filter...</span>
</td>
</tr>
{% endif %}
{% endpartialdef %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,5 +1,18 @@
from django.urls import path
from . import views
app_name = "doorcontrol"
urlpatterns = [report._urlpattern() for report in views.REPORTS]
urlpatterns = [report._urlpattern() for report in views.REPORTS] + [
path(
"assign-nfc-card/",
views.assign_nfc_card_user_selector,
name="assign-nfc-card-user-selector",
),
path(
"assign-nfc-card/<str:user_id>",
views.AssignNfcCardView.as_view(),
name="assign-nfc-card",
),
]

View File

@ -1,20 +1,37 @@
import datetime
from typing import TYPE_CHECKING
import itertools
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.postgres.aggregates import StringAgg
from django.core.exceptions import BadRequest
from django.db.models import Count, F, FloatField, Func, Q, Value, Window
from django.db.models.functions import Lead, NullIf, Trunc
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import render
from django.urls import path, reverse_lazy
from django.utils.text import slugify
from django.views.generic import TemplateView
from django.views.generic.list import ListView
import django_filters
import django_q.tasks as q2_tasks
import django_tables2 as tables
from django_filters.views import BaseFilterView
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
from pydantic import BaseModel, Field, ValidationError
from unifi_access import AccessClient, ResponseCode, UnifiAccessError
from unifi_access.schemas import (
FullUser,
NfcCard,
NfcCardEnrollmentSessionId,
NfcCardEnrollmentStatus,
User,
UserId,
UserStatus,
)
from .models import Door, HIDEvent
from .tables import (
@ -294,3 +311,203 @@ class BusiestTimeOfDay(BaseAccessReport):
events=Count("timestamp"), members=Count("member_id", distinct=True)
)
)
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 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)
template_name += "#results"
all_filtered_users = (
user
for user in all_users or []
if user.status == UserStatus.ACTIVE
and request.POST.get("search", "").lower() in user.full_name.lower()
)
filtered_users = list(itertools.islice(all_filtered_users, 10))
return render(request, template_name, {"users": filtered_users})
class AssignNfcCardStatus(BaseModel):
class ErrorEntry(BaseModel):
count: int
code: ResponseCode | None
msg: str
extra_details: str | None
session_id: NfcCardEnrollmentSessionId | None = None
last_status: NfcCardEnrollmentStatus | None = None
errors: list[ErrorEntry] = Field(default_factory=list)
card: NfcCard | None = None
user: User
def append_error(
self, error: UnifiAccessError, extra_details: str | None = None
) -> None:
if self.errors and self.errors[-1].code == error.code:
self.errors[-1].count += 1
else:
self.errors.append(
self.ErrorEntry(
count=1,
code=error.code,
msg=error.msg,
extra_details=extra_details,
)
)
def append_raw_error(self, msg: str, extra_details: str | None = None) -> None:
self.errors.append(
self.ErrorEntry(
count=1,
code=None,
msg=msg,
extra_details=extra_details,
)
)
class AssignNfcCardView(TemplateView):
# for storage in request.session
ENROLLMENT_STATUS_SESSION_KEY = "unifi_access_enrollment_status"
template_name = "doorcontrol/assign_nfc_card.dj.html"
def get_template_names(self) -> list[str]:
templates = super().get_template_names()
if (
self.request.method == "GET" and (part := self.request.GET.get("part"))
) or (
self.request.method == "POST" and (part := self.request.POST.get("part"))
):
return [f"{template_name}#{part}" for template_name in templates]
else:
return templates
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
self.access_client = AccessClient(
settings.UNIFI_ACCESS_HOST, settings.UNIFI_ACCESS_API_TOKEN, verify=False
)
try:
status = AssignNfcCardStatus.model_validate(
request.session.get(self.ENROLLMENT_STATUS_SESSION_KEY, "{}")
)
except ValidationError:
status = None
if status is None or status.user.id != self.kwargs["user_id"]:
try:
user = self.access_client.fetch_user(UserId(self.kwargs["user_id"]))
except UnifiAccessError as e:
if e.code == ResponseCode.USER_ACCOUNT_NOT_EXIST:
raise Http404(
"No account with that id exists in UniFi Access"
) from e
else:
raise e
status = AssignNfcCardStatus(user=user)
self.status = status
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
self.request.session[self.ENROLLMENT_STATUS_SESSION_KEY] = (
self.status.model_dump()
)
return super().get_context_data(**kwargs) | self.status.model_dump()
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
# poll an in-progress session
if self.status.session_id:
try:
self.status.last_status = self.access_client.fetch_enroll_card_status(
self.status.session_id
)
self.status.card = self.access_client.fetch_nfc_card(
self.status.last_status.token
)
self.access_client.remove_enrollment_session(self.status.session_id)
self.status.session_id = None
except UnifiAccessError as e:
match e.code:
case ResponseCode.CREDS_NFC_READ_SESSION_NOT_FOUND:
self.status.session_id = None
case ResponseCode.CREDS_NFC_READ_POLL_TOKEN_EMPTY:
# all is well, the reader just hasn't seen a card yet
pass
case ResponseCode.CREDS_NFC_CARD_IS_PROVISION:
self.status.session_id = None
self.status.append_error(
e,
"This card will need to be added by someone with admin access to the UniFi Access application",
)
case _:
self.status.append_error(e)
return super().get(request, *args, **kwargs)
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if "assign" in request.POST:
if not self.status.last_status:
self.status.append_raw_error(
"Missing session status. Please start a new session and try again."
)
elif request.POST.get("id") != self.status.last_status.id:
self.status.append_raw_error(
"Mismatched session status. Please start a new session and try again."
)
else:
try:
self.access_client.assign_nfc_card_to_user(
self.status.user.id, self.status.last_status.token
)
self.status.card = self.access_client.fetch_nfc_card(
self.status.last_status.token
)
except UnifiAccessError as e:
self.status.append_error(e)
else:
# remove old session, if it exists
if self.status.session_id:
self.access_client.remove_enrollment_session(self.status.session_id)
# start a new session
self.status = AssignNfcCardStatus(user=self.status.user)
self.status.session_id = self.access_client.begin_enroll_card(
settings.UNIFI_ACCESS_CARD_ASSIGNMENT_DEVICE
).session_id
return super().get(request, *args, **kwargs)

View File

@ -1,3 +1,4 @@
import "./bootstrap-css-only.entry.ts";
import "bootstrap-icons/font/bootstrap-icons.css";
import "bootstrap";
import "htmx.org";

View File

@ -22,6 +22,7 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"htmx.org": "^1.9.12",
"tabulator-tables": "^6.3.0"
}
}

8
pnpm-lock.yaml generated
View File

@ -17,6 +17,9 @@ importers:
bootstrap-icons:
specifier: ^1.11.3
version: 1.11.3
htmx.org:
specifier: ^1.9.12
version: 1.9.12
tabulator-tables:
specifier: ^6.3.0
version: 6.3.0
@ -448,6 +451,9 @@ packages:
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
engines: {node: '>=18'}
htmx.org@1.9.12:
resolution: {integrity: sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -886,6 +892,8 @@ snapshots:
slash: 5.1.0
unicorn-magic: 0.1.0
htmx.org@1.9.12: {}
ignore@5.3.2: {}
immutable@5.0.3: {}