Compare commits

..

23 Commits

Author SHA1 Message Date
Jeremy Stretch
ccff9c4d19 speed.html should reference value for port_speed 2026-04-03 09:40:42 -04:00
Jeremy Stretch
dd88673100 Clean up AddObject 2026-04-03 09:37:17 -04:00
Jeremy Stretch
bab4861103 PluginContentPanel should not call should_render() 2026-04-03 09:31:38 -04:00
Jeremy Stretch
8b5420f2af Remove beta warning 2026-04-02 16:00:49 -04:00
Jeremy Stretch
3ac7d8f117 Add tests for object attrs 2026-04-02 15:44:29 -04:00
Jeremy Stretch
9c2292048b Replace candidate template panels with ObjectAttributesPanel subclasses 2026-04-02 15:26:51 -04:00
Jeremy Stretch
b94136c121 Clean up object attrs 2026-04-02 14:45:26 -04:00
Jeremy Stretch
5b4c8d47be Fix typos 2026-04-02 12:58:52 -04:00
Jeremy Stretch
a8e2e50447 Yet more cleanup 2026-04-02 12:54:14 -04:00
Jeremy Stretch
7042844930 Pass the value returned by get_context() to should_render() 2026-04-02 12:09:24 -04:00
Jeremy Stretch
33a3632792 Misc cleanup 2026-04-02 11:59:15 -04:00
Jeremy Stretch
e5553e1949 Introduce should_render() method on Panel class 2026-04-02 11:30:23 -04:00
Jeremy Stretch
468dd84027 Handle panel title when object is not available 2026-04-02 11:15:08 -04:00
Jeremy Stretch
aeef559c0b Catch exceptions raised when rendering embedded plugin content 2026-04-02 11:08:04 -04:00
Jeremy Stretch
b91dc1243b Avoid setting mutable panel actions 2026-04-02 10:49:17 -04:00
Jeremy Stretch
5929c7cf1e CopyContent does not need to override render() 2026-04-02 10:38:53 -04:00
Jeremy Stretch
cb983c6308 Panel.render() should pass the request to render_to_string() 2026-04-02 10:28:35 -04:00
Jeremy Stretch
1ccab930ef Enable specifying column grid width 2026-04-02 10:10:35 -04:00
Jeremy Stretch
68bc97c24c Misc cleanup for layouts 2026-04-02 10:01:05 -04:00
Jeremy Stretch
bd45f6e4d1 Replace all instantiations of Panel with TemplatePanel 2026-04-02 09:31:03 -04:00
Jeremy Stretch
faf8554d2c Introduce CircuitTerminationPanel to replace generic panel 2026-04-01 17:15:05 -04:00
Jeremy Stretch
623ab55d5b Include permissions in TemplatedAttr context 2026-04-01 16:48:19 -04:00
Jeremy Stretch
c9073aca3c Misc cleanup 2026-04-01 15:49:55 -04:00
66 changed files with 625 additions and 839 deletions

View File

