Coverage for netbox/ui/panels.py: 64%
181 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
1from django.apps import apps
2from django.template.loader import render_to_string
3from django.utils.translation import gettext_lazy as _
5from netbox.ui import attrs
6from netbox.ui.actions import CopyContent
7from utilities.data import resolve_attr_path
8from utilities.permissions import get_permission_for_model
9from utilities.querydict import dict_to_querydict
10from utilities.string import title
11from utilities.templatetags.plugins import _get_registered_content
12from utilities.views import get_viewname
14__all__ = (
15 'CommentsPanel',
16 'ContextTablePanel',
17 'JSONPanel',
18 'NestedGroupObjectPanel',
19 'ObjectAttributesPanel',
20 'ObjectPanel',
21 'ObjectsTablePanel',
22 'OrganizationalObjectPanel',
23 'Panel',
24 'PluginContentPanel',
25 'RelatedObjectsPanel',
26 'TemplatePanel',
27 'TextCodePanel',
28)
31#
32# Base classes
33#
35class Panel:
36 """
37 A block of content rendered within an HTML template.
39 Panels are arranged within rows and columns, (generally) render as discrete "cards" within the user interface. Each
40 panel has a title and may have one or more actions associated with it, which will be rendered as hyperlinks in the
41 top right corner of the card.
43 Attributes:
44 template_name (str): The name of the template used to render the panel
46 Parameters:
47 title (str): The human-friendly title of the panel
48 actions (list): An iterable of PanelActions to include in the panel header
49 """
50 template_name = None
51 title = None
52 actions = None
54 def __init__(self, title=None, actions=None):
55 if title is not None:
56 self.title = title
57 if actions is not None:
58 self.actions = actions
59 self.actions = list(self.actions) if self.actions else []
61 def get_context(self, context):
62 """
63 Return the context data to be used when rendering the panel.
65 Parameters:
66 context (dict): The template context
67 """
68 return {
69 'request': context.get('request'),
70 'object': context.get('object'),
71 'perms': context.get('perms'),
72 'title': self.title,
73 'actions': self.actions,
74 'panel_class': self.__class__.__name__,
75 }
77 def should_render(self, context):
78 """
79 Determines whether the panel should render on the page. (Default: True)
81 Parameters:
82 context (dict): The panel's prepared context (the return value of get_context())
83 """
84 return True
86 def render(self, context):
87 """
88 Render the panel as HTML.
90 Parameters:
91 context (dict): The template context
92 """
93 ctx = self.get_context(context)
94 if not self.should_render(ctx):
95 return ''
96 return render_to_string(self.template_name, ctx, request=ctx.get('request'))
99#
100# Object-specific panels
101#
103class ObjectPanel(Panel):
104 """
105 Base class for object-specific panels.
107 Parameters:
108 accessor (str): The dotted path in context data to the object being rendered (default: "object")
109 """
110 accessor = 'object'
112 def __init__(self, accessor=None, **kwargs):
113 super().__init__(**kwargs)
115 if accessor is not None:
116 self.accessor = accessor
118 def get_context(self, context):
119 obj = resolve_attr_path(context, self.accessor)
120 if self.title is not None:
121 title_ = self.title
122 elif obj is not None:
123 title_ = title(obj._meta.verbose_name)
124 else:
125 title_ = None
126 return {
127 **super().get_context(context),
128 'title': title_,
129 'object': obj,
130 }
133class ObjectAttributesPanelMeta(type):
135 def __new__(mcls, name, bases, namespace, **kwargs):
136 declared = {}
138 # Walk MRO parents (excluding `object`) for declared attributes
139 for base in reversed([b for b in bases if hasattr(b, "_attrs")]):
140 for key, attr in getattr(base, '_attrs', {}).items():
141 if key not in declared: 141 ↛ 140line 141 didn't jump to line 140 because the condition on line 141 was always true
142 declared[key] = attr
144 # Add local declarations in the order they appear in the class body
145 for key, attr in namespace.items():
146 if isinstance(attr, attrs.ObjectAttribute):
147 declared[key] = attr
149 namespace['_attrs'] = declared
151 # Remove Attrs from the class namespace to keep things tidy
152 local_items = [key for key, attr in namespace.items() if isinstance(attr, attrs.ObjectAttribute)]
153 for key in local_items:
154 namespace.pop(key)
156 cls = super().__new__(mcls, name, bases, namespace, **kwargs)
157 return cls
160class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
161 """
162 A panel which displays selected attributes of an object.
164 Attributes are added to the panel by declaring ObjectAttribute instances in the class body (similar to fields on
165 a Django form). Attributes are displayed in the order they are declared.
167 Note that the `only` and `exclude` parameters are mutually exclusive.
169 Parameters:
170 only (list): If specified, only attributes in this list will be displayed
171 exclude (list): If specified, attributes in this list will be excluded from display
172 """
173 template_name = 'ui/panels/object_attributes.html'
175 def __init__(self, only=None, exclude=None, **kwargs):
176 super().__init__(**kwargs)
178 # Set included/excluded attributes
179 if only is not None and exclude is not None: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 raise ValueError("only and exclude cannot both be specified.")
181 self.only = only or []
182 self.exclude = exclude or []
184 @staticmethod
185 def _name_to_label(name):
186 """
187 Format an attribute's name to be presented as a human-friendly label.
188 """
189 label = name[:1].upper() + name[1:]
190 label = label.replace('_', ' ')
191 return _(label)
193 def get_context(self, context):
194 # Determine which attributes to display in the panel based on only/exclude args
195 attr_names = set(self._attrs.keys())
196 if self.only:
197 attr_names &= set(self.only)
198 elif self.exclude:
199 attr_names -= set(self.exclude)
201 ctx = super().get_context(context)
203 return {
204 **ctx,
205 'attrs': [
206 {
207 'label': attr.label or self._name_to_label(name),
208 'value': attr.render(ctx['object'], {
209 'name': name,
210 'perms': ctx['perms'],
211 'preferences': context.get('preferences', {}),
212 }),
213 } for name, attr in self._attrs.items() if name in attr_names
214 ],
215 }
218class OrganizationalObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
219 """
220 An ObjectPanel with attributes common to OrganizationalModels. Includes `name` and `description` attributes.
221 """
222 name = attrs.TextAttr('name', label=_('Name'))
223 description = attrs.TextAttr('description', label=_('Description'))
226class NestedGroupObjectPanel(ObjectAttributesPanel, metaclass=ObjectAttributesPanelMeta):
227 """
228 An ObjectPanel with attributes common to NestedGroupObjects. Includes the `parent` attribute.
229 """
230 parent = attrs.NestedObjectAttr('parent', label=_('Parent'), linkify=True)
231 name = attrs.TextAttr('name', label=_('Name'))
232 description = attrs.TextAttr('description', label=_('Description'))
235class CommentsPanel(ObjectPanel):
236 """
237 A panel which displays comments associated with an object.
239 Parameters:
240 field_name (str): The name of the comment field on the object (default: "comments")
241 """
242 template_name = 'ui/panels/comments.html'
243 title = _('Comments')
245 def __init__(self, field_name='comments', **kwargs):
246 super().__init__(**kwargs)
247 self.field_name = field_name
249 def get_context(self, context):
250 ctx = super().get_context(context)
251 return {
252 **ctx,
253 'comments': getattr(ctx['object'], self.field_name, None),
254 }
257class JSONPanel(ObjectPanel):
258 """
259 A panel which renders formatted JSON data from an object's JSONField.
261 Parameters:
262 field_name (str): The name of the JSON field on the object
263 copy_button (bool): Set to True (default) to include a copy-to-clipboard button
264 """
265 template_name = 'ui/panels/json.html'
267 def __init__(self, field_name, copy_button=True, **kwargs):
268 super().__init__(**kwargs)
269 self.field_name = field_name
271 if copy_button: 271 ↛ exitline 271 didn't return from function '__init__' because the condition on line 271 was always true
272 self.actions.append(CopyContent(f'panel_{field_name}'))
274 def get_context(self, context):
275 ctx = super().get_context(context)
276 return {
277 **ctx,
278 'data': getattr(ctx['object'], self.field_name, None),
279 'field_name': self.field_name,
280 }
283#
284# Miscellaneous panels
285#
287class RelatedObjectsPanel(Panel):
288 """
289 A panel which displays the types and counts of related objects.
290 """
291 template_name = 'ui/panels/related_objects.html'
292 title = _('Related Objects')
294 def get_context(self, context):
295 return {
296 **super().get_context(context),
297 'related_models': context.get('related_models'),
298 }
301class ObjectsTablePanel(Panel):
302 """
303 A panel which displays a table of objects (rendered via HTMX).
305 Parameters:
306 model (str): The dotted label of the model to be added (e.g. "dcim.site")
307 filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is
308 a callable, it will be passed the current template context.
309 include_columns (list): A list of column names to always display (overrides user preferences)
310 exclude_columns (list): A list of column names to hide from the table (overrides user preferences)
311 """
312 template_name = 'ui/panels/objects_table.html'
313 title = None
315 def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs):
316 super().__init__(**kwargs)
318 # Validate the model label format
319 if '.' not in model: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 raise ValueError(f"Invalid model label: {model}")
321 self.model_label = model
322 self.filters = filters or {}
323 self.include_columns = include_columns or []
324 self.exclude_columns = exclude_columns or []
326 @property
327 def model(self):
328 try:
329 return apps.get_model(self.model_label)
330 except LookupError:
331 raise ValueError(f"Invalid model label: {self.model_label}")
333 def get_context(self, context):
334 model = self.model
336 # If no title is specified, derive one from the model name
337 panel_title = self.title or title(model._meta.verbose_name_plural)
339 url_params = {
340 k: v(context) if callable(v) else v for k, v in self.filters.items()
341 }
342 if 'return_url' not in url_params and 'object' in context:
343 url_params['return_url'] = context['object'].get_absolute_url()
344 if self.include_columns:
345 url_params['include_columns'] = ','.join(self.include_columns)
346 if self.exclude_columns:
347 url_params['exclude_columns'] = ','.join(self.exclude_columns)
348 return {
349 **super().get_context(context),
350 'title': panel_title,
351 'viewname': get_viewname(model, 'list'),
352 'url_params': dict_to_querydict(url_params),
353 }
355 def should_render(self, context):
356 """
357 Hide the panel if the user does not have view permission for the panel's model.
358 """
359 request = context.get('request')
360 if request is None:
361 return True
363 return request.user.has_perm(get_permission_for_model(self.model, 'view'))
366class TemplatePanel(Panel):
367 """
368 A panel which renders custom content using an HTML template.
370 Parameters:
371 template_name (str): The name of the template to render
372 """
373 def __init__(self, template_name, **kwargs):
374 self.template_name = template_name
375 super().__init__(**kwargs)
377 def get_context(self, context):
378 # Pass the entire context to the template, but let the panel's own context take precedence
379 # for panel-specific variables (title, actions, panel_class)
380 return {
381 **context.flatten(),
382 **super().get_context(context)
383 }
386class TextCodePanel(ObjectPanel):
387 """
388 A panel displaying a text field as a pre-formatted code block.
389 """
390 template_name = 'ui/panels/text_code.html'
392 def __init__(self, field_name, show_sync_warning=False, **kwargs):
393 super().__init__(**kwargs)
394 self.field_name = field_name
395 self.show_sync_warning = show_sync_warning
397 def get_context(self, context):
398 ctx = super().get_context(context)
399 return {
400 **ctx,
401 'show_sync_warning': self.show_sync_warning,
402 'value': getattr(ctx['object'], self.field_name, None),
403 }
406class PluginContentPanel(Panel):
407 """
408 A panel which displays embedded plugin content.
410 Parameters:
411 method (str): The name of the plugin method to render (e.g. "left_page")
412 """
413 def __init__(self, method, **kwargs):
414 super().__init__(**kwargs)
415 self.method = method
417 def render(self, context):
418 # Override the default render() method to simply embed rendered plugin content
419 obj = context.get('object')
420 return _get_registered_content(obj, self.method, context)
423class ContextTablePanel(ObjectPanel):
424 """
425 A panel which renders a django-tables2/NetBoxTable instance provided
426 via the view's extra context.
428 This is useful when you already have a fully constructed table
429 (custom queryset, special columns, no list view) and just want to
430 render it inside a declarative layout panel.
432 Parameters:
433 table (str | callable): Either the context key holding the table
434 (e.g. "vlan_table") or a callable which accepts the template
435 context and returns a table instance.
436 """
437 template_name = 'ui/panels/context_table.html'
439 def __init__(self, table, **kwargs):
440 super().__init__(**kwargs)
441 self.table = table
443 def _resolve_table(self, context):
444 if callable(self.table):
445 return self.table(context)
446 return context.get(self.table)
448 def get_context(self, context):
449 return {
450 **super().get_context(context),
451 'table': self._resolve_table(context),
452 }
454 def should_render(self, context):
455 return context.get('table') is not None