diff --git a/base_requirements.txt b/base_requirements.txt index 87a3066c4..4ac2b044a 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -18,6 +18,10 @@ django-filter # https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst django-graphiql-debug-toolbar +# HTMX utilities for Django +# https://django-htmx.readthedocs.io/en/latest/changelog.html +django-htmx + # Modified Preorder Tree Traversal (recursive nesting of objects) # Pinned to 0.14.0; 0.15.0 requires Python 3.9+ # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 5a1fbf19e..f33b4c89b 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,5 +1,3 @@ -from django.apps import apps -from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType @@ -20,7 +18,6 @@ from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value -from utilities.htmx import is_htmx from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.rqworker import get_workers_for_queue from utilities.templatetags.builtins.filters import render_markdown @@ -892,7 +889,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View): template_name = 'extras/dashboard/widget_add.html' def get(self, request): - if not is_htmx(request): + if not request.htmx: return redirect('home') initial = request.GET or { @@ -942,7 +939,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View): template_name = 'extras/dashboard/widget_config.html' def get(self, request, id): - if not is_htmx(request): + if not request.htmx: return redirect('home') widget = request.user.dashboard.get_widget(id) @@ -983,7 +980,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View): template_name = 'generic/object_delete.html' def get(self, request, id): - if not is_htmx(request): + if not request.htmx: return redirect('home') widget = request.user.dashboard.get_widget(id) @@ -1173,7 +1170,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View): report = module.reports[job.name] # If this is an HTMX request, return only the result HTML - if is_htmx(request): + if request.htmx: response = render(request, 'extras/htmx/report_result.html', { 'report': report, 'job': job, @@ -1347,7 +1344,7 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View): script = module.scripts[job.name]() # If this is an HTMX request, return only the result HTML - if is_htmx(request): + if request.htmx: response = render(request, 'extras/htmx/script_result.html', { 'script': script, 'job': job, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2fc33f1d2..bc7603fc4 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -367,6 +367,7 @@ INSTALLED_APPS = [ 'debug_toolbar', 'graphiql_debug_toolbar', 'django_filters', + 'django_htmx', 'django_tables2', 'django_prometheus', 'graphene_django', @@ -405,6 +406,7 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', + 'django_htmx.middleware.HtmxMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.CoreMiddleware', 'netbox.middleware.MaintenanceModeMiddleware', diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 615db6181..f5b605ccd 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -22,7 +22,6 @@ from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm -from utilities.htmx import is_embedded, is_htmx from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname from utilities.views import GetReturnURLMixin @@ -162,8 +161,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): table = self.get_table(self.queryset, request, has_bulk_actions) # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): - if is_embedded(request): + if request.htmx: + if request.htmx.target != 'object_list': table.embedded = True # Hide selection checkboxes if 'pk' in table.base_columns: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 90b6e9495..10ea257bd 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -16,7 +16,6 @@ from extras.signals import clear_events from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ConfirmationForm, restrict_form_fields -from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin @@ -136,7 +135,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): table = self.get_table(table_data, request, has_bulk_actions) # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): + if request.htmx: return render(request, 'htmx/table.html', { 'object': instance, 'table': table, @@ -224,7 +223,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): restrict_form_fields(form, request.user) # If this is an HTMX request, return only the rendered form HTML - if is_htmx(request): + if request.htmx: return render(request, 'htmx/form.html', { 'form': form, }) @@ -349,7 +348,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ handle_protectederror(protected_objects, request, exc) - if is_htmx(request): + if request.htmx: return HttpResponse(headers={ 'HX-Redirect': obj.get_absolute_url(), }) @@ -378,7 +377,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): return self._handle_protected_objects(obj, e.restricted_objects, request, e) # If this is an HTMX request, return only the rendered deletion form as modal content - if is_htmx(request): + if request.htmx: viewname = get_viewname(self.queryset.model, action='delete') form_url = reverse(viewname, kwargs={'pk': obj.pk}) return render(request, 'htmx/delete_form.html', { @@ -480,7 +479,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): instance = self.alter_object(self.queryset.model(), request) # If this is an HTMX request, return only the rendered form HTML - if is_htmx(request): + if request.htmx: return render(request, 'htmx/form.html', { 'form': form, }) diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index c7255916c..781b40494 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -14,7 +14,6 @@ from netbox.forms import SearchForm from netbox.search import LookupTypes from netbox.search.backends import search_backend from netbox.tables import SearchTable -from utilities.htmx import is_htmx from utilities.paginator import EnhancedPaginator, get_paginate_count __all__ = ( @@ -96,7 +95,7 @@ class SearchView(View): }).configure(table) # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): + if request.htmx: return render(request, 'htmx/table.html', { 'table': table, }) diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 1958fa556..b7d4f6fc6 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -2,6 +2,7 @@ {% load static %} {% load helpers %} {% load i18n %} +{% load django_htmx %} + {% django_htmx_script %} {# Additional content #} {% block head %}{% endblock %} diff --git a/netbox/utilities/htmx.py b/netbox/utilities/htmx.py deleted file mode 100644 index d12478b67..000000000 --- a/netbox/utilities/htmx.py +++ /dev/null @@ -1,24 +0,0 @@ -from urllib.parse import urlparse - -__all__ = ( - 'is_embedded', - 'is_htmx', -) - - -def is_htmx(request): - """ - Returns True if the request was made by HTMX; False otherwise. - """ - return 'Hx-Request' in request.headers - - -def is_embedded(request): - """ - Returns True if the request indicates that it originates from a URL different from - the path being requested. - """ - hx_current_url = request.headers.get('HX-Current-URL', None) - if not hx_current_url: - return False - return request.path != urlparse(hx_current_url).path diff --git a/requirements.txt b/requirements.txt index cc733d4b9..f6edb9d2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ django-cors-headers==4.3.1 django-debug-toolbar==4.2.0 django-filter==23.5 django-graphiql-debug-toolbar==0.2.0 +django-htmx==1.17.2 django-mptt==0.14.0 django-pglocks==1.0.4 django-prometheus==2.3.1