doorcontrol: Add interface for adding/assigning UniFi Access NFC Cards
This commit is contained in:
parent
df4abbbe2f
commit
638db1c0b7
@ -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(
|
||||
|
@ -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
|
||||
|
94
doorcontrol/templates/doorcontrol/assign_nfc_card.dj.html
Normal file
94
doorcontrol/templates/doorcontrol/assign_nfc_card.dj.html
Normal 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 %}
|
@ -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 %}
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import "./bootstrap-css-only.entry.ts";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import "bootstrap";
|
||||
import "htmx.org";
|
||||
|
@ -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
8
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
Loading…
x
Reference in New Issue
Block a user