mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-07 09:53:47 +02:00
Compare commits
11 Commits
21455-sql-
...
feature
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcc410d99f | ||
|
|
02f9ca8f01 | ||
|
|
5ad4e95207 | ||
|
|
a06a300913 | ||
|
|
6c08941542 | ||
|
|
be1a29d7ee | ||
|
|
f06f8f3f1d | ||
|
|
a45ec6620a | ||
|
|
bd35afe320 | ||
|
|
364868a207 | ||
|
|
d4569df305 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,11 +29,27 @@ PLACEHOLDER_HTML = '<span class="text-muted">—</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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
12
netbox/templates/ui/exception.html
Normal file
12
netbox/templates/ui/exception.html
Normal 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>
|
||||
@@ -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" }} -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user