Compare commits

...

11 Commits

Author SHA1 Message Date
Jeremy Stretch
bcc410d99f Closes #20924: Ready UI components for use by plugins (#21827)
* Misc cleanup

* Include permissions in TemplatedAttr context

* Introduce CircuitTerminationPanel to replace generic panel

* Replace all instantiations of Panel with TemplatePanel

* Misc cleanup for layouts

* Enable specifying column grid width

* Panel.render() should pass the request to render_to_string()

* CopyContent does not need to override render()

* Avoid setting mutable panel actions

* Catch exceptions raised when rendering embedded plugin content

* Handle panel title when object is not available

* Introduce should_render() method on Panel class

* Misc cleanup

* Pass the value returned by get_context() to should_render()

* Yet more cleanup

* Fix typos

* Clean up object attrs

* Replace candidate template panels with ObjectAttributesPanel subclasses

* Add tests for object attrs

* Remove beta warning

* PluginContentPanel should not call should_render()

* Clean up AddObject

* speed.html should reference value for port_speed

* Address PR feedback
2026-04-06 15:35:18 -04:00
bctiemann
02f9ca8f01 Merge pull request #21816 from netbox-community/21770-embedded-table-columns
Closes #21770: Enable including/excluding columns on ObjectsTablePanel
2026-04-03 13:04:27 -04:00
Martin Hauser
5ad4e95207 Closes #21720: Improve validation of URLs containing HTTP basic authentication (#21822)
Fixes #21720
2026-04-02 11:42:06 -05:00
Mark Robert Coleman
a06a300913 Implement {module} position inheritance for nested module bays (#21753)
* Implement {module} position inheritance for nested module bays (#19796)

Enables a single ModuleType to produce correctly named components at any
nesting depth by resolving {module} in module bay position fields during
tree traversal. The user controls the separator through the position
field template itself (e.g. {module}/1 vs {module}-1 vs {module}.1).

Model layer:
- Add _get_inherited_positions() to resolve {module} in positions as
  the module tree is walked from root to leaf
- Update _resolve_module_placeholder() with single-token logic: one
  {module} resolves to the leaf bay's inherited position; multi-token
  continues level-by-level replacement for backwards compatibility

Form layer:
- Update _get_module_bay_tree() to resolve {module} in positions during
  traversal, propagating parent positions through the tree
- Extract validation into _validate_module_tokens() private method

Tests:
- Position inheritance at depth 2 and 3
- Custom separator (dot notation)
- Multi-token backwards compatibility
- Documentation for position inheritance

Fixes: #19796

* Consolidate {module} placeholder logic into shared utilities and add API validation

Extract get_module_bay_positions() and resolve_module_placeholder() into
dcim/utils.py as shared routines used by the model, form, and API serializer.
This eliminates duplicated traversal and resolution logic across three layers.

Key changes:
- Add position inheritance: {module} tokens in bay position fields resolve
  using the parent bay's position during hierarchy traversal
- Single {module} token now resolves to the leaf bay's inherited position
- Mismatched token count vs tree depth now raises ValueError instead of
  silently producing partial strings
- API serializer validation uses shared utilities for parity with the form
- Fix error message wording ("levels deep" instead of "in tree")
2026-04-01 17:58:16 -07:00
Jeremy Stretch
6c08941542 Tweak behavior of include_columns 2026-04-01 14:58:41 -04:00
Jeremy Stretch
be1a29d7ee Misc cleanup 2026-04-01 14:46:53 -04:00
Jeremy Stretch
f06f8f3f1d Exclude assigned object columns from IP addresses table on interface views 2026-04-01 14:25:31 -04:00
Jeremy Stretch
a45ec6620a Protect exempt columns from exclusion 2026-04-01 14:17:57 -04:00
Jeremy Stretch
bd35afe320 Apply column hiding before prefetching 2026-04-01 14:14:13 -04:00
Jeremy Stretch
364868a207 Implement exclude_columns on embedded tables 2026-04-01 13:46:59 -04:00
Jeremy Stretch
d4569df305 Closes #21770: Enable including/excluding columns on ObjectsTablePanel 2026-04-01 13:32:42 -04:00
42 changed files with 1069 additions and 454 deletions

View File

@@ -32,6 +32,26 @@ For example, `{vc_position:1}` will render as `1` when no Virtual Chassis positi
Automatic renaming is supported for all modular component types (those listed above).
### Position Inheritance for Nested Modules
When using nested module bays (modules installed inside other modules), the `{module}` placeholder
can also be used in the **position** field of module bay templates to inherit the parent bay's
position. This allows a single module type to produce correctly named components at any nesting
depth, with a user-controlled separator.
For example, a line card module type might define sub-bay positions as `{module}/1`, `{module}/2`,
etc. When the line card is installed in a device bay with position `3`, these sub-bay positions
resolve to `3/1`, `3/2`, etc. An SFP module type with interface template `SFP {module}` installed
in sub-bay `3/2` then produces interface `SFP 3/2`.
The separator between levels is defined by the user in the position field template itself. Using
`{module}-1` produces positions like `3-1`, while `{module}.1` produces `3.1`. This provides
full flexibility without requiring a global separator configuration.
!!! note
If the position field does not contain `{module}`, no inheritance occurs and behavior is
unchanged from previous versions.
## Fields
### Manufacturer

View File

@@ -1,12 +1,9 @@
# UI Components
!!! note "New in NetBox v4.5"
All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
!!! note "New in NetBox v4.6"
All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
!!! danger "Beta Feature"
UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
## Page Layout
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
**super().get_context(context),
'changes': get_changes()[:10],
}
def should_render(self, context):
return len(context['changes']) > 0
```
NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
NetBox also includes a set of panels suited for specific uses, such as displaying object details or embedding a table of related objects. These are listed below.
::: netbox.ui.panels.Panel
@@ -85,26 +85,6 @@ NetBox also includes a set of panels suite for specific uses, such as display ob
::: netbox.ui.panels.ObjectAttributesPanel
#### Object Attributes
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
| Class | Description |
|--------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
::: netbox.ui.panels.OrganizationalObjectPanel
::: netbox.ui.panels.NestedGroupObjectPanel
@@ -119,9 +99,13 @@ The following classes are available to represent object attributes within an Obj
::: netbox.ui.panels.TemplatePanel
::: netbox.ui.panels.TextCodePanel
::: netbox.ui.panels.ContextTablePanel
::: netbox.ui.panels.PluginContentPanel
## Panel Actions
### Panel Actions
Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
@@ -146,3 +130,60 @@ panels.ObjectsTablePanel(
::: netbox.ui.actions.AddObject
::: netbox.ui.actions.CopyContent
## Object Attributes
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
| Class | Description |
|------------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.DateTimeAttr` | A date or datetime value |
| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object (includes ancestors) |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
::: netbox.ui.attrs.ObjectAttribute
::: netbox.ui.attrs.AddressAttr
::: netbox.ui.attrs.BooleanAttr
::: netbox.ui.attrs.ChoiceAttr
::: netbox.ui.attrs.ColorAttr
::: netbox.ui.attrs.DateTimeAttr
::: netbox.ui.attrs.GenericForeignKeyAttr
::: netbox.ui.attrs.GPSCoordinatesAttr
::: netbox.ui.attrs.ImageAttr
::: netbox.ui.attrs.NestedObjectAttr
::: netbox.ui.attrs.NumericAttr
::: netbox.ui.attrs.RelatedObjectAttr
::: netbox.ui.attrs.RelatedObjectListAttr
::: netbox.ui.attrs.TemplatedAttr
::: netbox.ui.attrs.TextAttr
::: netbox.ui.attrs.TimezoneAttr
::: netbox.ui.attrs.UtilizationAttr

View File

@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
template_name = 'circuits/panels/circuit_circuit_termination.html'
title = _('Termination')
def __init__(self, accessor=None, side=None, **kwargs):
super().__init__(**kwargs)
if accessor is not None:
self.accessor = accessor
if side is not None:
self.side = side
def __init__(self, side, accessor=None, **kwargs):
super().__init__(accessor=accessor, **kwargs)
self.side = side
def get_context(self, context):
return {
@@ -58,6 +54,26 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
)
class CircuitTerminationPanel(panels.ObjectAttributesPanel):
title = _('Circuit Termination')
circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
connection = attrs.TemplatedAttr(
'pk',
template_name='circuits/circuit_termination/attrs/connection.html',
label=_('Connection'),
)
speed = attrs.TemplatedAttr(
'port_speed',
template_name='circuits/circuit_termination/attrs/speed.html',
label=_('Speed'),
)
xconnect_id = attrs.TextAttr('xconnect_id', label=_('Cross-Connect'), style='font-monospace')
pp_info = attrs.TextAttr('pp_info', label=_('Patch Panel/Port'))
description = attrs.TextAttr('description')
class CircuitGroupPanel(panels.OrganizationalObjectPanel):
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')

View File

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
Panel,
RelatedObjectsPanel,
)
from netbox.views import generic
@@ -53,6 +52,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.ProviderAccount',
filters={'provider_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider'],
actions=[
actions.AddObject(
'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
@@ -62,6 +62,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider'],
actions=[
actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
],
@@ -161,6 +162,7 @@ class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.Circuit',
filters={'provider_account_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider_account'],
actions=[
actions.AddObject(
'circuits.Circuit',
@@ -257,6 +259,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='circuits.VirtualCircuit',
filters={'provider_network_id': lambda ctx: ctx['object'].pk},
exclude_columns=['provider_network'],
actions=[
actions.AddObject(
'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
@@ -508,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
Panel(
template_name='circuits/panels/circuit_termination.html',
title=_('Circuit Termination'),
)
panels.CircuitTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
@@ -801,6 +801,7 @@ class VirtualCircuitView(generic.ObjectView):
model='circuits.VirtualCircuitTermination',
title=_('Terminations'),
filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
exclude_columns=['virtual_circuit'],
actions=[
actions.AddObject(
'circuits.VirtualCircuitTermination',

View File

@@ -94,6 +94,7 @@ class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='core.DataFile',
filters={'source_id': lambda ctx: ctx['object'].pk},
exclude_columns=['source'],
),
],
)
@@ -192,8 +193,14 @@ class DataFileView(generic.ObjectView):
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
)
),
)

View File

@@ -8,6 +8,7 @@ from rest_framework import serializers
from dcim.choices import *
from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
from ipam.api.serializers_.ip import IPAddressSerializer
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
@@ -207,13 +208,7 @@ class ModuleSerializer(PrimaryModelSerializer):
if not all([device, module_type, module_bay]):
return data
# Build module bay tree for MODULE_TOKEN placeholder resolution (outermost to innermost)
module_bays = []
current_bay = module_bay
while current_bay:
module_bays.append(current_bay)
current_bay = current_bay.module.module_bay if current_bay.module else None
module_bays.reverse()
positions = get_module_bay_positions(module_bay)
for templates_attr, component_attr in [
('consoleporttemplates', 'consoleports'),
@@ -236,17 +231,10 @@ class ModuleSerializer(PrimaryModelSerializer):
raise serializers.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
if template.name.count(MODULE_TOKEN) != len(module_bays):
raise serializers.ValidationError(
_(
"Cannot install module with placeholder values in a module bay tree {level} in tree "
"but {tokens} placeholders given."
).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
)
)
for bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, bay.position, 1)
try:
resolved_name = resolve_module_placeholder(template.name, positions)
except ValueError as e:
raise serializers.ValidationError(str(e))
existing_item = installed_components.get(resolved_name)

View File

@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from utilities.forms import get_field_value
__all__ = (
@@ -70,18 +71,6 @@ class InterfaceCommonForm(forms.Form):
class ModuleCommonForm(forms.Form):
def _get_module_bay_tree(self, module_bay):
module_bays = []
while module_bay:
module_bays.append(module_bay)
if module_bay.module:
module_bay = module_bay.module.module_bay
else:
module_bay = None
module_bays.reverse()
return module_bays
def clean(self):
super().clean()
@@ -100,7 +89,7 @@ class ModuleCommonForm(forms.Form):
self.instance._disable_replication = True
return
module_bays = self._get_module_bay_tree(module_bay)
positions = get_module_bay_positions(module_bay)
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
@@ -119,25 +108,15 @@ class ModuleCommonForm(forms.Form):
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
resolved_name = template.name
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name:
if not module_bay.position:
raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
if len(module_bays) != template.name.count(MODULE_TOKEN):
raise forms.ValidationError(
_(
"Cannot install module with placeholder values in a module bay tree {level} in tree "
"but {tokens} placeholders given."
).format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
)
)
for module_bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
try:
resolved_name = resolve_module_placeholder(template.name, positions)
except ValueError as e:
raise forms.ValidationError(str(e))
existing_item = installed_components.get(resolved_name)

View File

@@ -9,6 +9,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
@@ -185,33 +186,27 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
return VC_POSITION_RE.sub(replacer, value)
def _get_module_tree(self, module):
modules = []
while module:
modules.append(module)
if module.module_bay:
module = module.module_bay.module
else:
module = None
modules.reverse()
return modules
def _resolve_module_placeholder(self, value, module=None, device=None):
if MODULE_TOKEN in value and module:
modules = self._get_module_tree(module)
for m in modules:
value = value.replace(MODULE_TOKEN, m.module_bay.position, 1)
if VC_POSITION_RE.search(value) is not None:
def _resolve_all_placeholders(self, value, module=None, device=None):
has_module = MODULE_TOKEN in value
has_vc = VC_POSITION_RE.search(value) is not None
if not has_module and not has_vc:
return value
if has_module and module:
positions = get_module_bay_positions(module.module_bay)
value = resolve_module_placeholder(value, positions)
if has_vc:
resolved_device = (module.device if module else None) or device
value = self._resolve_vc_position(value, resolved_device)
return value
def resolve_name(self, module=None, device=None):
return self._resolve_module_placeholder(self.name, module, device)
return self._resolve_all_placeholders(self.name, module, device)
def resolve_label(self, module=None, device=None):
return self._resolve_module_placeholder(self.label, module, device)
return self._resolve_all_placeholders(self.label, module, device)
def resolve_position(self, module=None, device=None):
return self._resolve_all_placeholders(self.position, module, device)
class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -745,14 +740,11 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates')
def resolve_position(self, module):
return self._resolve_module_placeholder(self.position, module)
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
position=self.resolve_position(kwargs.get('module')),
position=self.resolve_position(kwargs.get('module'), kwargs.get('device')),
enabled=self.enabled,
**kwargs
)

