Compare commits

..

2 Commits

Author SHA1 Message Date
Jeremy Stretch
2b1f4ab51a Add migration files for indexes 2026-04-03 16:32:08 -04:00
Jeremy Stretch
84502e80d0 Add SQL indexes for default ordering on applicable models 2026-04-03 16:22:18 -04:00
66 changed files with 835 additions and 621 deletions

View File

@@ -3,7 +3,10 @@
!!! 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.
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.
!!! 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.
## Page Layout
@@ -74,7 +77,7 @@ class RecentChangesPanel(Panel):
}
```
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 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.ui.panels.Panel
@@ -82,6 +85,26 @@ NetBox also includes a set of panels suited for specific uses, such as display o
::: 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
@@ -96,13 +119,9 @@ NetBox also includes a set of panels suited for specific uses, such as display o
::: 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.
@@ -127,60 +146,3 @@ 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

@@ -0,0 +1,35 @@
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,6 +144,9 @@ 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')
@@ -221,6 +224,9 @@ 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,6 +97,9 @@ 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')
@@ -150,6 +153,9 @@ 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,9 +13,13 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
template_name = 'circuits/panels/circuit_circuit_termination.html'
title = _('Termination')
def __init__(self, side, accessor=None, **kwargs):
super().__init__(accessor=accessor, **kwargs)
self.side = side
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 get_context(self, context):
return {
@@ -54,26 +58,6 @@ 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,6 +8,7 @@ from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
Panel,
RelatedObjectsPanel,
)
from netbox.views import generic
@@ -507,7 +508,10 @@ class CircuitTerminationView(generic.ObjectView):
queryset = CircuitTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.CircuitTerminationPanel(),
Panel(
template_name='circuits/panels/circuit_termination.html',
title=_('Circuit Termination'),
)
],
right_panels=[
CustomFieldsPanel(),

View File

@@ -0,0 +1,21 @@
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,6 +37,9 @@ 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,6 +133,7 @@ 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,14 +192,8 @@ class DataFileView(generic.ObjectView):
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
)
),
)

View File

@@ -0,0 +1,78 @@
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,6 +143,9 @@ 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,6 +737,9 @@ 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',
@@ -1184,6 +1187,9 @@ class VirtualChassis(PrimaryModel):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('virtual chassis')
verbose_name_plural = _('virtual chassis')
@@ -1290,6 +1296,9 @@ 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')
@@ -1356,6 +1365,7 @@ 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,6 +113,9 @@ 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,6 +390,9 @@ 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')
@@ -738,6 +741,9 @@ 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,4 +1,5 @@
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
@@ -74,7 +75,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', linkify=True)
user = attrs.RelatedObjectAttr('user')
description = attrs.TextAttr('description')
@@ -219,8 +220,8 @@ class PowerPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
description = attrs.TextAttr('description')
maximum_draw = attrs.TextAttr('maximum_draw', format_string='{}W')
allocated_draw = attrs.TextAttr('allocated_draw', format_string='{}W')
maximum_draw = attrs.TextAttr('maximum_draw')
allocated_draw = attrs.TextAttr('allocated_draw')
class PowerOutletPanel(panels.ObjectAttributesPanel):
@@ -243,7 +244,7 @@ class FrontPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
positions = attrs.NumericAttr('positions')
positions = attrs.TextAttr('positions')
description = attrs.TextAttr('description')
@@ -254,7 +255,7 @@ class RearPortPanel(panels.ObjectAttributesPanel):
label = attrs.TextAttr('label')
type = attrs.ChoiceAttr('type')
color = attrs.ColorAttr('color')
positions = attrs.NumericAttr('positions')
positions = attrs.TextAttr('positions')
description = attrs.TextAttr('description')
@@ -267,15 +268,6 @@ 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')
@@ -283,12 +275,6 @@ 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'))
@@ -407,6 +393,10 @@ 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):
"""
@@ -424,6 +414,10 @@ 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):
"""
@@ -457,8 +451,10 @@ class VirtualChassisMembersPanel(panels.ObjectPanel):
'vc_members': context.get('vc_members'),
}
def should_render(self, context):
return bool(context.get('vc_members'))
def render(self, context):
if not context.get('vc_members'):
return ''
return super().render(context)
class PowerUtilizationPanel(panels.ObjectPanel):
@@ -474,9 +470,11 @@ class PowerUtilizationPanel(panels.ObjectPanel):
'vc_members': context.get('vc_members'),
}
def should_render(self, context):
def render(self, context):
obj = context['object']
return obj.powerports.exists() and obj.poweroutlets.exists()
if not obj.powerports.exists() or not obj.poweroutlets.exists():
return ''
return super().render(context)
class InterfacePanel(panels.ObjectAttributesPanel):
@@ -487,7 +485,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.NumericAttr('mtu', label=_('MTU'))
mtu = attrs.TextAttr('mtu', label=_('MTU'))
enabled = attrs.BooleanAttr('enabled')
mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
description = attrs.TextAttr('description')
@@ -496,7 +494,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'), format_string='{} dBm')
tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
@@ -529,9 +527,12 @@ class InterfaceConnectionPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_connection.html'
title = _('Connection')
def should_render(self, context):
def render(self, context):
obj = context.get('object')
return False if (obj is None or obj.is_virtual) else True
if obj and obj.is_virtual:
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class VirtualCircuitPanel(panels.ObjectPanel):
@@ -541,11 +542,12 @@ class VirtualCircuitPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_virtual_circuit.html'
title = _('Virtual Circuit')
def should_render(self, context):
def render(self, context):
obj = context.get('object')
if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
return False
return True
return ''
ctx = self.get_context(context)
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
class InterfaceWirelessPanel(panels.ObjectPanel):
@@ -555,9 +557,12 @@ class InterfaceWirelessPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_wireless.html'
title = _('Wireless')
def should_render(self, context):
def render(self, context):
obj = context.get('object')
return False if (obj is None or not obj.is_wireless) else True
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'))
class WirelessLANsPanel(panels.ObjectPanel):
@@ -567,6 +572,9 @@ class WirelessLANsPanel(panels.ObjectPanel):
template_name = 'dcim/panels/interface_wireless_lans.html'
title = _('Wireless LANs')
def should_render(self, context):
def render(self, context):
obj = context.get('object')
return False if (obj is None or not obj.is_wireless) else True
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'))

View File

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

View File

@@ -0,0 +1,74 @@
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,6 +176,9 @@ 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')
@@ -294,6 +297,9 @@ 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,6 +274,9 @@ 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,6 +356,9 @@ 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')
@@ -429,6 +432,9 @@ class ExportTemplate(
class Meta:
ordering = ('name',)
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('export template')
verbose_name_plural = _('export templates')
@@ -515,6 +521,9 @@ 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')
@@ -597,6 +606,9 @@ 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')
@@ -700,6 +712,7 @@ 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')
@@ -810,6 +823,7 @@ 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')
@@ -865,6 +879,7 @@ 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,6 +73,7 @@ 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 = (
@@ -215,6 +216,7 @@ 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,6 +62,9 @@ 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,6 +52,9 @@ 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,4 +1,5 @@
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
@@ -64,8 +65,12 @@ class CustomFieldsPanel(panels.ObjectPanel):
'custom_fields': obj.get_custom_fields_by_group(),
}
def should_render(self, context):
return bool(context['custom_fields'])
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))
class ImageAttachmentsPanel(panels.ObjectsTablePanel):

View File

@@ -0,0 +1,47 @@
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,6 +59,9 @@ 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')
@@ -105,6 +108,7 @@ 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,6 +110,9 @@ 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')
@@ -200,6 +203,9 @@ class Role(OrganizationalModel):
class Meta:
ordering = ('weight', 'name')
indexes = (
models.Index(fields=('weight', 'name')), # Default ordering
)
verbose_name = _('role')
verbose_name_plural = _('roles')
@@ -833,6 +839,7 @@ 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,6 +93,7 @@ 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,6 +267,9 @@ 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,6 +58,9 @@ 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,9 +7,6 @@ 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'
@@ -17,17 +14,11 @@ class VRFDisplayAttr(attrs.ObjectAttribute):
super().__init__(*args, **kwargs)
self.show_rd = show_rd
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,
**self.get_context(obj, context),
'name': context['name'],
'value': value,
'show_rd': self.show_rd,
})

View File

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

View File

@@ -16,7 +16,6 @@ from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -56,13 +55,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
layout.Column(
panels.VRFPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -73,11 +70,6 @@ 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):
@@ -177,12 +169,10 @@ class RouteTargetView(generic.ObjectView):
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -217,11 +207,6 @@ class RouteTargetView(generic.ObjectView):
),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)

View File

@@ -1,5 +1,3 @@
from types import SimpleNamespace
from django.test import TestCase
from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
@@ -78,7 +76,7 @@ class ChoiceAttrTest(TestCase):
self.termination.get_role_display(),
)
self.assertEqual(
attr.get_context(self.termination, 'role', attr.get_value(self.termination), {}),
attr.get_context(self.termination, {}),
{'bg_color': self.termination.get_role_color()},
)
@@ -90,7 +88,7 @@ class ChoiceAttrTest(TestCase):
self.termination.interface.get_type_display(),
)
self.assertEqual(
attr.get_context(self.termination, 'interface.type', attr.get_value(self.termination), {}),
attr.get_context(self.termination, {}),
{'bg_color': None},
)
@@ -102,9 +100,7 @@ class ChoiceAttrTest(TestCase):
self.termination.virtual_circuit.get_status_display(),
)
self.assertEqual(
attr.get_context(
self.termination, 'virtual_circuit.status', attr.get_value(self.termination), {}
),
attr.get_context(self.termination, {}),
{'bg_color': self.termination.virtual_circuit.get_status_color()},
)
@@ -217,176 +213,3 @@ 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 self.permissions and not user.has_perms(self.permissions):
if 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 from its label
if '.' not in model:
raise ValueError(f"Invalid model label: {model}")
# Resolve the model class from its app.name label
try:
self.model = apps.get_model(model)
except LookupError:
app_label, model_name = model.split('.')
model = apps.get_model(app_label, model_name)
except (ValueError, 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(self.model, 'add')])
kwargs.setdefault('permissions', [get_permission_for_model(model, 'add')])
super().__init__(view_name=get_viewname(self.model, 'add'), url_params=url_params, **kwargs)
super().__init__(view_name=view_name, url_params=url_params, **kwargs)
class CopyContent(PanelAction):
@@ -148,8 +148,10 @@ class CopyContent(PanelAction):
super().__init__(**kwargs)
self.target_id = target_id
def get_context(self, context):
return {
**super().get_context(context),
def render(self, context):
return render_to_string(self.template_name, {
'target_id': self.target_id,
}
'label': self.label,
'button_class': self.button_class,
'button_icon': self.button_icon,
})

View File

@@ -29,27 +29,11 @@ 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.
@@ -80,20 +64,17 @@ class ObjectAttribute:
"""
return resolve_attr_path(obj, self.accessor)
def get_context(self, obj, attr, value, context):
def get_context(self, obj, 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
@@ -101,8 +82,8 @@ class ObjectAttribute:
return self.placeholder
return render_to_string(self.template_name, {
**self.get_context(obj, name, value, context),
'name': name,
**self.get_context(obj, context),
'name': context['name'],
'value': value,
})
@@ -131,7 +112,7 @@ class TextAttr(ObjectAttribute):
return self.format_string.format(value)
return value
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
return {
'style': self.style,
'copy_button': self.copy_button,
@@ -153,7 +134,7 @@ class NumericAttr(ObjectAttribute):
self.unit_accessor = unit_accessor
self.copy_button = copy_button
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
unit = resolve_attr_path(obj, self.unit_accessor) if self.unit_accessor else None
return {
'unit': unit,
@@ -191,7 +172,7 @@ class ChoiceAttr(ObjectAttribute):
return resolve_attr_path(target, field_name)
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
target, field_name = self._resolve_target(obj)
if target is None:
return {'bg_color': None}
@@ -260,7 +241,7 @@ class ImageAttr(ObjectAttribute):
decoding = 'async'
self.decoding = decoding
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
return {
'decoding': self.decoding,
'load_lazy': self.load_lazy,
@@ -283,7 +264,8 @@ class RelatedObjectAttr(ObjectAttribute):
self.linkify = linkify
self.grouped_by = grouped_by
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
value = self.get_value(obj)
group = getattr(value, self.grouped_by, None) if self.grouped_by else None
return {
'linkify': self.linkify,
@@ -318,13 +300,14 @@ class RelatedObjectListAttr(RelatedObjectAttr):
self.max_items = max_items
self.overflow_indicator = overflow_indicator
def _get_items(self, items):
def _get_items(self, obj):
"""
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
@@ -339,8 +322,8 @@ class RelatedObjectListAttr(RelatedObjectAttr):
return items[:self.max_items], has_more
def get_context(self, obj, attr, value, context):
items, has_more = self._get_items(value)
def get_context(self, obj, context):
items, has_more = self._get_items(obj)
return {
'linkify': self.linkify,
@@ -355,15 +338,14 @@ class RelatedObjectListAttr(RelatedObjectAttr):
}
def render(self, obj, context):
name = context['name']
value = self.get_value(obj)
context_data = self.get_context(obj, name, value, context)
context = context or {}
context_data = self.get_context(obj, context)
if not context_data['items']:
return self.placeholder
return render_to_string(self.template_name, {
'name': name,
'name': context.get('name'),
**context_data,
})
@@ -384,13 +366,11 @@ class NestedObjectAttr(ObjectAttribute):
self.linkify = linkify
self.max_depth = 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 = []
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:]
return {
'nodes': nodes,
'linkify': self.linkify,
@@ -414,35 +394,40 @@ class GenericForeignKeyAttr(ObjectAttribute):
super().__init__(*args, **kwargs)
self.linkify = linkify
def get_context(self, obj, attr, value, context):
content_type = value._meta.verbose_name if value is not None else None
def get_context(self, obj, context):
value = self.get_value(obj)
content_type = value._meta.verbose_name
return {
'content_type': content_type,
'linkify': self.linkify,
}
class AddressAttr(MapURLMixin, ObjectAttribute):
class AddressAttr(ObjectAttribute):
"""
A physical or mailing address.
Parameters:
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.
map_url (bool): 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)
self._map_url = map_url
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
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
return {
'map_url': self.map_url,
}
class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
class GPSCoordinatesAttr(ObjectAttribute):
"""
A GPS coordinates pair comprising latitude and longitude values.
@@ -455,18 +440,24 @@ class GPSCoordinatesAttr(MapURLMixin, ObjectAttribute):
label = _('GPS coordinates')
def __init__(self, latitude_attr='latitude', longitude_attr='longitude', map_url=True, **kwargs):
super().__init__(accessor=latitude_attr, **kwargs)
super().__init__(accessor=None, **kwargs)
self.latitude_attr = latitude_attr
self.longitude_attr = longitude_attr
self._map_url = map_url
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
def render(self, obj, context):
def render(self, obj, context=None):
context = context or {}
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, {
'name': context['name'],
**context,
'latitude': latitude,
'longitude': longitude,
'map_url': self.map_url,
@@ -487,7 +478,7 @@ class DateTimeAttr(ObjectAttribute):
super().__init__(*args, **kwargs)
self.spec = spec
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
return {
'spec': self.spec,
}
@@ -513,9 +504,8 @@ class TemplatedAttr(ObjectAttribute):
self.template_name = template_name
self.context = context or {}
def get_context(self, obj, attr, value, context):
def get_context(self, obj, context):
return {
**context,
**self.context,
'object': obj,
}

View File

@@ -21,16 +21,10 @@ class Layout:
"""
def __init__(self, *rows):
for i, row in enumerate(rows):
if not isinstance(row, Row):
if type(row) is not 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:
"""
@@ -41,16 +35,10 @@ class Row:
"""
def __init__(self, *columns):
for i, column in enumerate(columns):
if not isinstance(column, Column):
if type(column) is not 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:
"""
@@ -58,25 +46,12 @@ 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, width=None):
def __init__(self, *panels):
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)"
#
@@ -87,7 +62,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_page` is included automatically. Most object
Plugin content registered for `left_page`, `right_page`, or `full_width_path` is included automatically. Most object
views in NetBox utilize this layout.
```

View File

@@ -45,17 +45,18 @@ 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):
def __init__(self, title=None, actions=None, template_name=None):
if title is not None:
self.title = title
if actions is not None:
self.actions = actions
self.actions = list(self.actions) if self.actions else []
self.actions = actions or self.actions or []
if template_name is not None:
self.template_name = template_name
def get_context(self, context):
"""
@@ -73,15 +74,6 @@ 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.
@@ -89,10 +81,7 @@ class Panel:
Parameters:
context (dict): The template 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'))
return render_to_string(self.template_name, self.get_context(context))
#
@@ -116,15 +105,9 @@ 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': title_,
'title': self.title or title(obj._meta.verbose_name),
'object': obj,
}
@@ -204,7 +187,7 @@ class ObjectAttributesPanel(ObjectPanel, metaclass=ObjectAttributesPanelMeta):
'attrs': [
{
'label': attr.label or self._name_to_label(name),
'value': attr.render(ctx['object'], {'name': name, 'perms': ctx['perms']}),
'value': attr.render(ctx['object'], {'name': name}),
} for name, attr in self._attrs.items() if name in attr_names
],
}
@@ -242,10 +225,9 @@ class CommentsPanel(ObjectPanel):
self.field_name = field_name
def get_context(self, context):
ctx = super().get_context(context)
return {
**ctx,
'comments': getattr(ctx['object'], self.field_name, None),
**super().get_context(context),
'comments': getattr(context['object'], self.field_name),
}
@@ -267,10 +249,9 @@ class JSONPanel(ObjectPanel):
self.actions.append(CopyContent(f'panel_{field_name}'))
def get_context(self, context):
ctx = super().get_context(context)
return {
**ctx,
'data': getattr(ctx['object'], self.field_name, None),
**super().get_context(context),
'data': getattr(context['object'], self.field_name),
'field_name': self.field_name,
}
@@ -308,25 +289,20 @@ class ObjectsTablePanel(Panel):
def __init__(self, model, filters=None, **kwargs):
super().__init__(**kwargs)
# Validate the model label format
if '.' not in model:
# 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):
raise ValueError(f"Invalid model label: {model}")
self.model_label = model
self.filters = filters or {}
@property
def model(self):
try:
return apps.get_model(self.model_label)
except LookupError:
raise ValueError(f"Invalid model label: {self.model_label}")
# 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)
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()
}
@@ -334,8 +310,7 @@ class ObjectsTablePanel(Panel):
url_params['return_url'] = context['object'].get_absolute_url()
return {
**super().get_context(context),
'title': panel_title,
'viewname': get_viewname(model, 'list'),
'viewname': get_viewname(self.model, 'list'),
'url_params': dict_to_querydict(url_params),
}
@@ -347,17 +322,12 @@ class TemplatePanel(Panel):
Parameters:
template_name (str): The name of the template to render
"""
def __init__(self, template_name, **kwargs):
self.template_name = template_name
super().__init__(**kwargs)
def __init__(self, template_name):
super().__init__(template_name=template_name)
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)
}
def render(self, context):
# Pass the entire context to the template
return render_to_string(self.template_name, context.flatten())
class TextCodePanel(ObjectPanel):
@@ -372,11 +342,10 @@ class TextCodePanel(ObjectPanel):
self.show_sync_warning = show_sync_warning
def get_context(self, context):
ctx = super().get_context(context)
return {
**ctx,
**super().get_context(context),
'show_sync_warning': self.show_sync_warning,
'value': getattr(ctx['object'], self.field_name, None),
'value': getattr(context.get('object'), self.field_name, None),
}
@@ -392,7 +361,6 @@ 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)
@@ -423,10 +391,14 @@ class ContextTablePanel(ObjectPanel):
return context.get(self.table)
def get_context(self, context):
table = self._resolve_table(context)
return {
**super().get_context(context),
'table': self._resolve_table(context),
'table': table,
}
def should_render(self, context):
return context.get('table') is not None
def render(self, context):
table = self._resolve_table(context)
if table is None:
return ''
return super().render(context)

View File

@@ -1,48 +0,0 @@
{% 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

@@ -1,8 +0,0 @@
{% 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

@@ -0,0 +1,16 @@
{% 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

@@ -0,0 +1,21 @@
{% 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

@@ -0,0 +1,33 @@
{% 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 %}
{% for row in layout.rows %}
<div class="row">
{% for column in row %}
<div class="col{% if column.width %}-{{ column.width }}{% endif %}">
{% for panel in column %}
{% for column in row.columns %}
<div class="col">
{% for panel in column.panels %}
{% render panel %}
{% endfor %}
</div>

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
{% 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

@@ -0,0 +1,30 @@
{% 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

@@ -0,0 +1,21 @@
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,6 +90,9 @@ class Contact(PrimaryModel):
class Meta:
ordering = ['name']
indexes = (
models.Index(fields=('name',)), # Default ordering
)
verbose_name = _('contact')
verbose_name_plural = _('contacts')
@@ -130,6 +133,7 @@ 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

@@ -0,0 +1,19 @@
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,6 +52,9 @@ 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,6 +117,9 @@ 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,6 +1,5 @@
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
@@ -39,11 +38,8 @@ def _get_registered_content(obj, method, template_context):
context['config'] = settings.PLUGINS_CONFIG.get(plugin_name, {})
# Call the method to render content
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)})
instance = template_extension(context)
content = getattr(instance, method)()
html += content
return mark_safe(html)

View File

@@ -0,0 +1,23 @@
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,6 +97,9 @@ 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,6 +92,9 @@ 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')
@@ -249,6 +252,9 @@ 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

@@ -0,0 +1,17 @@
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,6 +145,9 @@ 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,23 +71,6 @@ 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,6 +10,7 @@ from netbox.ui.panels import (
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
@@ -588,8 +589,8 @@ class IPSecProfileView(generic.ObjectView):
CommentsPanel(),
],
right_panels=[
panels.IPSecProfileIKEPolicyPanel(),
panels.IPSecProfileIPSecPolicyPanel(),
TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
],
)

View File

@@ -0,0 +1,20 @@
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,6 +118,7 @@ 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):
ctx = super().get_context(context)
obj = context['object']
return {
**ctx,
'interface': getattr(ctx['object'], self.interface_attr),
**super().get_context(context),
'interface': getattr(obj, self.interface_attr),
}