diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 32d19674b8..b825707f22 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage from django.db.models import Count, Q -from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse +from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse, Http404 from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone @@ -25,7 +25,7 @@ from netbox.views import generic from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value -from utilities.htmx import htmx_partial +from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.query import count_related from utilities.querydict import normalize_querydict @@ -518,8 +518,9 @@ class NotificationsView(LoginRequiredMixin, View): """ def get(self, request): return render(request, 'htmx/notifications.html', { - 'notifications': request.user.notifications.unread(), + 'notifications': request.user.notifications.unread()[:10], 'total_count': request.user.notifications.count(), + 'unread_count': request.user.notifications.unread().count(), }) @@ -528,6 +529,7 @@ class NotificationReadView(LoginRequiredMixin, View): """ Mark the Notification read and redirect the user to its attached object. """ + def get(self, request, pk): # Mark the Notification as read notification = get_object_or_404(request.user.notifications, pk=pk) @@ -541,18 +543,48 @@ def get(self, request, pk): return redirect('account:notifications') +@register_model_view(Notification, name='dismiss_all', path='dismiss-all', detail=False) +class NotificationDismissAllView(LoginRequiredMixin, View): + """ + Convenience view to clear all *unread* notifications for the current user. + """ + + def get(self, request): + request.user.notifications.unread().delete() + if htmx_partial(request): + # If a user is currently on the notification page, redirect there (full repaint) + redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True) + if redirect_resp: + return redirect_resp + + return render(request, 'htmx/notifications.html', { + 'notifications': request.user.notifications.unread()[:10], + 'total_count': request.user.notifications.count(), + 'unread_count': request.user.notifications.unread().count(), + }) + return redirect('account:notifications') + + @register_model_view(Notification, 'dismiss') class NotificationDismissView(LoginRequiredMixin, View): """ A convenience view which allows deleting notifications with one click. """ + def get(self, request, pk): notification = get_object_or_404(request.user.notifications, pk=pk) notification.delete() if htmx_partial(request): + # If a user is currently on the notification page, redirect there (full repaint) + redirect_resp = htmx_maybe_redirect_current_page(request, 'account:notifications', preserve_query=True) + if redirect_resp: + return redirect_resp + return render(request, 'htmx/notifications.html', { 'notifications': request.user.notifications.unread()[:10], + 'total_count': request.user.notifications.count(), + 'unread_count': request.user.notifications.unread().count(), }) return redirect('account:notifications') diff --git a/netbox/templates/htmx/notifications.html b/netbox/templates/htmx/notifications.html index 2154d56ba6..bb28a10893 100644 --- a/netbox/templates/htmx/notifications.html +++ b/netbox/templates/htmx/notifications.html @@ -1,4 +1,15 @@ {% load i18n %} +
+

Notifications

+ {% if notifications %} + + + {% trans "Dismiss all" %} + + {% endif %} +
{% for notification in notifications %}
diff --git a/netbox/utilities/htmx.py b/netbox/utilities/htmx.py index 55e3b26161..75fbab2654 100644 --- a/netbox/utilities/htmx.py +++ b/netbox/utilities/htmx.py @@ -1,5 +1,11 @@ +from django.http import HttpResponse +from django.urls import reverse +from urllib.parse import urlsplit + __all__ = ( + 'htmx_current_url', 'htmx_partial', + 'htmx_maybe_redirect_current_page', ) @@ -9,3 +15,45 @@ def htmx_partial(request): in response to an HTMX request, based on the target element. """ return request.htmx and not request.htmx.boosted + + +def htmx_current_url(request) -> str: + """ + Extracts the current URL from the HTMX-specific headers in the given request object. + + This function checks for the `HX-Current-URL` header in the request's headers + and `HTTP_HX_CURRENT_URL` in the META data of the request. It preferentially + chooses the value present in the `HX-Current-URL` header and falls back to the + `HTTP_HX_CURRENT_URL` META data if the former is unavailable. If neither value + exists, it returns an empty string. + """ + return request.headers.get('HX-Current-URL') or request.META.get('HTTP_HX_CURRENT_URL', '') or '' + + +def htmx_maybe_redirect_current_page( + request, url_name: str, *, preserve_query: bool = True, status: int = 200 +) -> HttpResponse | None: + """ + Redirects the current page in an HTMX request if conditions are met. + + This function checks whether a request is an HTMX partial request and if the + current URL matches the provided target URL. If the conditions are met, it + returns an HTTP response signaling a redirect to the provided or updated target + URL. Otherwise, it returns None. + """ + if not htmx_partial(request): + return None + + current = urlsplit(htmx_current_url(request)) + target_path = reverse(url_name) # will raise NoReverseMatch if misconfigured + + if current.path.rstrip('/') != target_path.rstrip('/'): + return None + + redirect_to = target_path + if preserve_query and current.query: + redirect_to = f'{target_path}?{current.query}' + + resp = HttpResponse(status=status) + resp['HX-Redirect'] = redirect_to + return resp