Coverage for extras/dashboard/widgets.py: 36%
255 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
2import uuid
3from functools import cached_property
4from hashlib import sha256
5from urllib.parse import urlencode, urlparse
7import feedparser
8import requests
9from django import forms
10from django.conf import settings
11from django.core.cache import cache
12from django.db.models import Model
13from django.template.loader import render_to_string
14from django.urls import NoReverseMatch, resolve
15from django.utils.translation import gettext as _
17from core.models import ObjectType
18from extras.choices import BookmarkOrderingChoices
19from netbox.config import get_config
20from utilities.choices import Choice
21from utilities.html import clean_html
22from utilities.object_types import object_type_identifier, object_type_name
23from utilities.permissions import get_permission_for_model
24from utilities.proxy import resolve_proxies
25from utilities.querydict import dict_to_querydict
26from utilities.templatetags.builtins.filters import render_markdown
27from utilities.views import get_action_url
29from .utils import register_widget
31__all__ = (
32 'BookmarksWidget',
33 'DashboardWidget',
34 'NoteWidget',
35 'ObjectCountsWidget',
36 'ObjectListWidget',
37 'RSSFeedWidget',
38 'WidgetConfigForm',
39)
41logger = logging.getLogger('netbox.data_backends')
44def get_object_type_choices():
45 return [
46 Choice(object_type_identifier(ot), object_type_name(ot))
47 for ot in ObjectType.objects.public().order_by('app_label', 'model')
48 ]
51def object_list_widget_supports_model(model: Model) -> bool:
52 """Test whether a model is supported by the ObjectListWidget
54 In theory there could be more than one reason why a model isn't supported by the
55 ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
56 for the model. Add more tests if more conditions arise.
57 """
58 def can_resolve_model_list_view(model: Model) -> bool:
59 try:
60 get_action_url(model, action='list')
61 return True
62 except NoReverseMatch:
63 return False
65 tests = [
66 can_resolve_model_list_view,
67 ]
69 return all(test(model) for test in tests)
72def get_bookmarks_object_type_choices():
73 return [
74 Choice(object_type_identifier(ot), object_type_name(ot))
75 for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model')
76 ]
79def get_models_from_content_types(content_types):
80 """
81 Return a list of models corresponding to the given content types, identified by natural key.
82 Accepts both lowercase (e.g. "dcim.site") and PascalCase (e.g. "dcim.Site") model names.
83 """
84 models = []
85 for content_type_id in content_types:
86 app_label, model_name = content_type_id.lower().split('.')
87 try:
88 content_type = ObjectType.objects.get_by_natural_key(app_label, model_name)
89 if content_type.model_class():
90 models.append(content_type.model_class())
91 else:
92 logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
93 except ObjectType.DoesNotExist:
94 logger.debug(f"Dashboard Widget ObjectType not found: {app_label}:{model_name}")
96 return models
99class WidgetConfigForm(forms.Form):
100 pass
103class DashboardWidget:
104 """
105 Base class for custom dashboard widgets.
107 Attributes:
108 description: A brief, user-friendly description of the widget's function
109 default_title: The string to show for the widget's title when none has been specified.
110 default_config: Default configuration parameters, as a dictionary mapping
111 width: The widget's default width (1 to 12)
112 height: The widget's default height; the number of rows it consumes
113 """
114 description = None
115 default_title = None
116 default_config = {}
117 width = 4
118 height = 3
120 class ConfigForm(WidgetConfigForm):
121 """
122 The widget's configuration form.
123 """
124 pass
126 def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
127 self.id = id or str(uuid.uuid4())
128 self.config = config or self.default_config
129 self.title = title or self.default_title
130 self.color = color
131 if width:
132 self.width = width
133 if height:
134 self.height = height
135 self.x, self.y = x, y
137 def __str__(self):
138 return self.title or self.__class__.__name__
140 def set_layout(self, grid_item):
141 self.width = grid_item.get('w', 1)
142 self.height = grid_item.get('h', 1)
143 self.x = grid_item.get('x')
144 self.y = grid_item.get('y')
146 def render(self, request):
147 """
148 This method is called to render the widget's content.
150 Params:
151 request: The current request
152 """
153 raise NotImplementedError(_("{class_name} must define a render() method.").format(
154 class_name=self.__class__
155 ))
157 @property
158 def name(self):
159 return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
161 @property
162 def form_data(self):
163 return {
164 'title': self.title,
165 'color': self.color,
166 'config': self.config,
167 }
170@register_widget
171class NoteWidget(DashboardWidget):
172 default_title = _('Note')
173 description = _('Display some arbitrary custom content. Markdown is supported.')
175 class ConfigForm(WidgetConfigForm):
176 content = forms.CharField(
177 widget=forms.Textarea()
178 )
180 def render(self, request):
181 return render_markdown(self.config.get('content'))
184@register_widget
185class ObjectCountsWidget(DashboardWidget):
186 default_title = _('Object Counts')
187 description = _('Display a set of NetBox models and the number of objects created for each type.')
188 template_name = 'extras/dashboard/widgets/objectcounts.html'
190 class ConfigForm(WidgetConfigForm):
191 models = forms.MultipleChoiceField(
192 choices=get_object_type_choices
193 )
194 filters = forms.JSONField(
195 required=False,
196 label='Object filters',
197 help_text=_("Filters to apply when counting the number of objects")
198 )
200 def clean_filters(self):
201 if data := self.cleaned_data['filters']:
202 try:
203 dict(data)
204 except TypeError:
205 raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
206 return data
208 def render(self, request):
209 counts = []
210 for model in get_models_from_content_types(self.config['models']):
211 permission = get_permission_for_model(model, 'view')
212 if request.user.has_perm(permission):
213 try:
214 url = get_action_url(model, action='list')
215 except NoReverseMatch:
216 url = None
217 try:
218 qs = model.objects.restrict(request.user, 'view')
219 except AttributeError:
220 qs = model.objects.all()
221 # Apply any specified filters
222 if url and (filters := self.config.get('filters')):
223 params = dict_to_querydict(filters)
224 filterset = getattr(resolve(url).func.view_class, 'filterset', None)
225 qs = filterset(params, qs).qs
226 url = f'{url}?{params.urlencode()}'
227 object_count = qs.count
228 counts.append((model, object_count, url))
229 else:
230 counts.append((model, None, None))
232 return render_to_string(self.template_name, {
233 'counts': counts,
234 })
237@register_widget
238class ObjectListWidget(DashboardWidget):
239 default_title = _('Object List')
240 description = _('Display an arbitrary list of objects.')
241 template_name = 'extras/dashboard/widgets/objectlist.html'
242 width = 12
243 height = 4
245 class ConfigForm(WidgetConfigForm):
246 model = forms.ChoiceField(
247 choices=get_object_type_choices
248 )
249 page_size = forms.IntegerField(
250 required=False,
251 min_value=1,
252 max_value=100,
253 help_text=_('The default number of objects to display')
254 )
255 url_params = forms.JSONField(
256 required=False,
257 label='URL parameters'
258 )
260 def clean_url_params(self):
261 if data := self.cleaned_data['url_params']:
262 try:
263 urlencode(data)
264 except (TypeError, ValueError):
265 raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
266 return data
268 def clean_model(self):
269 if model_info := self.cleaned_data['model']:
270 app_label, model_name = model_info.split('.')
271 model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
272 if not object_list_widget_supports_model(model):
273 raise forms.ValidationError(
274 _(f"Invalid model selection: {self['model'].data} is not supported.")
275 )
277 return model_info
279 def render(self, request):
280 app_label, model_name = self.config['model'].split('.')
281 model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
282 if not model:
283 logger.debug(f"Dashboard Widget model_class not found: {app_label}:{model_name}")
284 return None
286 # Evaluate user's permission. Note that this controls only whether the HTMX element is
287 # embedded on the page: The view itself will also evaluate permissions separately.
288 permission = get_permission_for_model(model, 'view')
289 has_permission = request.user.has_perm(permission)
291 try:
292 htmx_url = get_action_url(model, action='list')
293 except NoReverseMatch:
294 htmx_url = None
295 parameters = self.config.get('url_params') or {}
296 if page_size := self.config.get('page_size'):
297 parameters['per_page'] = page_size
298 parameters['embedded'] = True
300 if parameters and htmx_url is not None:
301 try:
302 htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
303 except ValueError:
304 pass
305 return render_to_string(self.template_name, {
306 'model_name': model_name,
307 'has_permission': has_permission,
308 'htmx_url': htmx_url,
309 })
312@register_widget
313class RSSFeedWidget(DashboardWidget):
314 default_title = _('RSS Feed')
315 default_config = {
316 'max_entries': 10,
317 'cache_timeout': 3600, # seconds
318 'request_timeout': 3, # seconds
319 'requires_internet': True,
320 }
321 description = _('Embed an RSS feed from an external website.')
322 template_name = 'extras/dashboard/widgets/rssfeed.html'
323 width = 6
324 height = 4
326 class ConfigForm(WidgetConfigForm):
327 feed_url = forms.URLField(
328 label=_('Feed URL'),
329 assume_scheme='https'
330 )
331 requires_internet = forms.BooleanField(
332 label=_('Requires external connection'),
333 required=False,
334 )
335 max_entries = forms.IntegerField(
336 min_value=1,
337 max_value=1000,
338 help_text=_('The maximum number of objects to display')
339 )
340 cache_timeout = forms.IntegerField(
341 min_value=600, # 10 minutes
342 max_value=86400, # 24 hours
343 help_text=_('How long to stored the cached content (in seconds)')
344 )
345 request_timeout = forms.IntegerField(
346 min_value=1,
347 max_value=60,
348 required=False,
349 help_text=_('Timeout value for fetching the feed (in seconds)')
350 )
352 def render(self, request):
353 return render_to_string(self.template_name, {
354 'url': self.config['feed_url'],
355 **self.get_feed()
356 })
358 @cached_property
359 def cache_key(self):
360 url = self.config['feed_url']
361 url_checksum = sha256(url.encode('utf-8')).hexdigest()
362 # The version segment invalidates entries cached by a pre-sanitization release: such
363 # entries live under the old key and are never read, so they can't be served unsanitized.
364 return f'dashboard_rss_2_{url_checksum}'
366 def get_feed(self):
367 if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
368 return {
369 'isolated_deployment': True,
370 }
372 # Fetch RSS content from cache if available. Cached content is always sanitized before
373 # it is written (see below), so no sanitization is needed on read.
374 if feed_content := cache.get(self.cache_key):
375 return {
376 'feed': feedparser.FeedParserDict(feed_content),
377 }
379 # Fetch feed content from remote server
380 try:
381 response = requests.get(
382 url=self.config['feed_url'],
383 headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
384 proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
385 timeout=self.config.get('request_timeout', 3),
386 )
387 response.raise_for_status()
388 except requests.exceptions.RequestException as e:
389 return {
390 'error': e,
391 }
393 # Parse feed content
394 feed = feedparser.parse(response.content)
395 if not feed.bozo:
396 # Cap number of entries
397 max_entries = self.config.get('max_entries')
398 feed['entries'] = feed['entries'][:max_entries]
399 # Sanitize feed-controlled content before caching/rendering
400 self.sanitize_entries(feed['entries'])
401 # Cache the feed content
402 cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
404 return {
405 'feed': feed,
406 }
408 @staticmethod
409 def sanitize_entries(entries):
410 """
411 Sanitize feed-controlled entry content in place. The feed URL is untrusted external
412 content, so we must guard against dangerous URL schemes (e.g. javascript:) in entry
413 links and sanitize entry summaries as defense-in-depth.
414 """
415 allowed_schemes = get_config().ALLOWED_URL_SCHEMES
416 for entry in entries:
417 # Blank any link whose scheme isn't permitted (blocks javascript:, data:, etc.).
418 # This is the load-bearing control: the template renders entry.link into an href.
419 if link := entry.get('link'):
420 result = urlparse(link)
421 if result.scheme and result.scheme.lower() not in allowed_schemes:
422 entry['link'] = ''
423 # Sanitize the summary HTML as defense-in-depth. The template renders entry.summary
424 # with auto-escaping (not |safe), so this is not currently load-bearing; it guards
425 # against a future change that renders the summary as markup.
426 if summary := entry.get('summary'):
427 entry['summary'] = clean_html(summary, allowed_schemes)
430@register_widget
431class BookmarksWidget(DashboardWidget):
432 default_title = _('Bookmarks')
433 default_config = {
434 'order_by': BookmarkOrderingChoices.ORDERING_NEWEST,
435 }
436 description = _('Show your personal bookmarks')
437 template_name = 'extras/dashboard/widgets/bookmarks.html'
439 class ConfigForm(WidgetConfigForm):
440 object_types = forms.MultipleChoiceField(
441 choices=get_bookmarks_object_type_choices,
442 required=False
443 )
444 order_by = forms.ChoiceField(
445 choices=BookmarkOrderingChoices
446 )
447 max_items = forms.IntegerField(
448 min_value=1,
449 required=False
450 )
452 def render(self, request):
453 from extras.models import Bookmark
455 if request.user.is_anonymous:
456 bookmarks = list()
457 else:
458 bookmarks = Bookmark.objects.filter(user=request.user)
459 if object_types := self.config.get('object_types'):
460 models = get_models_from_content_types(object_types)
461 content_types = ObjectType.objects.get_for_models(*models).values()
462 bookmarks = bookmarks.filter(object_type__in=content_types)
463 if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
464 bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
465 elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
466 bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
467 else:
468 bookmarks = bookmarks.order_by(self.config['order_by'])
469 if max_items := self.config.get('max_items'):
470 bookmarks = bookmarks[:max_items]
472 return render_to_string(self.template_name, {
473 'bookmarks': bookmarks,
474 })