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
This commit is contained in:
Jeremy Stretch 2024-01-22 12:09:15 -05:00 committed by GitHub
parent da085e60c2
commit 1d41a8ace5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 22 additions and 43 deletions

View File

@ -18,6 +18,10 @@ django-filter
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst # https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
django-graphiql-debug-toolbar 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) # Modified Preorder Tree Traversal (recursive nesting of objects)
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+ # Pinned to 0.14.0; 0.15.0 requires Python 3.9+
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst

View File

@ -1,5 +1,3 @@
from django.apps import apps
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType 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.constants import DEFAULT_ACTION_PERMISSIONS
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.rqworker import get_workers_for_queue from utilities.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown from utilities.templatetags.builtins.filters import render_markdown
@ -892,7 +889,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
template_name = 'extras/dashboard/widget_add.html' template_name = 'extras/dashboard/widget_add.html'
def get(self, request): def get(self, request):
if not is_htmx(request): if not request.htmx:
return redirect('home') return redirect('home')
initial = request.GET or { initial = request.GET or {
@ -942,7 +939,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
template_name = 'extras/dashboard/widget_config.html' template_name = 'extras/dashboard/widget_config.html'
def get(self, request, id): def get(self, request, id):
if not is_htmx(request): if not request.htmx:
return redirect('home') return redirect('home')
widget = request.user.dashboard.get_widget(id) widget = request.user.dashboard.get_widget(id)
@ -983,7 +980,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
template_name = 'generic/object_delete.html' template_name = 'generic/object_delete.html'
def get(self, request, id): def get(self, request, id):
if not is_htmx(request): if not request.htmx:
return redirect('home') return redirect('home')
widget = request.user.dashboard.get_widget(id) widget = request.user.dashboard.get_widget(id)
@ -1173,7 +1170,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
report = module.reports[job.name] report = module.reports[job.name]
# If this is an HTMX request, return only the result HTML # 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', { response = render(request, 'extras/htmx/report_result.html', {
'report': report, 'report': report,
'job': job, 'job': job,
@ -1347,7 +1344,7 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
script = module.scripts[job.name]() script = module.scripts[job.name]()
# If this is an HTMX request, return only the result HTML # 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', { response = render(request, 'extras/htmx/script_result.html', {
'script': script, 'script': script,
'job': job, 'job': job,

View File

@ -367,6 +367,7 @@ INSTALLED_APPS = [
'debug_toolbar', 'debug_toolbar',
'graphiql_debug_toolbar', 'graphiql_debug_toolbar',
'django_filters', 'django_filters',
'django_htmx',
'django_tables2', 'django_tables2',
'django_prometheus', 'django_prometheus',
'graphene_django', 'graphene_django',
@ -405,6 +406,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django_htmx.middleware.HtmxMiddleware',
'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware', 'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware', 'netbox.middleware.MaintenanceModeMiddleware',

View File

@ -22,7 +22,6 @@ from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm 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.permissions import get_permission_for_model
from utilities.utils import get_viewname from utilities.utils import get_viewname
from utilities.views import GetReturnURLMixin from utilities.views import GetReturnURLMixin
@ -162,8 +161,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
table = self.get_table(self.queryset, request, has_bulk_actions) table = self.get_table(self.queryset, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if is_htmx(request): if request.htmx:
if is_embedded(request): if request.htmx.target != 'object_list':
table.embedded = True table.embedded = True
# Hide selection checkboxes # Hide selection checkboxes
if 'pk' in table.base_columns: if 'pk' in table.base_columns:

View File

@ -16,7 +16,6 @@ from extras.signals import clear_events
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.htmx import is_htmx
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields
from utilities.views import GetReturnURLMixin from utilities.views import GetReturnURLMixin
@ -136,7 +135,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table = self.get_table(table_data, request, has_bulk_actions) table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML # 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', { return render(request, 'htmx/table.html', {
'object': instance, 'object': instance,
'table': table, 'table': table,
@ -224,7 +223,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
restrict_form_fields(form, request.user) restrict_form_fields(form, request.user)
# If this is an HTMX request, return only the rendered form HTML # 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', { return render(request, 'htmx/form.html', {
'form': form, 'form': form,
}) })
@ -349,7 +348,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
""" """
handle_protectederror(protected_objects, request, exc) handle_protectederror(protected_objects, request, exc)
if is_htmx(request): if request.htmx:
return HttpResponse(headers={ return HttpResponse(headers={
'HX-Redirect': obj.get_absolute_url(), '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) 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 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') viewname = get_viewname(self.queryset.model, action='delete')
form_url = reverse(viewname, kwargs={'pk': obj.pk}) form_url = reverse(viewname, kwargs={'pk': obj.pk})
return render(request, 'htmx/delete_form.html', { return render(request, 'htmx/delete_form.html', {
@ -480,7 +479,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
instance = self.alter_object(self.queryset.model(), request) instance = self.alter_object(self.queryset.model(), request)
# If this is an HTMX request, return only the rendered form HTML # 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', { return render(request, 'htmx/form.html', {
'form': form, 'form': form,
}) })

View File

@ -14,7 +14,6 @@ from netbox.forms import SearchForm
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from netbox.tables import SearchTable from netbox.tables import SearchTable
from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
__all__ = ( __all__ = (
@ -96,7 +95,7 @@ class SearchView(View):
}).configure(table) }).configure(table)
# If this is an HTMX request, return only the rendered table HTML # 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', { return render(request, 'htmx/table.html', {
'table': table, 'table': table,
}) })

View File

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

View File

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

View File

@ -3,6 +3,7 @@ django-cors-headers==4.3.1
django-debug-toolbar==4.2.0 django-debug-toolbar==4.2.0
django-filter==23.5 django-filter==23.5
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-htmx==1.17.2
django-mptt==0.14.0 django-mptt==0.14.0
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.3.1 django-prometheus==2.3.1