@@ -3,10 +3,7 @@
!!! 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.
!!! 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
@@ -77,7 +74,7 @@ class RecentChangesPanel(Panel):
}
```
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 display object details or embedding a table of related objects. These are listed below.
::: netbox.ui.panels.Panel
@@ -85,26 +82,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 +96,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 +127,60 @@ panels.ObjectsTablePanel(
::: netbox.ui.actions.AddObject
::: netbox.ui.actions.CopyContent
## Object Attributes
The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
| Class | Description |
|------------------------------------------|--------------------------------------------------|
| `netbox.ui.attrs.AddressAttr` | A physical or mailing address. |
| `netbox.ui.attrs.BooleanAttr` | A boolean value |
| `netbox.ui.attrs.ChoiceAttr` | A selection from a set of choices |
| `netbox.ui.attrs.ColorAttr` | A color expressed in RGB |
| `netbox.ui.attrs.DateTimeAttr` | A date or datetime value |
| `netbox.ui.attrs.GenericForeignKeyAttr` | A related object via a generic foreign key |
| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude) |
| `netbox.ui.attrs.ImageAttr` | An attached image (displays the image) |
| `netbox.ui.attrs.NestedObjectAttr` | A related nested object (includes ancestors) |
| `netbox.ui.attrs.NumericAttr` | An integer or float value |
| `netbox.ui.attrs.RelatedObjectAttr` | A related object |
| `netbox.ui.attrs.RelatedObjectListAttr` | A list of related objects |
| `netbox.ui.attrs.TemplatedAttr` | Renders an attribute using a custom template |
| `netbox.ui.attrs.TextAttr` | A string (text) value |
| `netbox.ui.attrs.TimezoneAttr` | A timezone with annotated offset |
| `netbox.ui.attrs.UtilizationAttr` | A numeric value expressed as a utilization graph |
::: netbox.ui.attrs.ObjectAttribute
::: netbox.ui.attrs.AddressAttr
::: netbox.ui.attrs.BooleanAttr
::: netbox.ui.attrs.ChoiceAttr
::: netbox.ui.attrs.ColorAttr
::: netbox.ui.attrs.DateTimeAttr
::: netbox.ui.attrs.GenericForeignKeyAttr
::: netbox.ui.attrs.GPSCoordinatesAttr
::: netbox.ui.attrs.ImageAttr
::: netbox.ui.attrs.NestedObjectAttr
::: netbox.ui.attrs.NumericAttr
::: netbox.ui.attrs.RelatedObjectAttr
::: netbox.ui.attrs.RelatedObjectListAttr
::: netbox.ui.attrs.TemplatedAttr
::: netbox.ui.attrs.TextAttr
::: netbox.ui.attrs.TimezoneAttr
::: netbox.ui.attrs.UtilizationAttr

View File

@@ -1,35 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0056_gfk_indexes'),
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0230_interface_rf_channel_frequency_precision'),
('extras', '0136_customfield_validation_schema'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='circuit',
index=models.Index(fields=['provider', 'provider_account', 'cid'], name='circuits_ci_provide_a0c42c_idx'),
),
migrations.AddIndex(
model_name='circuitgroupassignment',
index=models.Index(
fields=['group', 'member_type', 'member_id', 'priority', 'id'], name='circuits_ci_group_i_2f8327_idx'
),
),
migrations.AddIndex(
model_name='virtualcircuit',
index=models.Index(
fields=['provider_network', 'provider_account', 'cid'], name='circuits_vi_provide_989efa_idx'
),
),
migrations.AddIndex(
model_name='virtualcircuittermination',
index=models.Index(fields=['virtual_circuit', 'role', 'id'], name='circuits_vi_virtual_4b5c0c_idx'),
),
]

View File

@@ -144,9 +144,6 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
),
)
indexes = (
models.Index(fields=('provider', 'provider_account', 'cid')), # Default ordering
)
verbose_name = _('circuit')
verbose_name_plural = _('circuits')
@@ -224,9 +221,6 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
name='%(app_label)s_%(class)s_unique_member_group'
),
)
indexes = (
models.Index(fields=('group', 'member_type', 'member_id', 'priority', 'id')), # Default ordering
)
verbose_name = _('Circuit group assignment')
verbose_name_plural = _('Circuit group assignments')

View File

@@ -97,9 +97,6 @@ class VirtualCircuit(ContactsMixin, PrimaryModel):
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
),
)
indexes = (
models.Index(fields=('provider_network', 'provider_account', 'cid')), # Default ordering
)
verbose_name = _('virtual circuit')
verbose_name_plural = _('virtual circuits')
@@ -153,9 +150,6 @@ class VirtualCircuitTermination(
class Meta:
ordering = ['virtual_circuit', 'role', 'pk']
indexes = (
models.Index(fields=('virtual_circuit', 'role', 'id')), # Default ordering
)
verbose_name = _('virtual circuit termination')
verbose_name_plural = _('virtual circuit terminations')

View File

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

View File

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
Panel,
RelatedObjectsPanel,
)
from netbox.views import generic
@@ -508,10 +507,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(),

View File

@@ -1,21 +0,0 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0021_job_queue_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='configrevision',
index=models.Index(fields=['-created'], name='core_config_created_ef9552_idx'),
),
migrations.AddIndex(
model_name='job',
index=models.Index(fields=['-created'], name='core_job_created_efa7cb_idx'),
),
]

View File

@@ -37,9 +37,6 @@ class ConfigRevision(models.Model):
class Meta:
ordering = ['-created']
indexes = (
models.Index(fields=('-created',)), # Default ordering
)
verbose_name = _('config revision')
verbose_name_plural = _('config revisions')
constraints = [

View File

@@ -133,7 +133,6 @@ class Job(models.Model):
class Meta:
ordering = ['-created']
indexes = (
models.Index(fields=('-created',)), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
verbose_name = _('job')

View File

@@ -192,8 +192,14 @@ class DataFileView(generic.ObjectView):
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
)
),
)

View File

@@ -1,78 +0,0 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0230_interface_rf_channel_frequency_precision'),
('extras', '0136_customfield_validation_schema'),
('ipam', '0088_rename_vlangroup_total_vlan_ids'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
('virtualization', '0054_virtualmachinetype'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='consoleporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_consol_device__101ed5_idx'),
),
migrations.AddIndex(
model_name='consoleserverporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_consol_device__a901e6_idx'),
),
migrations.AddIndex(
model_name='device',
index=models.Index(fields=['name', 'id'], name='dcim_device_name_c27913_idx'),
),
migrations.AddIndex(
model_name='frontporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_frontp_device__ec2ffb_idx'),
),
migrations.AddIndex(
model_name='interfacetemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_interf_device__601012_idx'),
),
migrations.AddIndex(
model_name='macaddress',
index=models.Index(fields=['mac_address', 'id'], name='dcim_macadd_mac_add_f2662a_idx'),
),
migrations.AddIndex(
model_name='modulebaytemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_module_device__9eabad_idx'),
),
migrations.AddIndex(
model_name='moduletype',
index=models.Index(fields=['profile', 'manufacturer', 'model'], name='dcim_module_profile_868277_idx'),
),
migrations.AddIndex(
model_name='poweroutlettemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_powero_device__b83a8f_idx'),
),
migrations.AddIndex(
model_name='powerporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_powerp_device__6c25da_idx'),
),
migrations.AddIndex(
model_name='rack',
index=models.Index(fields=['site', 'location', 'name', 'id'], name='dcim_rack_site_id_715040_idx'),
),
migrations.AddIndex(
model_name='rackreservation',
index=models.Index(fields=['created', 'id'], name='dcim_rackre_created_84f02e_idx'),
),
migrations.AddIndex(
model_name='rearporttemplate',
index=models.Index(fields=['device_type', 'module_type', 'name'], name='dcim_rearpo_device__27f194_idx'),
),
migrations.AddIndex(
model_name='virtualchassis',
index=models.Index(fields=['name'], name='dcim_virtua_name_2dc5cd_idx'),
),
migrations.AddIndex(
model_name='virtualdevicecontext',
index=models.Index(fields=['name'], name='dcim_virtua_name_079d4d_idx'),
),
]

View File

@@ -143,9 +143,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
name='%(app_label)s_%(class)s_unique_module_type_name'
),
)
indexes = (
models.Index(fields=('device_type', 'module_type', 'name')), # Default ordering
)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)

View File

@@ -737,9 +737,6 @@ class Device(
class Meta:
ordering = ('name', 'pk') # Name may be null
indexes = (
models.Index(fields=('name', 'id')), # Default ordering
)
constraints = (
models.UniqueConstraint(
Lower('name'), 'site', 'tenant',
@@ -1187,9 +1184,6 @@ class VirtualChassis(PrimaryModel):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('virtual chassis')
verbose_name_plural = _('virtual chassis')
@@ -1296,9 +1290,6 @@ class VirtualDeviceContext(PrimaryModel):
name='%(app_label)s_%(class)s_device_name'
),
)
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('virtual device context')
verbose_name_plural = _('virtual device contexts')
@@ -1365,7 +1356,6 @@ class MACAddress(PrimaryModel):
class Meta:
ordering = ('mac_address', 'pk')
indexes = (
models.Index(fields=('mac_address', 'id')), # Default ordering
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)
verbose_name = _('MAC address')

View File

@@ -113,9 +113,6 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
)
indexes = (
models.Index(fields=('profile', 'manufacturer', 'model')), # Default ordering
)
verbose_name = _('module type')
verbose_name_plural = _('module types')

View File

@@ -390,9 +390,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
name='%(app_label)s_%(class)s_unique_location_facility_id'
),
)
indexes = (
models.Index(fields=('site', 'location', 'name', 'id')), # Default ordering
)
verbose_name = _('rack')
verbose_name_plural = _('racks')
@@ -741,9 +738,6 @@ class RackReservation(PrimaryModel):
class Meta:
ordering = ['created', 'pk']
indexes = (
models.Index(fields=('created', 'id')), # Default ordering
)
verbose_name = _('rack reservation')
verbose_name_plural = _('rack reservations')

View File

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

View File

@@ -27,7 +27,6 @@ from netbox.ui.panels import (
NestedGroupObjectPanel,
ObjectsTablePanel,
OrganizationalObjectPanel,
Panel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -1764,7 +1763,7 @@ class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
CommentsPanel(),
],
right_panels=[
Panel(
TemplatePanel(
title=_('Attributes'),
template_name='dcim/panels/module_type_attributes.html',
),
@@ -2934,7 +2933,7 @@ class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
CommentsPanel(),
],
right_panels=[
Panel(
TemplatePanel(
title=_('Module Type'),
template_name='dcim/panels/module_type.html',
),
@@ -3740,10 +3739,7 @@ class ModuleBayView(generic.ObjectView):
],
right_panels=[
CustomFieldsPanel(),
Panel(
title=_('Installed Module'),
template_name='dcim/panels/installed_module.html',
),
panels.InstalledModulePanel(),
],
)
@@ -3815,10 +3811,7 @@ class DeviceBayView(generic.ObjectView):
TagsPanel(),
],
right_panels=[
Panel(
title=_('Installed Device'),
template_name='dcim/panels/installed_device.html',
),
panels.InstalledDevicePanel(),
],
)
@@ -4310,11 +4303,11 @@ class CableView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
Panel(
TemplatePanel(
title=_('Termination A'),
template_name='dcim/panels/cable_termination_a.html',
),
Panel(
TemplatePanel(
title=_('Termination B'),
template_name='dcim/panels/cable_termination_b.html',
),

View File

@@ -1,74 +0,0 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0022_default_ordering_indexes'),
('dcim', '0231_default_ordering_indexes'),
('extras', '0136_customfield_validation_schema'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
('virtualization', '0054_virtualmachinetype'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddIndex(
model_name='bookmark',
index=models.Index(fields=['created', 'id'], name='extras_book_created_1cb4a5_idx'),
),
migrations.AddIndex(
model_name='configcontext',
index=models.Index(fields=['weight', 'name'], name='extras_conf_weight_ef9a81_idx'),
),
migrations.AddIndex(
model_name='configtemplate',
index=models.Index(fields=['name'], name='extras_conf_name_e276bf_idx'),
),
migrations.AddIndex(
model_name='customfield',
index=models.Index(fields=['group_name', 'weight', 'name'], name='extras_cust_group_n_40cb93_idx'),
),
migrations.AddIndex(
model_name='customlink',
index=models.Index(fields=['group_name', 'weight', 'name'], name='extras_cust_group_n_5a8be0_idx'),
),
migrations.AddIndex(
model_name='exporttemplate',
index=models.Index(fields=['name'], name='extras_expo_name_55a9af_idx'),
),
migrations.AddIndex(
model_name='imageattachment',
index=models.Index(fields=['name', 'id'], name='extras_imag_name_23cd9f_idx'),
),
migrations.AddIndex(
model_name='journalentry',
index=models.Index(fields=['-created'], name='extras_jour_created_ec0fac_idx'),
),
migrations.AddIndex(
model_name='notification',
index=models.Index(fields=['-created', 'id'], name='extras_noti_created_1d5146_idx'),
),
migrations.AddIndex(
model_name='savedfilter',
index=models.Index(fields=['weight', 'name'], name='extras_save_weight_c070c4_idx'),
),
migrations.AddIndex(
model_name='script',
index=models.Index(fields=['module', 'name'], name='extras_scri_module__8bd99c_idx'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['-created', 'user'], name='extras_subs_created_034618_idx'),
),
migrations.AddIndex(
model_name='tableconfig',
index=models.Index(fields=['weight', 'name'], name='extras_tabl_weight_7c4bb6_idx'),
),
migrations.AddIndex(
model_name='tag',
index=models.Index(fields=['weight', 'name'], name='extras_tag_weight_d99f50_idx'),
),
]

View File

@@ -176,9 +176,6 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, OwnerMixin,
class Meta:
ordering = ['weight', 'name']
indexes = (
models.Index(fields=('weight', 'name')), # Default ordering
)
verbose_name = _('config context')
verbose_name_plural = _('config contexts')
@@ -297,9 +294,6 @@ class ConfigTemplate(
class Meta:
ordering = ('name',)
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('config template')
verbose_name_plural = _('config templates')

View File

@@ -274,9 +274,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
class Meta:
ordering = ['group_name', 'weight', 'name']
indexes = (
models.Index(fields=('group_name', 'weight', 'name')), # Default ordering
)
verbose_name = _('custom field')
verbose_name_plural = _('custom fields')

View File

@@ -356,9 +356,6 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMod
class Meta:
ordering = ['group_name', 'weight', 'name']
indexes = (
models.Index(fields=('group_name', 'weight', 'name')), # Default ordering
)
verbose_name = _('custom link')
verbose_name_plural = _('custom links')
@@ -432,9 +429,6 @@ class ExportTemplate(
class Meta:
ordering = ('name',)
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('export template')
verbose_name_plural = _('export templates')
@@ -521,9 +515,6 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
class Meta:
ordering = ('weight', 'name')
indexes = (
models.Index(fields=('weight', 'name')), # Default ordering
)
verbose_name = _('saved filter')
verbose_name_plural = _('saved filters')
@@ -606,9 +597,6 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
class Meta:
ordering = ('weight', 'name')
indexes = (
models.Index(fields=('weight', 'name')), # Default ordering
)
verbose_name = _('table config')
verbose_name_plural = _('table configs')
@@ -712,7 +700,6 @@ class ImageAttachment(ChangeLoggedModel):
class Meta:
ordering = ('name', 'pk') # name may be non-unique
indexes = (
models.Index(fields=('name', 'id')), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
verbose_name = _('image attachment')
@@ -823,7 +810,6 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
class Meta:
ordering = ('-created',)
indexes = (
models.Index(fields=('-created',)), # Default ordering
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)
verbose_name = _('journal entry')
@@ -879,7 +865,6 @@ class Bookmark(models.Model):
class Meta:
ordering = ('created', 'pk')
indexes = (
models.Index(fields=('created', 'id')), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
constraints = (

View File

@@ -73,7 +73,6 @@ class Notification(models.Model):
class Meta:
ordering = ('-created', 'pk')
indexes = (
models.Index(fields=('-created', 'id')), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
constraints = (
@@ -216,7 +215,6 @@ class Subscription(models.Model):
class Meta:
ordering = ('-created', 'user')
indexes = (
models.Index(fields=('-created', 'user')), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
constraints = (

View File

@@ -62,9 +62,6 @@ class Script(EventRulesMixin, JobsMixin):
name='extras_script_unique_name_module'
),
)
indexes = (
models.Index(fields=('module', 'name')), # Default ordering
)
verbose_name = _('script')
verbose_name_plural = _('scripts')

View File

@@ -52,9 +52,6 @@ class Tag(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedModel, Tag
class Meta:
ordering = ('weight', 'name')
indexes = (
models.Index(fields=('weight', 'name')), # Default ordering
)
verbose_name = _('tag')
verbose_name_plural = _('tags')

View File

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

View File

@@ -1,47 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0231_default_ordering_indexes'),
('extras', '0137_default_ordering_indexes'),
('ipam', '0088_rename_vlangroup_total_vlan_ids'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='aggregate',
index=models.Index(fields=['prefix', 'id'], name='ipam_aggreg_prefix_89dd71_idx'),
),
migrations.AddIndex(
model_name='fhrpgroup',
index=models.Index(fields=['protocol', 'group_id', 'id'], name='ipam_fhrpgr_protoco_0445ae_idx'),
),
migrations.AddIndex(
model_name='fhrpgroupassignment',
index=models.Index(fields=['-priority', 'id'], name='ipam_fhrpgr_priorit_b76335_idx'),
),
migrations.AddIndex(
model_name='ipaddress',
index=models.Index(fields=['address', 'id'], name='ipam_ipaddr_address_3ddeea_idx'),
),
migrations.AddIndex(
model_name='role',
index=models.Index(fields=['weight', 'name'], name='ipam_role_weight_01396b_idx'),
),
migrations.AddIndex(
model_name='service',
index=models.Index(fields=['protocol', 'ports', 'id'], name='ipam_servic_protoco_687d13_idx'),
),
migrations.AddIndex(
model_name='vlan',
index=models.Index(fields=['site', 'group', 'vid', 'id'], name='ipam_vlan_site_id_985573_idx'),
),
migrations.AddIndex(
model_name='vrf',
index=models.Index(fields=['name', 'rd', 'id'], name='ipam_vrf_name_ec911d_idx'),
),
]

View File

@@ -59,9 +59,6 @@ class FHRPGroup(PrimaryModel):
class Meta:
ordering = ['protocol', 'group_id', 'pk']
indexes = (
models.Index(fields=('protocol', 'group_id', 'id')), # Default ordering
)
verbose_name = _('FHRP group')
verbose_name_plural = _('FHRP groups')
@@ -108,7 +105,6 @@ class FHRPGroupAssignment(ChangeLoggedModel):
class Meta:
ordering = ('-priority', 'pk')
indexes = (
models.Index(fields=('-priority', 'id')), # Default ordering
models.Index(fields=('interface_type', 'interface_id')),
)
constraints = (

View File

@@ -110,9 +110,6 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
class Meta:
ordering = ('prefix', 'pk') # prefix may be non-unique
indexes = (
models.Index(fields=('prefix', 'id')), # Default ordering
)
verbose_name = _('aggregate')
verbose_name_plural = _('aggregates')
@@ -203,9 +200,6 @@ class Role(OrganizationalModel):
class Meta:
ordering = ('weight', 'name')
indexes = (
models.Index(fields=('weight', 'name')), # Default ordering
)
verbose_name = _('role')
verbose_name_plural = _('roles')
@@ -839,7 +833,6 @@ class IPAddress(ContactsMixin, PrimaryModel):
class Meta:
ordering = ('address', 'pk') # address may be non-unique
indexes = (
models.Index(fields=('address', 'id')), # Default ordering
models.Index(Cast(Host('address'), output_field=IPAddressField()), name='ipam_ipaddress_host'),
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
)

View File

@@ -93,7 +93,6 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
class Meta:
indexes = (
models.Index(fields=('protocol', 'ports', 'id')), # Default ordering
models.Index(fields=('parent_object_type', 'parent_object_id')),
)
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique

View File

@@ -267,9 +267,6 @@ class VLAN(PrimaryModel):
class Meta:
ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique
indexes = (
models.Index(fields=('site', 'group', 'vid', 'id')), # Default ordering
)
constraints = (
models.UniqueConstraint(
fields=('group', 'vid'),

View File

@@ -58,9 +58,6 @@ class VRF(PrimaryModel):
class Meta:
ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique
indexes = (
models.Index(fields=('name', 'rd', 'id')), # Default ordering
)
verbose_name = _('VRF')
verbose_name_plural = _('VRFs')

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -55,11 +56,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
layout.Column(
panels.VRFPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -70,6 +73,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
ContextTablePanel('export_targets_table', title=_('Export route targets')),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)
def get_extra_context(self, request, instance):
@@ -169,10 +177,12 @@ class RouteTargetView(generic.ObjectView):
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -207,6 +217,11 @@ class RouteTargetView(generic.ObjectView):
),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)

View File

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

View File

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

View File

@@ -29,11 +29,27 @@ PLACEHOLDER_HTML = '<span class="text-muted">&mdash;</span>'
IMAGE_DECODING_CHOICES = ('auto', 'async', 'sync')
#
# Mixins
#
class MapURLMixin:
_map_url = None
@property
def map_url(self):
if self._map_url is True:
return get_config().MAPS_URL
if self._map_url:
return self._map_url
return None
#
# Attributes
#
class ObjectAttribute:
"""
Base class for representing an attribute of an object.
@@ -64,17 +80,20 @@ class ObjectAttribute:
"""
return resolve_attr_path(obj, self.accessor)
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
"""
Return any additional template context used to render the attribute value.
Parameters:
obj (object): The object for which the attribute is being rendered
attr (str): The name of the attribute being rendered
value: The value of the attribute on the object
context (dict): The root 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,13 @@ 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):
if value is not None:
nodes = value.get_ancestors(include_self=True)
if self.max_depth:
nodes = list(nodes)[-self.max_depth:]
else:
nodes = []
return {
'nodes': nodes,
'linkify': self.linkify,
@@ -394,40 +414,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 +455,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 +487,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 +513,9 @@ class TemplatedAttr(ObjectAttribute):
self.template_name = template_name
self.context = context or {}
def get_context(self, obj, context):
def get_context(self, obj, attr, value, context):
return {
**context,
**self.context,
'object': obj,
}

View File

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

View File

@@ -45,18 +45,17 @@ class Panel:
Parameters:
title (str): The human-friendly title of the panel
actions (list): An iterable of PanelActions to include in the panel header
template_name (str): Overrides the default template name, if defined
"""
template_name = None
title = None
actions = None
def __init__(self, title=None, actions=None, template_name=None):
def __init__(self, title=None, actions=None):
if title is not None:
self.title = title
self.actions = actions or self.actions or []
if template_name is not None:
self.template_name = template_name
if actions is not None:
self.actions = actions
self.actions = list(self.actions) if self.actions else []
def get_context(self, context):
"""
@@ -74,6 +73,15 @@ class Panel:
'panel_class': self.__class__.__name__,
}
def should_render(self, context):
"""
Determines whether the panel should render on the page. (Default: True)
Parameters:
context (dict): The panel's prepared context (the return value of get_context())
"""
return True
def render(self, context):
"""
Render the panel as HTML.
@@ -81,7 +89,10 @@ class Panel:
Parameters:
context (dict): The template context
"""
return render_to_string(self.template_name, self.get_context(context))
ctx = self.get_context(context)
if not self.should_render(ctx):
return ''
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
#
@@ -105,9 +116,15 @@ class ObjectPanel(Panel):
def get_context(self, context):
obj = resolve_attr_path(context, self.accessor)
if self.title is not None:
title_ = self.title
elif obj is not None:
title_ = title(obj._meta.verbose_name)
else:
title_ = None
return {
**super().get_context(context),
'title': self.title or title(obj._meta.verbose_name),
'title': title_,
'object': obj,
}
@@ -187,7 +204,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
'attrs': [
{
'label': attr.label or self._name_to_label(name),
'value': attr.render(ctx['object'], {'name': name}),
'value': attr.render(ctx['object'], {'name': name, 'perms': ctx['perms']}),
} for name, attr in self._attrs.items() if name in attr_names
],
}
@@ -225,9 +242,10 @@ class CommentsPanel(ObjectPanel):
self.field_name = field_name
def get_context(self, context):
ctx = super().get_context(context)
return {
**super().get_context(context),
'comments': getattr(context['object'], self.field_name),
**ctx,
'comments': getattr(ctx['object'], self.field_name, None),
}
@@ -249,9 +267,10 @@ class JSONPanel(ObjectPanel):
self.actions.append(CopyContent(f'panel_{field_name}'))
def get_context(self, context):
ctx = super().get_context(context)
return {
**super().get_context(context),
'data': getattr(context['object'], self.field_name),
**ctx,
'data': getattr(ctx['object'], self.field_name, None),
'field_name': self.field_name,
}
@@ -289,20 +308,25 @@ class ObjectsTablePanel(Panel):
def __init__(self, model, filters=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 {}
# 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()
}
@@ -310,7 +334,8 @@ class ObjectsTablePanel(Panel):
url_params['return_url'] = context['object'].get_absolute_url()
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 +347,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 +372,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 +392,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 +423,10 @@ class ContextTablePanel(ObjectPanel):
return context.get(self.table)
def get_context(self, context):
table = self._resolve_table(context)
return {
**super().get_context(context),
'table': table,
'table': self._resolve_table(context),
}
def render(self, context):
table = self._resolve_table(context)
if table is None:
return ''
return super().render(context)
def should_render(self, context):
return context.get('table') is not None

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0137_default_ordering_indexes'),
('tenancy', '0023_add_mptt_tree_indexes'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='contact',
index=models.Index(fields=['name'], name='tenancy_con_name_c26153_idx'),
),
migrations.AddIndex(
model_name='contactassignment',
index=models.Index(fields=['contact', 'priority', 'role', 'id'], name='tenancy_con_contact_23011f_idx'),
),
]

View File

@@ -90,9 +90,6 @@ class Contact(PrimaryModel):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('contact')
verbose_name_plural = _('contacts')
@@ -133,7 +130,6 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
class Meta:
ordering = ('contact', 'priority', 'role', 'pk')
indexes = (
models.Index(fields=('contact', 'priority', 'role', 'id')), # Default ordering
models.Index(fields=('object_type', 'object_id')),
)
constraints = (

View File

@@ -1,19 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('users', '0015_owner'),
]
operations = [
migrations.AddIndex(
model_name='objectpermission',
index=models.Index(fields=['name'], name='users_objec_name_ca707b_idx'),
),
migrations.AddIndex(
model_name='token',
index=models.Index(fields=['-created'], name='users_token_created_1467b4_idx'),
),
]

View File

@@ -52,9 +52,6 @@ class ObjectPermission(CloningMixin, models.Model):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('permission')
verbose_name_plural = _('permissions')

View File

@@ -117,9 +117,6 @@ class Token(models.Model):
class Meta:
ordering = ('-created',)
indexes = (
models.Index(fields=('-created',)), # Default ordering
)
verbose_name = _('token')
verbose_name_plural = _('tokens')
constraints = [

View File

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

View File

@@ -1,23 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0231_default_ordering_indexes'),
('extras', '0137_default_ordering_indexes'),
('ipam', '0089_default_ordering_indexes'),
('tenancy', '0024_default_ordering_indexes'),
('users', '0016_default_ordering_indexes'),
('virtualization', '0054_virtualmachinetype'),
]
operations = [
migrations.AddIndex(
model_name='virtualmachine',
index=models.Index(fields=['name', 'id'], name='virtualizat_name_16033e_idx'),
),
migrations.AddIndex(
model_name='virtualmachinetype',
index=models.Index(fields=['name'], name='virtualizat_name_6cff11_idx'),
),
]

View File

@@ -97,9 +97,6 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
constraints = (
models.UniqueConstraint(
fields=('group', 'name'),

View File

@@ -92,9 +92,6 @@ class VirtualMachineType(ImageAttachmentsMixin, PrimaryModel):
violation_error_message=_('Virtual machine type slug must be unique.'),
),
)
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('virtual machine type')
verbose_name_plural = _('virtual machine types')
@@ -252,9 +249,6 @@ class VirtualMachine(
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
indexes = (
models.Index(fields=('name', 'id')), # Default ordering
)
constraints = (
models.UniqueConstraint(
Lower('name'), 'cluster', 'tenant',

View File

@@ -1,17 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0137_default_ordering_indexes'),
('ipam', '0089_default_ordering_indexes'),
('vpn', '0011_add_comments_to_organizationalmodel'),
]
operations = [
migrations.AddIndex(
model_name='tunneltermination',
index=models.Index(fields=['tunnel', 'role', 'id'], name='vpn_tunnelt_tunnel__f542d3_idx'),
),
]

View File

@@ -145,9 +145,6 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo
violation_error_message=_("An object may be terminated to only one tunnel at a time.")
),
)
indexes = (
models.Index(fields=('tunnel', 'role', 'id')), # Default ordering
)
verbose_name = _('tunnel termination')
verbose_name_plural = _('tunnel terminations')

View File

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

View File

@@ -10,7 +10,6 @@ from netbox.ui.panels import (
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
@@ -589,8 +588,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(),
],
)

View File

@@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('dcim', '0231_default_ordering_indexes'),
('extras', '0137_default_ordering_indexes'),
('ipam', '0089_default_ordering_indexes'),
('tenancy', '0024_default_ordering_indexes'),
('users', '0016_default_ordering_indexes'),
('wireless', '0018_add_mptt_tree_indexes'),
]
operations = [
migrations.AddIndex(
model_name='wirelesslan',
index=models.Index(fields=['ssid', 'id'], name='wireless_wi_ssid_64a9ce_idx'),
),
]

View File

@@ -118,7 +118,6 @@ class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel):
class Meta:
ordering = ('ssid', 'pk')
indexes = (
models.Index(fields=('ssid', 'id')), # Default ordering
models.Index(fields=('scope_type', 'scope_id')),
)
verbose_name = _('wireless LAN')

View File

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