Browse Source

Closes #14735: Implement django-htmx (#14873)

* Install django-htmx

* Replace is_htmx() function with request.htmx

* Remove is_embedded() HTMX utility

* Include django-htmx debug error handler
Jeremy Stretch 2 years ago
parent
commit
1d41a8ace5

+ 4 - 0
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

+ 5 - 8
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,

+ 2 - 0
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',

+ 2 - 3
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:

+ 5 - 6
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,
             })

+ 1 - 2
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,
             })

+ 2 - 0
netbox/templates/base/base.html

@@ -2,6 +2,7 @@
 {% load static %}
 {% load helpers %}
 {% load i18n %}
+{% load django_htmx %}
 <!DOCTYPE html>
 <html
   lang="en"
@@ -48,6 +49,7 @@
       src="{% static 'netbox.js' %}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
     </script>
+    {% django_htmx_script %}
 
     {# Additional <head> content #}
     {% block head %}{% endblock %}

+ 0 - 24
netbox/utilities/htmx.py

@@ -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

+ 1 - 0
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