Merge pull request #18854 from netbox-community/18782-dashboard-broken-on-notification-list-widget

Fixes #18782: Misconfigured `ObjectListWidget`s now degrade gracefully
This commit is contained in:
bctiemann 2025-03-18 14:45:23 -04:00 committed by GitHub
commit 7c152e9234
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 82 additions and 1 deletions

View File

@ -9,6 +9,7 @@ import requests
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -42,6 +43,27 @@ def get_object_type_choices():
] ]
def object_list_widget_supports_model(model: Model) -> bool:
"""Test whether a model is supported by the ObjectListWidget
In theory there could be more than one reason why a model isn't supported by the
ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
for the model. Add more tests if more conditions arise.
"""
def can_resolve_model_list_view(model: Model) -> bool:
try:
reverse(get_viewname(model, action='list'))
return True
except Exception:
return False
tests = [
can_resolve_model_list_view,
]
return all(test(model) for test in tests)
def get_bookmarks_object_type_choices(): def get_bookmarks_object_type_choices():
return [ return [
(object_type_identifier(ot), object_type_name(ot)) (object_type_identifier(ot), object_type_name(ot))
@ -234,6 +256,17 @@ class ObjectListWidget(DashboardWidget):
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary.")) raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
return data return data
def clean_model(self):
if model_info := self.cleaned_data['model']:
app_label, model_name = model_info.split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
if not object_list_widget_supports_model(model):
raise forms.ValidationError(
_(f"Invalid model selection: {self['model'].data} is not supported.")
)
return model_info
def render(self, request): def render(self, request):
app_label, model_name = self.config['model'].split('.') app_label, model_name = self.config['model'].split('.')
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class() model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
@ -257,7 +290,7 @@ class ObjectListWidget(DashboardWidget):
parameters['per_page'] = page_size parameters['per_page'] = page_size
parameters['embedded'] = True parameters['embedded'] = True
if parameters: if parameters and htmx_url is not None:
try: try:
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}' htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
except ValueError: except ValueError:

View File

@ -0,0 +1,48 @@
from django.test import tag, TestCase
from extras.dashboard.widgets import ObjectListWidget
class ObjectListWidgetTests(TestCase):
def test_widget_config_form_validates_model(self):
model_info = 'extras.notification'
form = ObjectListWidget.ConfigForm({'model': model_info})
self.assertFalse(form.is_valid())
@tag('regression')
def test_widget_fails_gracefully(self):
"""
Example:
'2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
'class': 'extras.ObjectListWidget',
'color': 'indigo',
'title': 'Object List',
'config': {
'model': 'extras.notification',
'page_size': None,
'url_params': None
}
}
"""
config = {
# 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
'color': 'yellow',
'title': 'this should fail',
'config': {
'model': 'extras.notification',
'page_size': None,
'url_params': None,
},
}
class Request:
class User:
def has_perm(self, *args, **kwargs):
return True
user = User()
mock_request = Request()
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request)
self.assertTrue('Unable to load content. Invalid view name:' in rendered)