View File

@@ -999,6 +999,273 @@ class ModuleBayTestCase(TestCase):
nested_bay = module.modulebays.get(name='Sub-bay 1-1')
self.assertEqual(nested_bay.position, '1-1')
#
# Position inheritance tests (#19796)
#
def test_position_inheritance_depth_2(self):
"""
A module bay with position '{module}/2' under a parent bay with position '1'
should resolve to position '1/2'. A single {module} in the interface template
should then resolve to '1/2'.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Chassis for Inheritance',
slug='chassis-for-inheritance'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Line card slot 1',
position='1'
)
line_card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Line Card with Inherited Bays'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='SFP bay {module}/1',
position='{module}/1'
)
ModuleBayTemplate.objects.create(
module_type=line_card_type,
name='SFP bay {module}/2',
position='{module}/2'
)
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='SFP with Inherited Path'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='SFP {module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
device = Device.objects.create(
name='Inheritance Chassis',
device_type=device_type,
role=device_role,
site=site
)
lc_bay = device.modulebays.get(name='Line card slot 1')
line_card = Module.objects.create(
device=device,
module_bay=lc_bay,
module_type=line_card_type
)
sfp_bay = line_card.modulebays.get(name='SFP bay 1/2')
sfp_module = Module.objects.create(
device=device,
module_bay=sfp_bay,
module_type=sfp_type
)
interface = sfp_module.interfaces.first()
self.assertEqual(interface.name, 'SFP 1/2')
def test_position_inheritance_depth_3(self):
"""
Position inheritance at depth 3: positions should chain through the tree.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Deep Chassis',
slug='deep-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Slot A',
position='A'
)
mid_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Mid Module'
)
ModuleBayTemplate.objects.create(
module_type=mid_type,
name='Sub {module}-1',
position='{module}-1'
)
leaf_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Leaf Module'
)
InterfaceTemplate.objects.create(
module_type=leaf_type,
name='Port {module}',
type=InterfaceTypeChoices.TYPE_1GE_FIXED
)
device = Device.objects.create(
name='Deep Device',
device_type=device_type,
role=device_role,
site=site
)
slot_a = device.modulebays.get(name='Slot A')
mid_module = Module.objects.create(
device=device,
module_bay=slot_a,
module_type=mid_type
)
sub_bay = mid_module.modulebays.get(name='Sub A-1')
self.assertEqual(sub_bay.position, 'A-1')
leaf_module = Module.objects.create(
device=device,
module_bay=sub_bay,
module_type=leaf_type
)
interface = leaf_module.interfaces.first()
self.assertEqual(interface.name, 'Port A-1')
def test_position_inheritance_custom_separator(self):
"""
Users control the separator through the position field template.
Using '.' instead of '/' should work correctly.
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Dot Separator Chassis',
slug='dot-separator-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Bay 1',
position='1'
)
card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Card with Dot Separator'
)
ModuleBayTemplate.objects.create(
module_type=card_type,
name='Port {module}.1',
position='{module}.1'
)
sfp_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='SFP Dot'
)
InterfaceTemplate.objects.create(
module_type=sfp_type,
name='eth{module}',
type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
)
device = Device.objects.create(
name='Dot Device',
device_type=device_type,
role=device_role,
site=site
)
bay = device.modulebays.get(name='Bay 1')
card = Module.objects.create(
device=device,
module_bay=bay,
module_type=card_type
)
port_bay = card.modulebays.get(name='Port 1.1')
sfp = Module.objects.create(
device=device,
module_bay=port_bay,
module_type=sfp_type
)
interface = sfp.interfaces.first()
self.assertEqual(interface.name, 'eth1.1')
def test_multi_token_backwards_compat(self):
"""
Multi-token {module}/{module} at matching depth should still resolve
level-by-level (backwards compatibility).
"""
manufacturer = Manufacturer.objects.first()
site = Site.objects.first()
device_role = DeviceRole.objects.first()
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Multi Token Chassis',
slug='multi-token-chassis'
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Slot 1',
position='1'
)
card_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Card for Multi Token'
)
ModuleBayTemplate.objects.create(
module_type=card_type,
name='Port 1',
position='2'
)
iface_type = ModuleType.objects.create(
manufacturer=manufacturer,
model='Interface Module Multi Token'
)
InterfaceTemplate.objects.create(
module_type=iface_type,
name='Gi{module}/{module}',
type=InterfaceTypeChoices.TYPE_1GE_FIXED
)
device = Device.objects.create(
name='Multi Token Device',
device_type=device_type,
role=device_role,
site=site
)
slot = device.modulebays.get(name='Slot 1')
card = Module.objects.create(
device=device,
module_bay=slot,
module_type=card_type
)
port = card.modulebays.get(name='Port 1')
iface_module = Module.objects.create(
device=device,
module_bay=port,
module_type=iface_type
)
interface = iface_module.interfaces.first()
self.assertEqual(interface.name, 'Gi1/2')
@tag('regression') # #20912
def test_module_bay_parent_cleared_when_module_removed(self):
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""

View File

@@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
@@ -75,7 +74,7 @@ class RackReservationPanel(panels.ObjectAttributesPanel):
unit_count = attrs.TextAttr('unit_count', label=_("Total U's"))
status = attrs.ChoiceAttr('status')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
user = attrs.RelatedObjectAttr('user')
user = attrs.RelatedObjectAttr('user', linkify=True)
description = attrs.TextAttr('description')
@@ -220,8 +219,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
description = attrs.TextAttr('description')
maximum_draw = attrs.TextAttr('maximum_draw')
allocated_draw = attrs.TextAttr('allocated_draw')
maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W')
allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W')
class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -244,7 +243,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
positions = attrs.TextAttr('positions')
positions = attrs.NumericAttr('positions')
description = attrs.TextAttr('description')
@@ -255,7 +254,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
positions = attrs.TextAttr('positions')
positions = attrs.NumericAttr('positions')
description = attrs.TextAttr('description')
@@ -268,6 +267,15 @@ class ModuleBayPanel(panels.ObjectAttributesPanel):
description = attrs.TextAttr('description')
class InstalledModulePanel(panels.ObjectAttributesPanel):
title = _('Installed Module')
module = attrs.RelatedObjectAttr('installed_module', linkify=True)
manufacturer = attrs.RelatedObjectAttr('installed_module.module_type.manufacturer', linkify=True)
module_type = attrs.RelatedObjectAttr('installed_module.module_type', linkify=True)
serial = attrs.TextAttr('installed_module.serial', label=_('Serial number'), style='font-monospace')
asset_tag = attrs.TextAttr('installed_module.asset_tag', style='font-monospace')
class DeviceBayPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
name = attrs.TextAttr('name')
@@ -275,6 +283,12 @@ class DeviceBayPanel(panels.ObjectAttributesPanel):
description = attrs.TextAttr('description')
class InstalledDevicePanel(panels.ObjectAttributesPanel):
title = _('Installed Device')
device = attrs.RelatedObjectAttr('installed_device', linkify=True)
device_type = attrs.RelatedObjectAttr('installed_device.device_type')
class InventoryItemPanel(panels.ObjectAttributesPanel):
device = attrs.RelatedObjectAttr('device', linkify=True)
parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
@@ -393,10 +407,6 @@ class ConnectionPanel(panels.ObjectPanel):
'show_endpoints': self.show_endpoints,
}
def render(self, context):
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class InventoryItemsPanel(panels.ObjectPanel):
"""
@@ -414,10 +424,6 @@ class InventoryItemsPanel(panels.ObjectPanel):
),
]
def render(self, context):
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class VirtualChassisMembersPanel(panels.ObjectPanel):
"""
@@ -451,10 +457,8 @@ class VirtualChassisMembersPanel(panels.ObjectPanel):
'vc_members': context.get('vc_members'),
}
def render(self, context):
if not context.get('vc_members'):
return ''
return super().render(context)
def should_render(self, context):
return bool(context.get('vc_members'))
class PowerUtilizationPanel(panels.ObjectPanel):
@@ -470,11 +474,9 @@ class PowerUtilizationPanel(panels.ObjectPanel):
'vc_members': context.get('vc_members'),
}
def render(self, context):
def should_render(self, context):
obj = context['object']
if not obj.powerports.exists() or not obj.poweroutlets.exists():
return ''
return super().render(context)
return obj.powerports.exists() and obj.poweroutlets.exists()
class InterfacePanel(panels.ObjectAttributesPanel):
@@ -485,7 +487,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
type = attrs.ChoiceAttr('type')
speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
duplex = attrs.ChoiceAttr('duplex')
mtu = attrs.TextAttr('mtu', label=_('MTU'))
mtu = attrs.NumericAttr('mtu', label=_('MTU'))
enabled = attrs.BooleanAttr('enabled')
mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
description = attrs.TextAttr('description')
@@ -494,7 +496,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
tx_power = attrs.TextAttr('tx_power', label=_('Transmit power'), format_string='{} dBm')
tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
@@ -527,12 +529,9 @@ class InterfaceConnectionPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_connection.html'
title = _('Connection')
def render(self, context):
def should_render(self, context):
obj = context.get('object')
if obj and obj.is_virtual:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
return False if (obj is None or obj.is_virtual) else True
class VirtualCircuitPanel(panels.ObjectPanel):
@@ -542,12 +541,11 @@ class VirtualCircuitPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_virtual_circuit.html'
title = _('Virtual Circuit')
def render(self, context):
def should_render(self, context):
obj = context.get('object')
if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
return False
return True
class InterfaceWirelessPanel(panels.ObjectPanel):
@@ -557,12 +555,9 @@ class InterfaceWirelessPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_wireless.html'
title = _('Wireless')
def render(self, context):
def should_render(self, context):
obj = context.get('object')
if not obj or not obj.is_wireless:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
return False if (obj is None or not obj.is_wireless) else True
class WirelessLANsPanel(panels.ObjectPanel):
@@ -572,9 +567,6 @@ class WirelessLANsPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_wireless_lans.html'
title = _('Wireless LANs')
def render(self, context):
def should_render(self, context):
obj = context.get('object')
if not obj or not obj.is_wireless:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
return False if (obj is None or not obj.is_wireless) else True

View File

@@ -3,6 +3,59 @@ from collections import defaultdict
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import router, transaction
from django.utils.translation import gettext as _
from dcim.constants import MODULE_TOKEN
def get_module_bay_positions(module_bay):
"""
Given a module bay, traverse up the module hierarchy and return
a list of bay position strings from root to leaf, resolving any
{module} tokens in each position using the parent position
(position inheritance).
"""
positions = []
while module_bay:
pos = module_bay.position or ''
if positions and MODULE_TOKEN in pos:
pos = pos.replace(MODULE_TOKEN, positions[-1])
positions.append(pos)
if module_bay.module:
module_bay = module_bay.module.module_bay
else:
module_bay = None
positions.reverse()
return positions
def resolve_module_placeholder(value, positions):
"""
Resolve {module} placeholder tokens in a string using the given
list of module bay positions (ordered root to leaf).
A single {module} token resolves to the leaf (immediate parent) bay's position.
Multiple tokens must match the tree depth and resolve level-by-level.
Returns the resolved string.
Raises ValueError if token count is greater than 1 and doesn't match tree depth.
"""
if MODULE_TOKEN not in value:
return value
token_count = value.count(MODULE_TOKEN)
if token_count == 1:
return value.replace(MODULE_TOKEN, positions[-1])
if token_count == len(positions):
for pos in positions:
value = value.replace(MODULE_TOKEN, pos, 1)
return value
raise ValueError(
_("Cannot install module with placeholder values in a module bay tree "
"{level} levels deep but {tokens} placeholders given.").format(
level=len(positions), tokens=token_count
)
)
def compile_path_node(ct_id, object_id):

View File

@@ -27,7 +27,6 @@ from netbox.ui.panels import (
NestedGroupObjectPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
Panel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -258,6 +257,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.Region',
title=_('Child Regions'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -390,6 +390,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.SiteGroup',
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -540,6 +541,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
ObjectsTablePanel(
model='dcim.Location',
filters={'site_id': lambda ctx: ctx['object'].pk},
exclude_columns=['site'],
actions=[
actions.AddObject('dcim.Location', url_params={'site': lambda ctx: ctx['object'].pk}),
],
@@ -552,6 +554,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
},
exclude_columns=['site'],
actions=[
actions.AddObject('dcim.Device', url_params={'site': lambda ctx: ctx['object'].pk}),
],
@@ -674,6 +677,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.Location',
title=_('Child Locations'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject(
'dcim.Location',
@@ -692,6 +696,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
'rack_id': settings.FILTERS_NULL_CHOICE_VALUE,
'parent_bay_id': settings.FILTERS_NULL_CHOICE_VALUE,
},
exclude_columns=['location'],
actions=[
actions.AddObject(
'dcim.Device',
@@ -1686,6 +1691,7 @@ class ModuleTypeProfileView(generic.ObjectView):
filters={
'profile_id': lambda ctx: ctx['object'].pk,
},
exclude_columns=['profile'],
actions=[
actions.AddObject(
'dcim.ModuleType',
@@ -1764,7 +1770,7 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
CommentsPanel(),
],
right_panels=[
Panel(
TemplatePanel(
title=_('Attributes'),
template_name='dcim/panels/module_type_attributes.html',
),
@@ -2427,6 +2433,7 @@ class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.DeviceRole',
title=_('Child Device Roles'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.DeviceRole', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -2527,6 +2534,7 @@ class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
model='dcim.Platform',
title=_('Child Platforms'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject('dcim.Platform', url_params={'parent': lambda ctx: ctx['object'].pk}),
],
@@ -2605,6 +2613,7 @@ class DeviceView(generic.ObjectView):
ObjectsTablePanel(
model='dcim.VirtualDeviceContext',
filters={'device_id': lambda ctx: ctx['object'].pk},
exclude_columns=['device'],
actions=[
actions.AddObject('dcim.VirtualDeviceContext', url_params={'device': lambda ctx: ctx['object'].pk}),
],
@@ -2617,6 +2626,7 @@ class DeviceView(generic.ObjectView):
model='ipam.Service',
title=_('Application Services'),
filters={'device_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject(
'ipam.Service',
@@ -2934,7 +2944,7 @@ class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
CommentsPanel(),
],
right_panels=[
Panel(
TemplatePanel(
title=_('Module Type'),
template_name='dcim/panels/module_type.html',
),
@@ -3376,11 +3386,13 @@ class InterfaceView(generic.ObjectView):
model='ipam.IPAddress',
filters={'interface_id': lambda ctx: ctx['object'].pk},
title=_('IP Addresses'),
exclude_columns=['assigned', 'assigned_object', 'assigned_object_parent'],
),
ObjectsTablePanel(
model='dcim.MACAddress',
filters={'interface_id': lambda ctx: ctx['object'].pk},
title=_('MAC Addresses'),
exclude_columns=['assigned_object', 'assigned_object_parent'],
),
ObjectsTablePanel(
model='ipam.VLAN',
@@ -3740,10 +3752,7 @@ class ModuleBayView(generic.ObjectView):
],
right_panels=[
CustomFieldsPanel(),
Panel(
title=_('Installed Module'),
template_name='dcim/panels/installed_module.html',
),
panels.InstalledModulePanel(),
],
)
@@ -3815,10 +3824,7 @@ class DeviceBayView(generic.ObjectView):
TagsPanel(),
],
right_panels=[
Panel(
title=_('Installed Device'),
template_name='dcim/panels/installed_device.html',
),
panels.InstalledDevicePanel(),
],
)
@@ -4310,11 +4316,11 @@ class CableView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
Panel(
TemplatePanel(
title=_('Termination A'),
template_name='dcim/panels/cable_termination_a.html',
),
Panel(
TemplatePanel(
title=_('Termination B'),
template_name='dcim/panels/cable_termination_b.html',
),

View File

@@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from netbox.ui import actions, attrs, panels
@@ -65,12 +64,8 @@ class CustomFieldsPanel(panels.ObjectPanel):
'custom_fields': obj.get_custom_fields_by_group(),
}
def render(self, context):
ctx = self.get_context(context)
# Hide the panel if no custom fields exist
if not ctx['custom_fields']:
return ''
return render_to_string(self.template_name, self.get_context(context))
def should_render(self, context):
return bool(context['custom_fields'])
class ImageAttachmentsPanel(panels.ObjectsTablePanel):

View File

@@ -7,6 +7,9 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
"""
Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
the route distinguisher (RD).
Parameters:
show_rd (bool): If true, the VRF's RD will be included. (Default: False)
"""
template_name = 'ipam/attrs/vrf.html'
@@ -14,11 +17,17 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
super().__init__(*args, **kwargs)
self.show_rd = show_rd
def render(self, obj, context):
value = self.get_value(obj)
return render_to_string(self.template_name, {
**self.get_context(obj, context),
'name': context['name'],
'value': value,
def get_context(self, obj, attr, value, context):
return {
'show_rd': self.show_rd,
}
def render(self, obj, context):
name = context['name']
value = self.get_value(obj)
return render_to_string(self.template_name, {
**self.get_context(obj, name, value, context),
'name': name,
'value': value,
})

View File

@@ -229,11 +229,9 @@ class VLANCustomerVLANsPanel(panels.ObjectsTablePanel):
],
)
def render(self, context):
def should_render(self, context):
obj = context.get('object')
if not obj or obj.qinq_role != 'svlan':
return ''
return super().render(context)
return False if (obj is None or obj.qinq_role != 'svlan') else True
class ServiceTemplatePanel(panels.ObjectAttributesPanel):

View File

@@ -16,6 +16,7 @@ from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -55,11 +56,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
layout.Column(
panels.VRFPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -70,6 +73,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
ContextTablePanel('export_targets_table', title=_('Export route targets')),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)
def get_extra_context(self, request, instance):
@@ -169,10 +177,12 @@ class RouteTargetView(generic.ObjectView):
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -207,6 +217,11 @@ class RouteTargetView(generic.ObjectView):
),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)
@@ -1331,6 +1346,7 @@ class VLANTranslationPolicyView(generic.ObjectView):
'ipam.vlantranslationrule',
filters={'policy_id': lambda ctx: ctx['object'].pk},
title=_('VLAN translation rules'),
exclude_columns=['policy'],
actions=[
actions.AddObject(
'ipam.vlantranslationrule',
@@ -1628,6 +1644,7 @@ class VLANView(generic.ObjectView):
'ipam.prefix',
filters={'vlan_id': lambda ctx: ctx['object'].pk},
title=_('Prefixes'),
exclude_columns=['vlan'],
actions=[
actions.AddObject(
'ipam.prefix',

View File

@@ -185,6 +185,18 @@ class BaseTable(tables.Table):
columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
self._set_columns(columns)
# Apply column inclusion/exclusion (overrides user preferences)
if columns_param := request.GET.get('include_columns'):
for column_name in columns_param.split(','):
if column_name in self.columns.names():
self.columns.show(column_name)
if exclude_columns := request.GET.get('exclude_columns'):
exclude_columns = exclude_columns.split(',')
for column_name in exclude_columns:
if column_name in self.columns.names() and column_name not in self.exempt_columns:
self.columns.hide(column_name)
self._apply_prefetching()
if ordering is not None:
self.order_by = ordering

View File

@@ -1,3 +1,5 @@
from types import SimpleNamespace
from django.test import TestCase
from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -76,7 +78,7 @@ class ChoiceAttrTest(TestCase):
self.termination.get_role_display(),
)
self.assertEqual(
attr.get_context(self.termination, {}),
attr.get_context(self.termination, 'role', attr.get_value(self.termination), {}),
{'bg_color': self.termination.get_role_color()},
)
@@ -88,7 +90,7 @@ class ChoiceAttrTest(TestCase):
self.termination.interface.get_type_display(),
)
self.assertEqual(
attr.get_context(self.termination, {}),
attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
{'bg_color': None},
)
@@ -100,7 +102,9 @@ class ChoiceAttrTest(TestCase):
self.termination.virtual_circuit.get_status_display(),
)
self.assertEqual(
attr.get_context(self.termination, {}),
attr.get_context(
self.termination, 'virtual_circuit.status', attr.get_value(self.termination), {}
),
{'bg_color': self.termination.virtual_circuit.get_status_color()},
)
@@ -213,3 +217,176 @@ class RelatedObjectListAttrTest(TestCase):
self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
self.assertNotIn('IKE Proposal 3', rendered)
self.assertIn('', rendered)
class TextAttrTest(TestCase):
def test_get_value_with_format_string(self):
attr = attrs.TextAttr('asn', format_string='AS{}')
obj = SimpleNamespace(asn=65000)
self.assertEqual(attr.get_value(obj), 'AS65000')
def test_get_value_without_format_string(self):
attr = attrs.TextAttr('name')
obj = SimpleNamespace(name='foo')
self.assertEqual(attr.get_value(obj), 'foo')
def test_get_value_none_skips_format_string(self):
attr = attrs.TextAttr('name', format_string='prefix-{}')
obj = SimpleNamespace(name=None)
self.assertIsNone(attr.get_value(obj))
def test_get_context(self):
attr = attrs.TextAttr('name', style='text-monospace', copy_button=True)
obj = SimpleNamespace(name='bar')
context = attr.get_context(obj, 'name', 'bar', {})
self.assertEqual(context['style'], 'text-monospace')
self.assertTrue(context['copy_button'])
class NumericAttrTest(TestCase):
def test_get_context_with_unit_accessor(self):
attr = attrs.NumericAttr('speed', unit_accessor='speed_unit')
obj = SimpleNamespace(speed=1000, speed_unit='Mbps')
context = attr.get_context(obj, 'speed', 1000, {})
self.assertEqual(context['unit'], 'Mbps')
def test_get_context_without_unit_accessor(self):
attr = attrs.NumericAttr('speed')
obj = SimpleNamespace(speed=1000)
context = attr.get_context(obj, 'speed', 1000, {})
self.assertIsNone(context['unit'])
def test_get_context_copy_button(self):
attr = attrs.NumericAttr('speed', copy_button=True)
obj = SimpleNamespace(speed=1000)
context = attr.get_context(obj, 'speed', 1000, {})
self.assertTrue(context['copy_button'])
class BooleanAttrTest(TestCase):
def test_false_value_shown_by_default(self):
attr = attrs.BooleanAttr('enabled')
obj = SimpleNamespace(enabled=False)
self.assertIs(attr.get_value(obj), False)
def test_false_value_hidden_when_display_false_disabled(self):
attr = attrs.BooleanAttr('enabled', display_false=False)
obj = SimpleNamespace(enabled=False)
self.assertIsNone(attr.get_value(obj))
def test_true_value_always_shown(self):
attr = attrs.BooleanAttr('enabled', display_false=False)
obj = SimpleNamespace(enabled=True)
self.assertIs(attr.get_value(obj), True)
class ImageAttrTest(TestCase):
def test_invalid_decoding_raises_value_error(self):
with self.assertRaises(ValueError):
attrs.ImageAttr('image', decoding='invalid')
def test_default_decoding_for_lazy_image(self):
attr = attrs.ImageAttr('image')
self.assertTrue(attr.load_lazy)
self.assertEqual(attr.decoding, 'async')
def test_default_decoding_for_non_lazy_image(self):
attr = attrs.ImageAttr('image', load_lazy=False)
self.assertFalse(attr.load_lazy)
self.assertIsNone(attr.decoding)
def test_explicit_decoding_value(self):
attr = attrs.ImageAttr('image', load_lazy=False, decoding='sync')
self.assertEqual(attr.decoding, 'sync')
def test_get_context(self):
attr = attrs.ImageAttr('image', load_lazy=False, decoding='async')
obj = SimpleNamespace(image='test.png')
context = attr.get_context(obj, 'image', 'test.png', {})
self.assertEqual(context['decoding'], 'async')
self.assertFalse(context['load_lazy'])
class RelatedObjectAttrTest(TestCase):
def test_get_context_with_grouped_by(self):
region = SimpleNamespace(name='Region 1')
site = SimpleNamespace(name='Site 1', region=region)
obj = SimpleNamespace(site=site)
attr = attrs.RelatedObjectAttr('site', grouped_by='region')
context = attr.get_context(obj, 'site', site, {})
self.assertEqual(context['group'], region)
def test_get_context_without_grouped_by(self):
site = SimpleNamespace(name='Site 1')
obj = SimpleNamespace(site=site)
attr = attrs.RelatedObjectAttr('site')
context = attr.get_context(obj, 'site', site, {})
self.assertIsNone(context['group'])
def test_get_context_linkify(self):
site = SimpleNamespace(name='Site 1')
obj = SimpleNamespace(site=site)
attr = attrs.RelatedObjectAttr('site', linkify=True)
context = attr.get_context(obj, 'site', site, {})
self.assertTrue(context['linkify'])
class GenericForeignKeyAttrTest(TestCase):
def test_get_context_content_type(self):
value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider'))
obj = SimpleNamespace()
attr = attrs.GenericForeignKeyAttr('assigned_object')
context = attr.get_context(obj, 'assigned_object', value, {})
self.assertEqual(context['content_type'], 'provider')
def test_get_context_linkify(self):
value = SimpleNamespace(_meta=SimpleNamespace(verbose_name='provider'))
obj = SimpleNamespace()
attr = attrs.GenericForeignKeyAttr('assigned_object', linkify=True)
context = attr.get_context(obj, 'assigned_object', value, {})
self.assertTrue(context['linkify'])
class GPSCoordinatesAttrTest(TestCase):
def test_missing_latitude_returns_placeholder(self):
attr = attrs.GPSCoordinatesAttr()
obj = SimpleNamespace(latitude=None, longitude=-74.006)
self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
def test_missing_longitude_returns_placeholder(self):
attr = attrs.GPSCoordinatesAttr()
obj = SimpleNamespace(latitude=40.712, longitude=None)
self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
def test_both_missing_returns_placeholder(self):
attr = attrs.GPSCoordinatesAttr()
obj = SimpleNamespace(latitude=None, longitude=None)
self.assertEqual(attr.render(obj, {'name': 'coordinates'}), attr.placeholder)
class DateTimeAttrTest(TestCase):
def test_default_spec(self):
attr = attrs.DateTimeAttr('created')
obj = SimpleNamespace(created='2024-01-01')
context = attr.get_context(obj, 'created', '2024-01-01', {})
self.assertEqual(context['spec'], 'seconds')
def test_date_spec(self):
attr = attrs.DateTimeAttr('created', spec='date')
obj = SimpleNamespace(created='2024-01-01')
context = attr.get_context(obj, 'created', '2024-01-01', {})
self.assertEqual(context['spec'], 'date')
def test_minutes_spec(self):
attr = attrs.DateTimeAttr('created', spec='minutes')
obj = SimpleNamespace(created='2024-01-01')
context = attr.get_context(obj, 'created', '2024-01-01', {})
self.assertEqual(context['spec'], 'minutes')

View File

@@ -59,7 +59,7 @@ class PanelAction:
"""
# Enforce permissions
user = context['request'].user
if not user.has_perms(self.permissions):
if self.permissions and not user.has_perms(self.permissions):
return ''
return render_to_string(self.template_name, self.get_context(context))
@@ -118,19 +118,19 @@ class AddObject(LinkAction):
url_params (dict): A dictionary of arbitrary URL parameters to append to the resolved URL
"""
def __init__(self, model, url_params=None, **kwargs):
# Resolve the model class from its app.name label
try:
app_label, model_name = model.split('.')
model = apps.get_model(app_label, model_name)
except (ValueError, LookupError):
# Resolve the model from its label
if '.' not in model:
raise ValueError(f"Invalid model label: {model}")
try:
self.model = apps.get_model(model)
except LookupError:
raise ValueError(f"Invalid model label: {model}")
view_name = get_viewname(model, 'add')
kwargs.setdefault('label', _('Add'))
kwargs.setdefault('button_icon', 'plus-thick')
kwargs.setdefault('permissions', [get_permission_for_model(model, 'add')])
kwargs.setdefault('permissions', [get_permission_for_model(self.model, 'add')])
super().__init__(view_name=view_name, url_params=url_params, **kwargs)
super().__init__(view_name=get_viewname(self.model, 'add'), url_params=url_params, **kwargs)
class CopyContent(PanelAction):
@@ -148,10 +148,8 @@ class CopyContent(PanelAction):
super().__init__(**kwargs)
self.target_id = target_id
def render(self, context):
return render_to_string(self.template_name, {
def get_context(self, context):
return {
**super().get_context(context),
'target_id': self.target_id,
'label': self.label,
'button_class': self.button_class,
'button_icon': self.button_icon,
})
}

View File

@@ -29,11 +29,27 @@ PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
#
# Mixins
#
class MapURLMixin:
_map_url = None
@property
def map_url(self):
if self._map_url is True:
return get_config().MAPS_URL
if self._map_url:
return self._map_url
return None
#
# Attributes
#
class ObjectAttribute:
"""
Base class for representing an attribute of an object.
@@ -64,17 +80,20 @@ class ObjectAttribute:
"""
return resolve_attr_path(obj, self.accessor)
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
"""
Return any additional template context used to render the attribute value.
Parameters:
obj (object): The object for which the attribute is being rendered
context (dict): The root template context
attr (str): The name of the attribute being rendered
value: The value of the attribute on the object
context (dict): The panel template context
"""
return {}
def render(self, obj, context):
name = context['name']
value = self.get_value(obj)
# If the value is empty, render a placeholder
@@ -82,8 +101,8 @@ class ObjectAttribute:
return self.placeholder
return render_to_string(self.template_name, {
**self.get_context(obj, context),
'name': context['name'],
**self.get_context(obj, name, value, context),
'name': name,
'value': value,
})
@@ -112,7 +131,7 @@ class TextAttr(ObjectAttribute):
return self.format_string.format(value)
return value
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
return {
'style': self.style,
'copy_button': self.copy_button,
@@ -134,7 +153,7 @@ class NumericAttr(ObjectAttribute):
self.unit_accessor = unit_accessor
self.copy_button = copy_button
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
return {
'unit': unit,
@@ -172,7 +191,7 @@ class ChoiceAttr(ObjectAttribute):
return resolve_attr_path(target, field_name)
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
target, field_name = self._resolve_target(obj)
if target is None:
return {'bg_color': None}
@@ -241,7 +260,7 @@ class ImageAttr(ObjectAttribute):
decoding = 'async'
self.decoding = decoding
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
return {
'decoding': self.decoding,
'load_lazy': self.load_lazy,
@@ -264,8 +283,7 @@ class RelatedObjectAttr(ObjectAttribute):
self.linkify = linkify
self.grouped_by = grouped_by
def get_context(self, obj, context):
value = self.get_value(obj)
def get_context(self, obj, attr, value, context):
group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return {
'linkify': self.linkify,
@@ -300,14 +318,13 @@ class RelatedObjectListAttr(RelatedObjectAttr):
self.max_items = max_items
self.overflow_indicator = overflow_indicator
def _get_items(self, obj):
def _get_items(self, items):
"""
Retrieve items from the given object using the accessor path.
Returns a tuple of (items, has_more) where items is a list of resolved objects
and has_more indicates whether additional items exist beyond the max_items limit.
"""
items = resolve_attr_path(obj, self.accessor)
if items is None:
return [], False
@@ -322,8 +339,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
return items[:self.max_items], has_more
def get_context(self, obj, context):
items, has_more = self._get_items(obj)
def get_context(self, obj, attr, value, context):
items, has_more = self._get_items(value)
return {
'linkify': self.linkify,
@@ -338,14 +355,15 @@ class RelatedObjectListAttr(RelatedObjectAttr):
}
def render(self, obj, context):
context = context or {}
context_data = self.get_context(obj, context)
name = context['name']
value = self.get_value(obj)
context_data = self.get_context(obj, name, value, context)
if not context_data['items']:
return self.placeholder
return render_to_string(self.template_name, {
'name': context.get('name'),
'name': name,
**context_data,
})
@@ -366,11 +384,12 @@ class NestedObjectAttr(ObjectAttribute):
self.linkify = linkify
self.max_depth = max_depth
def get_context(self, obj, context):
value = self.get_value(obj)
nodes = value.get_ancestors(include_self=True)
if self.max_depth:
nodes = list(nodes)[-self.max_depth:]
def get_context(self, obj, attr, value, context):
nodes = []
if value is not None:
nodes = value.get_ancestors(include_self=True)
if self.max_depth:
nodes = list(nodes)[-self.max_depth:]
return {
'nodes': nodes,
'linkify': self.linkify,
@@ -394,40 +413,35 @@ class GenericForeignKeyAttr(ObjectAttribute):
super().__init__(*args, **kwargs)
self.linkify = linkify
def get_context(self, obj, context):
value = self.get_value(obj)
content_type = value._meta.verbose_name
def get_context(self, obj, attr, value, context):
content_type = value._meta.verbose_name if value is not None else None
return {
'content_type': content_type,
'linkify': self.linkify,
}
class AddressAttr(ObjectAttribute):
class AddressAttr(MapURLMixin, ObjectAttribute):
"""
A physical or mailing address.
Parameters:
map_url (bool): If true, the address will render as a hyperlink using settings.MAPS_URL
map_url (bool/str): The URL to use when rendering the address. If True, the address will render as a
hyperlink using settings.MAPS_URL.
"""
template_name = 'ui/attrs/address.html'
def __init__(self, *args, map_url=True, **kwargs):
super().__init__(*args, **kwargs)
if map_url is True:
self.map_url = get_config().MAPS_URL
elif map_url:
self.map_url = map_url
else:
self.map_url = None
self._map_url = map_url
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
return {
'map_url': self.map_url,
}
class GPSCoordinatesAttr(ObjectAttribute):
class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
"""
A GPS coordinates pair comprising latitude and longitude values.
@@ -440,24 +454,18 @@ class GPSCoordinatesAttr(ObjectAttribute):
label = _('GPS coordinates')
def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
super().__init__(accessor=None, **kwargs)
super().__init__(accessor=latitude_attr, **kwargs)
self.latitude_attr = latitude_attr
self.longitude_attr = longitude_attr
if map_url is True:
self.map_url = get_config().MAPS_URL
elif map_url:
self.map_url = map_url
else:
self.map_url = None
self._map_url = map_url
def render(self, obj, context=None):
context = context or {}
def render(self, obj, context):
latitude = resolve_attr_path(obj, self.latitude_attr)
longitude = resolve_attr_path(obj, self.longitude_attr)
if latitude is None or longitude is None:
return self.placeholder
return render_to_string(self.template_name, {
**context,
'name': context['name'],
'latitude': latitude,
'longitude': longitude,
'map_url': self.map_url,
@@ -478,7 +486,7 @@ class DateTimeAttr(ObjectAttribute):
super().__init__(*args, **kwargs)
self.spec = spec
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
return {
'spec': self.spec,
}
@@ -504,8 +512,9 @@ class TemplatedAttr(ObjectAttribute):
self.template_name = template_name
self.context = context or {}
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
return {
**context,
**self.context,
'object': obj,
}

View File

@@ -21,10 +21,16 @@ class Layout:
"""
def __init__(self, *rows):
for i, row in enumerate(rows):
if type(row) is not Row:
if not isinstance(row, Row):
raise TypeError(f"Row {i} must be a Row instance, not {type(row)}.")
self.rows = rows
def __iter__(self):
return iter(self.rows)
def __repr__(self):
return f"Layout({len(self.rows)} rows)"
class Row:
"""
@@ -35,10 +41,16 @@ class Row:
"""
def __init__(self, *columns):
for i, column in enumerate(columns):
if type(column) is not Column:
if not isinstance(column, Column):
raise TypeError(f"Column {i} must be a Column instance, not {type(column)}.")
self.columns = columns
def __iter__(self):
return iter(self.columns)
def __repr__(self):
return f"Row({len(self.columns)} columns)"
class Column:
"""
@@ -46,12 +58,25 @@ class Column:
Parameters:
*panels: One or more Panel instances
width: Bootstrap grid column width (1-12). If unset, the column will expand to fill available space.
"""
def __init__(self, *panels):
def __init__(self, *panels, width=None):
for i, panel in enumerate(panels):
if not isinstance(panel, Panel):
raise TypeError(f"Panel {i} must be an instance of a Panel, not {type(panel)}.")
if width is not None:
if type(width) is not int:
raise ValueError(f"Column width must be an integer, not {type(width)}")
if width not in range(1, 13):
raise ValueError(f"Column width must be an integer between 1 and 12 (got {width}).")
self.panels = panels
self.width = width
def __iter__(self):
return iter(self.panels)
def __repr__(self):
return f"Column({len(self.panels)} panels)"
#
@@ -62,7 +87,7 @@ class SimpleLayout(Layout):
"""
A layout with one row of two columns and a second row with one column.
Plugin content registered for `left_page`, `right_page`, or `full_width_path` is included automatically. Most object
Plugin content registered for `left_page`, `right_page`, or `full_width_page` is included automatically. Most object
views in NetBox utilize this layout.
```

View File

@@ -45,18 +45,17 @@ class Panel:
Parameters:
title (str): The human-friendly title of the panel
actions (list): An iterable of PanelActions to include in the panel header
template_name (str): Overrides the default template name, if defined
"""
template_name = None
title = None
actions = None
def __init__(self, title=None, actions=None, template_name=None):
def __init__(self, title=None, actions=None):
if title is not None:
self.title = title
self.actions = actions or self.actions or []
if template_name is not None:
self.template_name = template_name
if actions is not None:
self.actions = actions
self.actions = list(self.actions) if self.actions else []
def get_context(self, context):
"""
@@ -74,6 +73,15 @@ class Panel:
'panel_class': self.__class__.__name__,
}
def should_render(self, context):
"""
Determines whether the panel should render on the page. (Default: True)
Parameters:
context (dict): The panel's prepared context (the return value of get_context())
"""
return True
def render(self, context):
"""
Render the panel as HTML.
@@ -81,7 +89,10 @@ class Panel:
Parameters:
context (dict): The template context
"""
return render_to_string(self.template_name, self.get_context(context))
ctx = self.get_context(context)
if not self.should_render(ctx):
return ''
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
#
@@ -105,9 +116,15 @@ class ObjectPanel(Panel):
def get_context(self, context):
obj = resolve_attr_path(context, self.accessor)
if self.title is not None:
title_ = self.title
elif obj is not None:
title_ = title(obj._meta.verbose_name)
else:
title_ = None
return {
**super().get_context(context),
'title': self.title or title(obj._meta.verbose_name),
'title': title_,
'object': obj,
}
@@ -187,7 +204,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
'attrs': [
{
'label': attr.label or self._name_to_label(name),
'value': attr.render(ctx['object'], {'name': name}),
'value': attr.render(ctx['object'], {'name': name, 'perms': ctx['perms']}),
} for name, attr in self._attrs.items() if name in attr_names
],
}
@@ -225,9 +242,10 @@ class CommentsPanel(ObjectPanel):
self.field_name = field_name
def get_context(self, context):
ctx = super().get_context(context)
return {
**super().get_context(context),
'comments': getattr(context['object'], self.field_name),
**ctx,
'comments': getattr(ctx['object'], self.field_name, None),
}
@@ -249,9 +267,10 @@ class JSONPanel(ObjectPanel):
self.actions.append(CopyContent(f'panel_{field_name}'))
def get_context(self, context):
ctx = super().get_context(context)
return {
**super().get_context(context),
'data': getattr(context['object'], self.field_name),
**ctx,
'data': getattr(ctx['object'], self.field_name, None),
'field_name': self.field_name,
}
@@ -282,35 +301,49 @@ class ObjectsTablePanel(Panel):
model (str): The dotted label of the model to be added (e.g. "dcim.site")
filters (dict): A dictionary of arbitrary URL parameters to append to the table's URL. If the value of a key is
a callable, it will be passed the current template context.
include_columns (list): A list of column names to always display (overrides user preferences)
exclude_columns (list): A list of column names to hide from the table (overrides user preferences)
"""
template_name = 'ui/panels/objects_table.html'
title = None
def __init__(self, model, filters=None, **kwargs):
def __init__(self, model, filters=None, include_columns=None, exclude_columns=None, **kwargs):
super().__init__(**kwargs)
# Resolve the model class from its app.name label
try:
app_label, model_name = model.split('.')
self.model = apps.get_model(app_label, model_name)
except (ValueError, LookupError):
# Validate the model label format
if '.' not in model:
raise ValueError(f"Invalid model label: {model}")
self.model_label = model
self.filters = filters or {}
self.include_columns = include_columns or []
self.exclude_columns = exclude_columns or []
# If no title is specified, derive one from the model name
if self.title is None:
self.title = title(self.model._meta.verbose_name_plural)
@property
def model(self):
try:
return apps.get_model(self.model_label)
except LookupError:
raise ValueError(f"Invalid model label: {self.model_label}")
def get_context(self, context):
model = self.model
# If no title is specified, derive one from the model name
panel_title = self.title or title(model._meta.verbose_name_plural)
url_params = {
k: v(context) if callable(v) else v for k, v in self.filters.items()
}
if 'return_url' not in url_params and 'object' in context:
url_params['return_url'] = context['object'].get_absolute_url()
if self.include_columns:
url_params['include_columns'] = ','.join(self.include_columns)
if self.exclude_columns:
url_params['exclude_columns'] = ','.join(self.exclude_columns)
return {
**super().get_context(context),
'viewname': get_viewname(self.model, 'list'),
'title': panel_title,
'viewname': get_viewname(model, 'list'),
'url_params': dict_to_querydict(url_params),
}
@@ -322,12 +355,17 @@ class TemplatePanel(Panel):
Parameters:
template_name (str): The name of the template to render
"""
def __init__(self, template_name):
super().__init__(template_name=template_name)
def __init__(self, template_name, **kwargs):
self.template_name = template_name
super().__init__(**kwargs)
def render(self, context):
# Pass the entire context to the template
return render_to_string(self.template_name, context.flatten())
def get_context(self, context):
# Pass the entire context to the template, but let the panel's own context take precedence
# for panel-specific variables (title, actions, panel_class)
return {
**context.flatten(),
**super().get_context(context)
}
class TextCodePanel(ObjectPanel):
@@ -342,10 +380,11 @@ class TextCodePanel(ObjectPanel):
self.show_sync_warning = show_sync_warning
def get_context(self, context):
ctx = super().get_context(context)
return {
**super().get_context(context),
**ctx,
'show_sync_warning': self.show_sync_warning,
'value': getattr(context.get('object'), self.field_name, None),
'value': getattr(ctx['object'], self.field_name, None),
}
@@ -361,6 +400,7 @@ class PluginContentPanel(Panel):
self.method = method
def render(self, context):
# Override the default render() method to simply embed rendered plugin content
obj = context.get('object')
return _get_registered_content(obj, self.method, context)
@@ -391,14 +431,10 @@ class ContextTablePanel(ObjectPanel):
return context.get(self.table)
def get_context(self, context):
table = self._resolve_table(context)
return {
**super().get_context(context),
'table': table,
'table': self._resolve_table(context),
}
def render(self, context):
table = self._resolve_table(context)
if table is None:
return ''
return super().render(context)
def should_render(self, context):
return context.get('table') is not None

View File

@@ -0,0 +1,48 @@
{% load helpers i18n %}
{% if object.mark_connected %}
<div>
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">{% trans "Marked as connected" %}</span>
</div>
{% elif object.cable %}
<div>
<a class="d-block d-md-inline" href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a> {% trans "to" %}
{% for peer in object.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
{% elif peer.circuit %}
{{ peer.circuit|linkify }}<br/>
{% endif %}
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
</div>
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
</a>
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=object.cable.pk %}?return_url={{ object.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-sm btn-warning">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
</a>
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=object.cable.pk %}?return_url={{ object.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-sm btn-danger">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
</a>
{% endif %}
</div>
{% elif perms.dcim.add_cable %}
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -0,0 +1,8 @@
{% load helpers i18n %}
{% if object.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ value|humanize_speed }}
<i class="mdi mdi-slash-forward"></i>
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ object.upstream_speed|humanize_speed }}
{% else %}
{{ value|humanize_speed }}
{% endif %}

View File

@@ -1,16 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Circuit" %}</th>
<td>{{ object.circuit|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.circuit.provider|linkify|placeholder }}</td>
</tr>
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
</table>
{% endblock panel_content %}

View File

@@ -1,21 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
{% if object.installed_device %}
{% with device=object.installed_device %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Device type" %}</th>
<td>{{ device.device_type }}</td>
</tr>
</table>
{% endwith %}
{% else %}
<div class="card-body text-muted">{% trans "None" %}</div>
{% endif %}
{% endblock panel_content %}

View File

@@ -1,33 +0,0 @@
{% extends "ui/panels/_base.html" %}
{% load helpers i18n %}
{% block panel_content %}
{% if object.installed_module %}
{% with module=object.installed_module %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Module" %}</th>
<td>{{ module|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ module.module_type.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Module type" %}</th>
<td>{{ module.module_type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Serial number" %}</th>
<td class="font-monospace">{{ module.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Asset tag" %}</th>
<td class="font-monospace">{{ module.asset_tag|placeholder }}</td>
</tr>
</table>
{% endwith %}
{% else %}
<div class="card-body text-muted">{% trans "None" %}</div>
{% endif %}
{% endblock panel_content %}

View File

@@ -124,11 +124,11 @@ Context:
{% block content %}
{# Render panel layout declared on view class #}
{% for row in layout.rows %}
{% for row in layout %}
<div class="row">
{% for column in row.columns %}
<div class="col">
{% for panel in column.panels %}
{% for column in row %}
<div class="col-12 col-md{% if column.width %}-{{ column.width }}{% endif %}">
{% for panel in column %}
{% render panel %}
{% endfor %}
</div>

View File

@@ -1,5 +1,5 @@
{% load i18n %}
<span{% if style %} class="{{ style }}"{% endif %}>
<span>
<span{% if name %} id="attr_{{ name }}"{% endif %}>{{ value }}</span>
{% if unit %}
{{ unit|lower }}

View File

@@ -0,0 +1,12 @@
{% load i18n %}
<div class="alert alert-danger" role="alert">
<div class="alert-icon">
<i class="mdi mdi-alert"></i>
</div>
<div>
<p>
{% blocktrans %}An error occurred when loading content from plugin {{ plugin }}:{% endblocktrans %}
</p>
<pre class="p-0">{{ exception }}</pre>
</div>
</div>

View File

@@ -1,15 +1,17 @@
<!-- begin {{ panel_class|default:"panel" }} -->
<div class="card">
<h2 class="card-header">
{{ title }}
{% if actions %}
<div class="card-actions">
{% for action in actions %}
{% render action %}
{% endfor %}
</div>
{% endif %}
</h2>
{% if title or actions %}
<h2 class="card-header">
{{ title|default:"" }}
{% if actions %}
<div class="card-actions">
{% for action in actions %}
{% render action %}
{% endfor %}
</div>
{% endif %}
</h2>
{% endif %}
{% block panel_content %}{% endblock %}
</div>
<!-- end {{ panel_class|default:"panel" }} -->

View File

@@ -1,34 +0,0 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ike_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ike_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.ike_policy.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.ike_policy.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ike_policy.proposals.all %}
<li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>

View File

@@ -1,30 +0,0 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ipsec_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ipsec_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ipsec_policy.proposals.all %}
<li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<th scope="row">{% trans "PFS Group" %}</th>
<td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
</tr>
</table>
</div>

View File

@@ -57,6 +57,7 @@ class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
'tenancy.tenantgroup',
filters={'parent_id': lambda ctx: ctx['object'].pk},
title=_('Child Groups'),
exclude_columns=['parent'],
actions=[
actions.AddObject(
'tenancy.tenantgroup',
@@ -235,6 +236,7 @@ class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
'tenancy.contactgroup',
filters={'parent_id': lambda ctx: ctx['object'].pk},
title=_('Child Groups'),
exclude_columns=['parent'],
actions=[
actions.AddObject(
'tenancy.contactgroup',
@@ -414,6 +416,7 @@ class ContactView(generic.ObjectView):
'tenancy.contactassignment',
filters={'contact_id': lambda ctx: ctx['object'].pk},
title=_('Assignments'),
exclude_columns=['contact'],
),
],
)

View File

@@ -200,7 +200,10 @@ class GroupView(generic.ObjectView):
OrganizationalObjectPanel(),
],
right_panels=[
ObjectsTablePanel('users.User', filters={'group_id': lambda ctx: ctx['object'].pk}),
ObjectsTablePanel(
'users.User',
filters={'group_id': lambda ctx: ctx['object'].pk},
),
ObjectsTablePanel(
'users.ObjectPermission',
title=_('Assigned Permissions'),
@@ -345,6 +348,7 @@ class OwnerGroupView(generic.ObjectView):
'users.Owner',
filters={'group_id': lambda ctx: ctx['object'].pk},
title=_('Members'),
exclude_columns=['group'],
actions=[
actions.AddObject(
'users.Owner',
@@ -412,8 +416,14 @@ class OwnerView(GetRelatedModelsMixin, generic.ObjectView):
layout = layout.SimpleLayout(
left_panels=[
panels.OwnerPanel(),
ObjectsTablePanel('users.Group', filters={'owner_id': lambda ctx: ctx['object'].pk}),
ObjectsTablePanel('users.User', filters={'owner_id': lambda ctx: ctx['object'].pk}),
ObjectsTablePanel(
'users.Group',
filters={'owner_id': lambda ctx: ctx['object'].pk},
),
ObjectsTablePanel(
'users.User',
filters={'owner_id': lambda ctx: ctx['object'].pk},
),
],
right_panels=[
RelatedObjectsPanel(),

View File

@@ -1,5 +1,6 @@
from django import template as template_
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from netbox.plugins import PluginTemplateExtension
@@ -38,8 +39,11 @@ def _get_registered_content(obj, method, template_context):
context['config'] = settings.PLUGINS_CONFIG.get(plugin_name, {})
# Call the method to render content
instance = template_extension(context)
content = getattr(instance, method)()
try:
instance = template_extension(context)
content = getattr(instance, method)()
except Exception as e:
content = render_to_string('ui/exception.html', {'plugin': plugin_name, 'exception': repr(e)})
html += content
return mark_safe(html)

View File

@@ -31,11 +31,11 @@ class EnhancedURLValidator(URLValidator):
fqdn_re = URLValidator.hostname_re + URLValidator.domain_re + URLValidator.tld_re
host_res = [URLValidator.ipv4_re, URLValidator.ipv6_re, fqdn_re, URLValidator.hostname_re]
regex = _lazy_re_compile(
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (enforced separately)
r'(?:\S+(?::\S*)?@)?' # HTTP basic authentication
r'(?:' + '|'.join(host_res) + ')' # IPv4, IPv6, FQDN, or hostname
r'(?::\d{1,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (enforced separately)
r'(?:[^\s:@/]+(?::[^\s:@/]*)?@)?' # HTTP basic authentication
r'(?:' + '|'.join(host_res) + ')' # IPv4, IPv6, FQDN, or hostname
r'(?::\d{1,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE)
schemes = None

View File

@@ -492,6 +492,7 @@ class VirtualMachineView(generic.ObjectView):
model='ipam.Service',
title=_('Application Services'),
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject(
'ipam.Service',
@@ -508,6 +509,7 @@ class VirtualMachineView(generic.ObjectView):
ObjectsTablePanel(
model='virtualization.VirtualDisk',
filters={'virtual_machine_id': lambda ctx: ctx['object'].pk},
exclude_columns=['virtual_machine'],
actions=[
actions.AddObject(
'virtualization.VirtualDisk', url_params={'virtual_machine': lambda ctx: ctx['object'].pk}
@@ -649,6 +651,7 @@ class VMInterfaceView(generic.ObjectView):
ObjectsTablePanel(
model='ipam.IPaddress',
filters={'vminterface_id': lambda ctx: ctx['object'].pk},
exclude_columns=['assigned', 'assigned_object', 'assigned_object_parent'],
actions=[
actions.AddObject(
'ipam.IPaddress',
@@ -662,6 +665,7 @@ class VMInterfaceView(generic.ObjectView):
ObjectsTablePanel(
model='dcim.MACAddress',
filters={'vminterface_id': lambda ctx: ctx['object'].pk},
exclude_columns=['assigned_object', 'assigned_object_parent'],
actions=[
actions.AddObject(
'dcim.MACAddress', url_params={'vminterface': lambda ctx: ctx['object'].pk}

View File

@@ -71,6 +71,23 @@ class IPSecProfilePanel(panels.ObjectAttributesPanel):
mode = attrs.ChoiceAttr('mode')
class IPSecProfileIKEPolicyPanel(panels.ObjectAttributesPanel):
title = _('IKE Policy')
name = attrs.RelatedObjectAttr('ike_policy', linkify=True)
description = attrs.TextAttr('ike_policy.description')
version = attrs.ChoiceAttr('ike_policy.version', label=_('IKE version'))
mode = attrs.ChoiceAttr('ike_policy.mode')
proposals = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=True)
class IPSecProfileIPSecPolicyPanel(panels.ObjectAttributesPanel):
title = _('IPSec Policy')
name = attrs.RelatedObjectAttr('ipsec_policy', linkify=True)
description = attrs.TextAttr('ipsec_policy.description')
proposals = attrs.RelatedObjectListAttr('ipsec_policy.proposals', linkify=True)
pfs_group = attrs.ChoiceAttr('ipsec_policy.pfs_group', label=_('PFS group'))
class L2VPNPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
identifier = attrs.TextAttr('identifier')

View File

@@ -10,7 +10,6 @@ from netbox.ui.panels import (
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
@@ -129,6 +128,7 @@ class TunnelView(generic.ObjectView):
ObjectsTablePanel(
'vpn.tunneltermination',
filters={'tunnel_id': lambda ctx: ctx['object'].pk},
exclude_columns=['tunnel'],
actions=[
actions.AddObject(
'vpn.tunneltermination',
@@ -223,6 +223,7 @@ class TunnelTerminationView(generic.ObjectView):
'tunnel_id': lambda ctx: ctx['object'].tunnel.pk,
'id__n': lambda ctx: ctx['object'].pk,
},
exclude_columns=['tunnel'],
title=_('Peer Terminations'),
),
],
@@ -589,8 +590,8 @@ class IPSecProfileView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
panels.IPSecProfileIKEPolicyPanel(),
panels.IPSecProfileIPSecPolicyPanel(),
],
)
@@ -675,6 +676,7 @@ class L2VPNView(generic.ObjectView):
ObjectsTablePanel(
'vpn.l2vpntermination',
filters={'l2vpn_id': lambda ctx: ctx['object'].pk},
exclude_columns=['l2vpn'],
actions=[
actions.AddObject(
'vpn.l2vpntermination',

View File

@@ -34,10 +34,10 @@ class WirelessLinkInterfacePanel(panels.ObjectPanel):
self.title = title
def get_context(self, context):
obj = context['object']
ctx = super().get_context(context)
return {
**super().get_context(context),
'interface': getattr(obj, self.interface_attr),
**ctx,
'interface': getattr(ctx['object'], self.interface_attr),
}

View File

@@ -53,6 +53,7 @@ class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
model='wireless.WirelessLANGroup',
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
exclude_columns=['parent'],
actions=[
actions.AddObject(
'wireless.WirelessLANGroup',