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",
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"django_vite",
|
"django_vite",
|
||||||
|
"template_partials",
|
||||||
"django_q",
|
"django_q",
|
||||||
"django_nh3",
|
"django_nh3",
|
||||||
"django_tables2",
|
"django_tables2",
|
||||||
@ -242,6 +243,7 @@ class NonCIBase(Base):
|
|||||||
|
|
||||||
UNIFI_ACCESS_HOST = values.Value(environ_prefix=None)
|
UNIFI_ACCESS_HOST = values.Value(environ_prefix=None)
|
||||||
UNIFI_ACCESS_API_TOKEN = values.SecretValue(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)
|
# TODO: should validate emails (but EmailValidator doesn't handle name parts)
|
||||||
INVOICE_HANDLERS = values.ListValue(
|
INVOICE_HANDLERS = values.ListValue(
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
import dashboard
|
import dashboard
|
||||||
from dashboard import Link
|
from dashboard import Link
|
||||||
|
|
||||||
@ -14,6 +16,12 @@ class DoorControlDashboardFragment(dashboard.LinksCardDashboardFragment):
|
|||||||
Link(name, link, permission="doorcontrol.view_hidevent")
|
Link(name, link, permission="doorcontrol.view_hidevent")
|
||||||
for report in REPORTS
|
for report in REPORTS
|
||||||
for name, link in report._report_types()
|
for name, link in report._report_types()
|
||||||
|
] + [
|
||||||
|
Link(
|
||||||
|
"Assign NFC Card ",
|
||||||
|
reverse("doorcontrol:assign-nfc-card-user-selector"),
|
||||||
|
permission="doorcontrol.assign_nfc_card",
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@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
|
from . import views
|
||||||
|
|
||||||
app_name = "doorcontrol"
|
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
|
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.auth.mixins import PermissionRequiredMixin
|
||||||
from django.contrib.postgres.aggregates import StringAgg
|
from django.contrib.postgres.aggregates import StringAgg
|
||||||
from django.core.exceptions import BadRequest
|
from django.core.exceptions import BadRequest
|
||||||
from django.db.models import Count, F, FloatField, Func, Q, Value, Window
|
from django.db.models import Count, F, FloatField, Func, Q, Value, Window
|
||||||
from django.db.models.functions import Lead, NullIf, Trunc
|
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.urls import path, reverse_lazy
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.views.generic import TemplateView
|
||||||
from django.views.generic.list import ListView
|
from django.views.generic.list import ListView
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
|
import django_q.tasks as q2_tasks
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_filters.views import BaseFilterView
|
from django_filters.views import BaseFilterView
|
||||||
from django_tables2 import SingleTableMixin
|
from django_tables2 import SingleTableMixin
|
||||||
from django_tables2.export.views import ExportMixin
|
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 .models import Door, HIDEvent
|
||||||
from .tables import (
|
from .tables import (
|
||||||
@ -294,3 +311,203 @@ class BusiestTimeOfDay(BaseAccessReport):
|
|||||||
events=Count("timestamp"), members=Count("member_id", distinct=True)
|
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-css-only.entry.ts";
|
||||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||||
import "bootstrap";
|
import "bootstrap";
|
||||||
|
import "htmx.org";
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
|
"htmx.org": "^1.9.12",
|
||||||
"tabulator-tables": "^6.3.0"
|
"tabulator-tables": "^6.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -17,6 +17,9 @@ importers:
|
|||||||
bootstrap-icons:
|
bootstrap-icons:
|
||||||
specifier: ^1.11.3
|
specifier: ^1.11.3
|
||||||
version: 1.11.3
|
version: 1.11.3
|
||||||
|
htmx.org:
|
||||||
|
specifier: ^1.9.12
|
||||||
|
version: 1.9.12
|
||||||
tabulator-tables:
|
tabulator-tables:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.3.0
|
version: 6.3.0
|
||||||
@ -448,6 +451,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
htmx.org@1.9.12:
|
||||||
|
resolution: {integrity: sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@ -886,6 +892,8 @@ snapshots:
|
|||||||
slash: 5.1.0
|
slash: 5.1.0
|
||||||
unicorn-magic: 0.1.0
|
unicorn-magic: 0.1.0
|
||||||
|
|
||||||
|
htmx.org@1.9.12: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
immutable@5.0.3: {}
|
immutable@5.0.3: {}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user