diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 88fbb1df9..dc8dd8275 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.3-beta1
+ placeholder: v3.3-beta2
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 1035c02fb..d9e5a26fd 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.3-beta1
+ placeholder: v3.3-beta2
validations:
required: true
- type: dropdown
diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md
index 10ccaeb4d..1b4b85c1c 100644
--- a/docs/release-notes/version-3.2.md
+++ b/docs/release-notes/version-3.2.md
@@ -2,6 +2,23 @@
## v3.2.8 (FUTURE)
+### Enhancements
+
+* [#9062](https://github.com/netbox-community/netbox/issues/9062) - Add/edit {module} substitution to help text for component template name
+* [#9637](https://github.com/netbox-community/netbox/issues/9637) - Add site group field to rack reservation form
+* [#9762](https://github.com/netbox-community/netbox/issues/9762) - Add `nat_outside` column to the IPAddress table
+* [#9825](https://github.com/netbox-community/netbox/issues/9825) - Add contacts column to virtual machines table
+* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
+* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
+* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
+
+### Bug Fixes
+
+* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
+* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
+* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
+* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
+
---
## v3.2.7 (2022-07-20)
diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md
index 68cff0547..2a3935e5e 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -1,6 +1,6 @@
# NetBox v3.3
-## v3.3.0 (FUTURE)
+## v3.3-beta2 (2022-08-03)
### Breaking Changes
@@ -104,6 +104,9 @@ Custom field UI visibility has no impact on API operation.
* [#9730](https://github.com/netbox-community/netbox/issues/9730) - Fix validation error when creating a new cable via UI form
* [#9733](https://github.com/netbox-community/netbox/issues/9733) - Handle split paths during trace when fanning out to front ports with differing cables
* [#9765](https://github.com/netbox-community/netbox/issues/9765) - Report correct segment count under cable trace UI view
+* [#9778](https://github.com/netbox-community/netbox/issues/9778) - Fix exception during cable deletion after deleting a connected termination
+* [#9788](https://github.com/netbox-community/netbox/issues/9788) - Ensure denormalized fields on CableTermination are kept in sync with related objects
+* [#9789](https://github.com/netbox-community/netbox/issues/9789) - Fix rendering of cable traces ending at provider networks
* [#9794](https://github.com/netbox-community/netbox/issues/9794) - Fix link to connect a rear port to a circuit termination
* [#9818](https://github.com/netbox-community/netbox/issues/9818) - Fix circuit side selection when connecting a cable to a circuit termination
* [#9829](https://github.com/netbox-community/netbox/issues/9829) - Arrange custom fields by group when editing objects
@@ -123,6 +126,7 @@ Custom field UI visibility has no impact on API operation.
* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset
* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output
+* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields
### REST API Changes
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 11f211b27..423bd67d6 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView):
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=instance
).prefetch_related(
- 'type', 'tenant', 'tenant__group', 'terminations__site'
+ 'tenant__group', 'termination_a__site', 'termination_z__site',
+ 'termination_a__provider_network', 'termination_z__provider_network',
)
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
circuits_table.configure(request)
@@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView):
Q(termination_a__provider_network=instance.pk) |
Q(termination_z__provider_network=instance.pk)
).prefetch_related(
- 'type', 'tenant', 'tenant__group', 'terminations__site'
+ 'tenant__group', 'termination_a__site', 'termination_z__site',
+ 'termination_a__provider_network', 'termination_z__provider_network',
)
circuits_table = tables.CircuitTable(circuits, user=request.user)
circuits_table.configure(request)
@@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related(
- 'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
+ 'tenant__group', 'termination_a__site', 'termination_z__site',
+ 'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
@@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView):
class CircuitBulkEditView(generic.BulkEditView):
queryset = Circuit.objects.prefetch_related(
- 'provider', 'type', 'tenant', 'terminations'
+ 'termination_a__site', 'termination_z__site',
+ 'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
@@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView):
class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
- 'provider', 'type', 'tenant', 'terminations'
+ 'termination_a__site', 'termination_z__site',
+ 'termination_a__provider_network', 'termination_z__provider_network',
)
filterset = filtersets.CircuitFilterSet
table = tables.CircuitTable
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 59445d97b..c18eab01f 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -1,5 +1,4 @@
import socket
-from collections import OrderedDict
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404
@@ -64,20 +63,20 @@ class PathEndpointMixin(object):
return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
# Serialize path objects, iterating over each three-tuple in the path
- for near_end, cable, far_end in obj.trace():
- if near_end is not None:
- serializer_a = get_serializer_for_model(near_end[0], prefix=NESTED_SERIALIZER_PREFIX)
- near_end = serializer_a(near_end, many=True, context={'request': request}).data
+ for near_ends, cable, far_ends in obj.trace():
+ if near_ends:
+ serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
+ near_ends = serializer_a(near_ends, many=True, context={'request': request}).data
else:
# Path is split; stop here
break
- if cable is not None:
+ if cable:
cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
- if far_end is not None:
- serializer_b = get_serializer_for_model(far_end[0], prefix=NESTED_SERIALIZER_PREFIX)
- far_end = serializer_b(far_end, many=True, context={'request': request}).data
+ if far_ends:
+ serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX)
+ far_ends = serializer_b(far_ends, many=True, context={'request': request}).data
- path.append((near_end, cable, far_end))
+ path.append((near_ends, cable, far_ends))
return Response(path)
@@ -484,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
- response = OrderedDict([(m, None) for m in napalm_methods])
+ response = {m: None for m in napalm_methods}
config = get_config()
username = config.NAPALM_USERNAME
diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py
index 78a243f84..4be2df659 100644
--- a/netbox/dcim/apps.py
+++ b/netbox/dcim/apps.py
@@ -1,10 +1,26 @@
from django.apps import AppConfig
+from netbox import denormalized
+
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"
def ready(self):
-
import dcim.signals
+ from .models import CableTermination
+
+ # Register denormalized fields
+ denormalized.register(CableTermination, '_device', {
+ '_rack': 'rack',
+ '_location': 'location',
+ '_site': 'site',
+ })
+ denormalized.register(CableTermination, '_rack', {
+ '_location': 'location',
+ '_site': 'site',
+ })
+ denormalized.register(CableTermination, '_location', {
+ '_site': 'site',
+ })
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index c5474a2b1..16ff6fee2 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -291,7 +291,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('User', ('user_id',)),
- ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
+ ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
@@ -299,25 +299,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
required=False,
label=_('Region')
)
- site_id = DynamicModelMultipleChoiceField(
- queryset=Site.objects.all(),
- required=False,
- query_params={
- 'region_id': '$region_id'
- },
- label=_('Site')
- )
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
label=_('Site group')
)
- location_id = DynamicModelMultipleChoiceField(
- queryset=Location.objects.prefetch_related('site'),
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
required=False,
+ query_params={
+ 'region_id': '$region_id',
+ 'group_id': '$site_group_id',
+ },
+ label=_('Site')
+ )
+ location_id = DynamicModelMultipleChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site_id',
+ },
label=_('Location'),
null_option='None'
)
+ rack_id = DynamicModelMultipleChoiceField(
+ queryset=Rack.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site_id',
+ 'location_id': '$location_id',
+ },
+ label=_('Rack')
+ )
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index f3ab6f3a9..edf25cf2c 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -325,7 +325,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
)
fieldsets = (
- ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
+ ('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
)
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 8c9ddab19..d2c941b34 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -64,6 +64,14 @@ class ModularComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
"""
+ name_pattern = ExpandableNameField(
+ label='Name',
+ help_text="""
+ Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
+ are not supported. Example: [ge,xe]-0/0/[0-9]
. {module} is accepted as a substitution for
+ the module bay position.
+ """
+ )
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index e0a489f5b..321d808ff 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -431,11 +431,7 @@ class CablePath(models.Model):
"""
Return the list of originating objects.
"""
- if hasattr(self, '_path_objects'):
- return self.path_objects[0]
- return [
- path_node_to_object(node) for node in self.path[0]
- ]
+ return self.path_objects[0]
@property
def destinations(self):
@@ -444,11 +440,7 @@ class CablePath(models.Model):
"""
if not self.is_complete:
return []
- if hasattr(self, '_path_objects'):
- return self.path_objects[-1]
- return [
- path_node_to_object(node) for node in self.path[-1]
- ]
+ return self.path_objects[-1]
@property
def segment_count(self):
@@ -463,6 +455,9 @@ class CablePath(models.Model):
"""
from circuits.models import CircuitTermination
+ if not terminations:
+ return None
+
# Ensure all originating terminations are attached to the same link
if len(terminations) > 1:
assert all(t.link == terminations[0].link for t in terminations[1:])
@@ -529,6 +524,9 @@ class CablePath(models.Model):
])
# Step 6: Determine the "next hop" terminations, if applicable
+ if not remote_terminations:
+ break
+
if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts
rear_ports = RearPort.objects.filter(
@@ -640,7 +638,11 @@ class CablePath(models.Model):
nodes = []
for node in step:
ct_id, object_id = decompile_path_node(node)
- nodes.append(prefetched[ct_id][object_id])
+ try:
+ nodes.append(prefetched[ct_id][object_id])
+ except KeyError:
+ # Ignore stale (deleted) object IDs
+ pass
path.append(nodes)
return path
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 4a66bc457..3fc1d4e61 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
related_name='%(class)ss'
)
name = models.CharField(
- max_length=64
+ max_length=64,
+ help_text="""
+ {module} is accepted as a substitution for the module bay position when attached to a module type.
+ """
)
_name = NaturalOrderingField(
target_field='name',
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
**kwargs
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
"""
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
**kwargs
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class PowerPortTemplate(ModularComponentTemplateModel):
"""
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
})
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'maximum_draw': self.maximum_draw,
+ 'allocated_draw': self.allocated_draw,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class PowerOutletTemplate(ModularComponentTemplateModel):
"""
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
**kwargs
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'power_port': self.power_port.name if self.power_port else None,
+ 'feed_leg': self.feed_leg,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class InterfaceTemplate(ModularComponentTemplateModel):
"""
@@ -351,6 +390,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
**kwargs
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'mgmt_only': self.mgmt_only,
+ 'label': self.label,
+ 'description': self.description,
+ 'poe_mode': self.poe_mode,
+ 'poe_type': self.poe_type,
+ }
+
class FrontPortTemplate(ModularComponentTemplateModel):
"""
@@ -424,6 +474,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
**kwargs
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'rear_port': self.rear_port.name,
+ 'rear_port_position': self.rear_port_position,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class RearPortTemplate(ModularComponentTemplateModel):
"""
@@ -463,6 +523,15 @@ class RearPortTemplate(ModularComponentTemplateModel):
**kwargs
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'type': self.type,
+ 'positions': self.positions,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class ModuleBayTemplate(ComponentTemplateModel):
"""
@@ -488,6 +557,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
position=self.position
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'label': self.label,
+ 'position': self.position,
+ 'description': self.description,
+ }
+
class DeviceBayTemplate(ComponentTemplateModel):
"""
@@ -512,6 +589,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
)
+ def to_yaml(self):
+ return {
+ 'name': self.name,
+ 'label': self.label,
+ 'description': self.description,
+ }
+
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
"""
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 8f62b0626..5e2fc348e 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -212,10 +212,13 @@ class PathEndpoint(models.Model):
break
path.extend(origin._path.path_objects)
- while (len(path)) % 3:
- # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
- # by inserting empty entries immediately prior to the path's destination node(s)
- path.append([])
+
+ # If the path ends at a non-connected pass-through port, pad out the link and far-end terminations
+ if len(path) % 3 == 1:
+ path.extend(([], []))
+ # If the path ends at a site or provider network, inject a null "link" to render an attachment
+ elif len(path) % 3 == 2:
+ path.insert(-1, [])
# Check for a bridged relationship to continue the trace
destinations = origin._path.destinations
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index f8a28eb58..f21176d8d 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1,5 +1,4 @@
import decimal
-from collections import OrderedDict
import yaml
from django.contrib.contenttypes.fields import GenericRelation
@@ -164,117 +163,54 @@ class DeviceType(NetBoxModel):
return reverse('dcim:devicetype', args=[self.pk])
def to_yaml(self):
- data = OrderedDict((
- ('manufacturer', self.manufacturer.name),
- ('model', self.model),
- ('slug', self.slug),
- ('part_number', self.part_number),
- ('u_height', float(self.u_height)),
- ('is_full_depth', self.is_full_depth),
- ('subdevice_role', self.subdevice_role),
- ('airflow', self.airflow),
- ('comments', self.comments),
- ))
+ data = {
+ 'manufacturer': self.manufacturer.name,
+ 'model': self.model,
+ 'slug': self.slug,
+ 'part_number': self.part_number,
+ 'u_height': float(self.u_height),
+ 'is_full_depth': self.is_full_depth,
+ 'subdevice_role': self.subdevice_role,
+ 'airflow': self.airflow,
+ 'comments': self.comments,
+ }
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.consoleporttemplates.all()
+ c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.consoleserverporttemplates.all()
+ c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'maximum_draw': c.maximum_draw,
- 'allocated_draw': c.allocated_draw,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.powerporttemplates.all()
+ c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'power_port': c.power_port.name if c.power_port else None,
- 'feed_leg': c.feed_leg,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.poweroutlettemplates.all()
+ c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'mgmt_only': c.mgmt_only,
- 'label': c.label,
- 'description': c.description,
- 'poe_mode': c.poe_mode,
- 'poe_type': c.poe_type,
- }
- for c in self.interfacetemplates.all()
+ c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'rear_port': c.rear_port.name,
- 'rear_port_position': c.rear_port_position,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.frontporttemplates.all()
+ c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'positions': c.positions,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.rearporttemplates.all()
+ c.to_yaml() for c in self.rearporttemplates.all()
]
if self.modulebaytemplates.exists():
data['module-bays'] = [
- {
- 'name': c.name,
- 'label': c.label,
- 'position': c.position,
- 'description': c.description,
- }
- for c in self.modulebaytemplates.all()
+ c.to_yaml() for c in self.modulebaytemplates.all()
]
if self.devicebaytemplates.exists():
data['device-bays'] = [
- {
- 'name': c.name,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.devicebaytemplates.all()
+ c.to_yaml() for c in self.devicebaytemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
@@ -406,91 +342,41 @@ class ModuleType(NetBoxModel):
return reverse('dcim:moduletype', args=[self.pk])
def to_yaml(self):
- data = OrderedDict((
- ('manufacturer', self.manufacturer.name),
- ('model', self.model),
- ('part_number', self.part_number),
- ('comments', self.comments),
- ))
+ data = {
+ 'manufacturer': self.manufacturer.name,
+ 'model': self.model,
+ 'part_number': self.part_number,
+ 'comments': self.comments,
+ }
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.consoleporttemplates.all()
+ c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.consoleserverporttemplates.all()
+ c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'maximum_draw': c.maximum_draw,
- 'allocated_draw': c.allocated_draw,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.powerporttemplates.all()
+ c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'power_port': c.power_port.name if c.power_port else None,
- 'feed_leg': c.feed_leg,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.poweroutlettemplates.all()
+ c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'mgmt_only': c.mgmt_only,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.interfacetemplates.all()
+ c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'rear_port': c.rear_port.name,
- 'rear_port_position': c.rear_port_position,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.frontporttemplates.all()
+ c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'positions': c.positions,
- 'label': c.label,
- 'description': c.description,
- }
- for c in self.rearporttemplates.all()
+ c.to_yaml() for c in self.rearporttemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index 2293f8840..b990daf1a 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -116,7 +116,10 @@ def retrace_cable_paths(instance, **kwargs):
@receiver(post_delete, sender=CableTermination)
def nullify_connected_endpoints(instance, **kwargs):
"""
- Disassociate the Cable from the termination object.
+ Disassociate the Cable from the termination object, and retrace any affected CablePaths.
"""
model = instance.termination_type.model_class()
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
+
+ for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
+ cablepath.retrace()
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index f9c614b67..26d16fafe 100644
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -362,21 +362,26 @@ class CableTraceSVG:
terminations = self.draw_terminations(far_ends)
for term in terminations:
self.draw_fanout(term, cable)
- else:
+ elif far_ends:
self.draw_terminations(far_ends)
+ else:
+ # Link is not connected to anything
+ break
# Far end parent
parent_objects = set(end.parent_object for end in far_ends)
self.draw_parent_objects(parent_objects)
+ # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
+ # a CircuitTermination)
elif far_ends:
# Attachment
attachment = self.draw_attachment()
self.connectors.append(attachment)
- # ProviderNetwork
- self.draw_parent_objects(set(end.parent_object for end in far_ends))
+ # Object
+ self.draw_parent_objects(far_ends)
# Determine drawing size
self.drawing = svgwrite.Drawing(
diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py
index 5b009e42e..e40d7bd80 100644
--- a/netbox/dcim/tables/modules.py
+++ b/netbox/dcim/tables/modules.py
@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
linkify=True,
verbose_name='Module Type'
)
+ manufacturer = tables.Column(
+ linkify=True
+ )
instance_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
module_bay = tables.Column(
linkify=True
)
+ manufacturer = tables.Column(
+ accessor=tables.A('module_type__manufacturer'),
+ linkify=True
+ )
module_type = tables.Column(
linkify=True
)
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
- 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
+ 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments',
+ 'tags',
)
default_columns = (
- 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
+ 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
)
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index 92c4bb0aa..6696d516a 100644
--- a/netbox/dcim/tables/power.py
+++ b/netbox/dcim/tables/power.py
@@ -21,6 +21,9 @@ class PowerPanelTable(NetBoxTable):
site = tables.Column(
linkify=True
)
+ location = tables.Column(
+ linkify=True
+ )
powerfeed_count = columns.LinkedCountColumn(
viewname='dcim:powerfeed_list',
url_params={'power_panel_id': 'pk'},
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = PowerPanel
- fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
+ fields = (
+ 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',
+ )
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index 5412e2297..d83f25a5f 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -109,6 +109,10 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
accessor=Accessor('rack__site'),
linkify=True
)
+ location = tables.Column(
+ accessor=Accessor('rack__location'),
+ linkify=True
+ )
rack = tables.Column(
linkify=True
)
@@ -123,7 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
- 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
+ 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index d97823e7c..0e02b0de5 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -163,8 +163,8 @@ class RackTestCase(TestCase):
}
self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
- del(rack1_inventory_front[10.0])
- del(rack1_inventory_front[10.5])
+ del rack1_inventory_front[10.0]
+ del rack1_inventory_front[10.5]
for u in rack1_inventory_front.values():
self.assertIsNone(u['device'])
@@ -174,8 +174,8 @@ class RackTestCase(TestCase):
}
self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
- del(rack1_inventory_rear[10.0])
- del(rack1_inventory_rear[10.5])
+ del rack1_inventory_rear[10.0]
+ del rack1_inventory_rear[10.5]
for u in rack1_inventory_rear.values():
self.assertIsNone(u['device'])
diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py
index 26b6e2e25..eadd2da96 100644
--- a/netbox/dcim/utils.py
+++ b/netbox/dcim/utils.py
@@ -24,11 +24,12 @@ def object_to_path_node(obj):
def path_node_to_object(repr):
"""
- Given the string representation of a path node, return the corresponding instance.
+ Given the string representation of a path node, return the corresponding instance. If the object no longer
+ exists, return None.
"""
ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id)
- return ct.model_class().objects.get(pk=object_id)
+ return ct.model_class().objects.filter(pk=object_id).first()
def create_cablepath(terminations):
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 12e070e70..4480bee6e 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -324,7 +322,7 @@ class SiteListView(generic.ObjectListView):
class SiteView(generic.ObjectView):
- queryset = Site.objects.prefetch_related('region', 'tenant__group')
+ queryset = Site.objects.prefetch_related('tenant__group')
def get_extra_context(self, request, instance):
stats = {
@@ -359,7 +357,7 @@ class SiteView(generic.ObjectView):
site=instance,
position__isnull=True,
parent_bay__isnull=True
- ).prefetch_related('device_type__manufacturer')
+ ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
asn_count = asns.count()
@@ -391,14 +389,14 @@ class SiteBulkImportView(generic.BulkImportView):
class SiteBulkEditView(generic.BulkEditView):
- queryset = Site.objects.prefetch_related('region', 'tenant')
+ queryset = Site.objects.all()
filterset = filtersets.SiteFilterSet
table = tables.SiteTable
form = forms.SiteBulkEditForm
class SiteBulkDeleteView(generic.BulkDeleteView):
- queryset = Site.objects.prefetch_related('region', 'tenant')
+ queryset = Site.objects.all()
filterset = filtersets.SiteFilterSet
table = tables.SiteTable
@@ -454,7 +452,7 @@ class LocationView(generic.ObjectView):
location=instance,
position__isnull=True,
parent_bay__isnull=True
- ).prefetch_related('device_type__manufacturer')
+ ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
return {
'rack_count': rack_count,
@@ -572,7 +570,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
#
class RackListView(generic.ObjectListView):
- queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
+ queryset = Rack.objects.annotate(
device_count=count_related(Device, 'rack')
)
filterset = filtersets.RackFilterSet
@@ -631,7 +629,7 @@ class RackView(generic.ObjectView):
rack=instance,
position__isnull=True,
parent_bay__isnull=True
- ).prefetch_related('device_type__manufacturer')
+ ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
@@ -682,14 +680,14 @@ class RackBulkImportView(generic.BulkImportView):
class RackBulkEditView(generic.BulkEditView):
- queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
+ queryset = Rack.objects.all()
filterset = filtersets.RackFilterSet
table = tables.RackTable
form = forms.RackBulkEditForm
class RackBulkDeleteView(generic.BulkDeleteView):
- queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
+ queryset = Rack.objects.all()
filterset = filtersets.RackFilterSet
table = tables.RackTable
@@ -706,7 +704,7 @@ class RackReservationListView(generic.ObjectListView):
class RackReservationView(generic.ObjectView):
- queryset = RackReservation.objects.prefetch_related('rack')
+ queryset = RackReservation.objects.all()
class RackReservationEditView(generic.ObjectEditView):
@@ -742,14 +740,14 @@ class RackReservationImportView(generic.BulkImportView):
class RackReservationBulkEditView(generic.BulkEditView):
- queryset = RackReservation.objects.prefetch_related('rack', 'user')
+ queryset = RackReservation.objects.all()
filterset = filtersets.RackReservationFilterSet
table = tables.RackReservationTable
form = forms.RackReservationBulkEditForm
class RackReservationBulkDeleteView(generic.BulkDeleteView):
- queryset = RackReservation.objects.prefetch_related('rack', 'user')
+ queryset = RackReservation.objects.all()
filterset = filtersets.RackReservationFilterSet
table = tables.RackReservationTable
@@ -831,7 +829,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
#
class DeviceTypeListView(generic.ObjectListView):
- queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+ queryset = DeviceType.objects.annotate(
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet
@@ -840,7 +838,7 @@ class DeviceTypeListView(generic.ObjectListView):
class DeviceTypeView(generic.ObjectView):
- queryset = DeviceType.objects.prefetch_related('manufacturer')
+ queryset = DeviceType.objects.all()
def get_extra_context(self, request, instance):
instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
@@ -945,18 +943,18 @@ class DeviceTypeImportView(generic.ObjectImportView):
]
queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeImportForm
- related_object_forms = OrderedDict((
- ('console-ports', forms.ConsolePortTemplateImportForm),
- ('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
- ('power-ports', forms.PowerPortTemplateImportForm),
- ('power-outlets', forms.PowerOutletTemplateImportForm),
- ('interfaces', forms.InterfaceTemplateImportForm),
- ('rear-ports', forms.RearPortTemplateImportForm),
- ('front-ports', forms.FrontPortTemplateImportForm),
- ('module-bays', forms.ModuleBayTemplateImportForm),
- ('device-bays', forms.DeviceBayTemplateImportForm),
- ('inventory-items', forms.InventoryItemTemplateImportForm),
- ))
+ related_object_forms = {
+ 'console-ports': forms.ConsolePortTemplateImportForm,
+ 'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
+ 'power-ports': forms.PowerPortTemplateImportForm,
+ 'power-outlets': forms.PowerOutletTemplateImportForm,
+ 'interfaces': forms.InterfaceTemplateImportForm,
+ 'rear-ports': forms.RearPortTemplateImportForm,
+ 'front-ports': forms.FrontPortTemplateImportForm,
+ 'module-bays': forms.ModuleBayTemplateImportForm,
+ 'device-bays': forms.DeviceBayTemplateImportForm,
+ 'inventory-items': forms.InventoryItemTemplateImportForm,
+ }
def prep_related_object_data(self, parent, data):
data.update({'device_type': parent})
@@ -964,7 +962,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
class DeviceTypeBulkEditView(generic.BulkEditView):
- queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+ queryset = DeviceType.objects.annotate(
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet
@@ -973,7 +971,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
- queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+ queryset = DeviceType.objects.annotate(
instance_count=count_related(Device, 'device_type')
)
filterset = filtersets.DeviceTypeFilterSet
@@ -985,7 +983,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
#
class ModuleTypeListView(generic.ObjectListView):
- queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+ queryset = ModuleType.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet
@@ -994,7 +992,7 @@ class ModuleTypeListView(generic.ObjectListView):
class ModuleTypeView(generic.ObjectView):
- queryset = ModuleType.objects.prefetch_related('manufacturer')
+ queryset = ModuleType.objects.all()
def get_extra_context(self, request, instance):
instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
@@ -1075,15 +1073,15 @@ class ModuleTypeImportView(generic.ObjectImportView):
]
queryset = ModuleType.objects.all()
model_form = forms.ModuleTypeImportForm
- related_object_forms = OrderedDict((
- ('console-ports', forms.ConsolePortTemplateImportForm),
- ('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
- ('power-ports', forms.PowerPortTemplateImportForm),
- ('power-outlets', forms.PowerOutletTemplateImportForm),
- ('interfaces', forms.InterfaceTemplateImportForm),
- ('rear-ports', forms.RearPortTemplateImportForm),
- ('front-ports', forms.FrontPortTemplateImportForm),
- ))
+ related_object_forms = {
+ 'console-ports': forms.ConsolePortTemplateImportForm,
+ 'console-server-ports': forms.ConsoleServerPortTemplateImportForm,
+ 'power-ports': forms.PowerPortTemplateImportForm,
+ 'power-outlets': forms.PowerOutletTemplateImportForm,
+ 'interfaces': forms.InterfaceTemplateImportForm,
+ 'rear-ports': forms.RearPortTemplateImportForm,
+ 'front-ports': forms.FrontPortTemplateImportForm,
+ }
def prep_related_object_data(self, parent, data):
data.update({'module_type': parent})
@@ -1091,7 +1089,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
class ModuleTypeBulkEditView(generic.BulkEditView):
- queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+ queryset = ModuleType.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet
@@ -1100,7 +1098,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
- queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+ queryset = ModuleType.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeFilterSet
@@ -1611,9 +1609,7 @@ class DeviceListView(generic.ObjectListView):
class DeviceView(generic.ObjectView):
- queryset = Device.objects.prefetch_related(
- 'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
- )
+ queryset = Device.objects.all()
def get_extra_context(self, request, instance):
# VirtualChassis members
@@ -1790,14 +1786,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView):
class DeviceBulkEditView(generic.BulkEditView):
- queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
+ queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
form = forms.DeviceBulkEditForm
class DeviceBulkDeleteView(generic.BulkDeleteView):
- queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
+ queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
@@ -1807,7 +1803,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
#
class ModuleListView(generic.ObjectListView):
- queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+ queryset = Module.objects.prefetch_related('module_type__manufacturer')
filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable
@@ -1833,14 +1829,14 @@ class ModuleBulkImportView(generic.BulkImportView):
class ModuleBulkEditView(generic.BulkEditView):
- queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+ queryset = Module.objects.prefetch_related('module_type__manufacturer')
filterset = filtersets.ModuleFilterSet
table = tables.ModuleTable
form = forms.ModuleBulkEditForm
class ModuleBulkDeleteView(generic.BulkDeleteView):
- queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+ queryset = Module.objects.prefetch_related('module_type__manufacturer')
filterset = filtersets.ModuleFilterSet
table = tables.ModuleTable
@@ -2566,7 +2562,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
class InventoryItemBulkEditView(generic.BulkEditView):
- queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
+ queryset = InventoryItem.objects.all()
filterset = filtersets.InventoryItemFilterSet
table = tables.InventoryItemTable
form = forms.InventoryItemBulkEditForm
@@ -2577,7 +2573,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
- queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
+ queryset = InventoryItem.objects.all()
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@@ -2867,14 +2863,20 @@ class CableBulkImportView(generic.BulkImportView):
class CableBulkEditView(generic.BulkEditView):
- queryset = Cable.objects.prefetch_related('terminations')
+ queryset = Cable.objects.prefetch_related(
+ 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
+ 'terminations___site',
+ )
filterset = filtersets.CableFilterSet
table = tables.CableTable
form = forms.CableBulkEditForm
class CableBulkDeleteView(generic.BulkDeleteView):
- queryset = Cable.objects.prefetch_related('terminations')
+ queryset = Cable.objects.prefetch_related(
+ 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location',
+ 'terminations___site',
+ )
filterset = filtersets.CableFilterSet
table = tables.CableTable
@@ -2930,7 +2932,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
#
class VirtualChassisListView(generic.ObjectListView):
- queryset = VirtualChassis.objects.prefetch_related('master').annotate(
+ queryset = VirtualChassis.objects.annotate(
member_count=count_related(Device, 'virtual_chassis')
)
table = tables.VirtualChassisTable
@@ -3158,9 +3160,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
#
class PowerPanelListView(generic.ObjectListView):
- queryset = PowerPanel.objects.prefetch_related(
- 'site', 'location'
- ).annotate(
+ queryset = PowerPanel.objects.annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filtersets.PowerPanelFilterSet
@@ -3169,10 +3169,10 @@ class PowerPanelListView(generic.ObjectListView):
class PowerPanelView(generic.ObjectView):
- queryset = PowerPanel.objects.prefetch_related('site', 'location')
+ queryset = PowerPanel.objects.all()
def get_extra_context(self, request, instance):
- power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack')
+ power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance)
powerfeed_table = tables.PowerFeedTable(
data=power_feeds,
orderable=False
@@ -3202,16 +3202,14 @@ class PowerPanelBulkImportView(generic.BulkImportView):
class PowerPanelBulkEditView(generic.BulkEditView):
- queryset = PowerPanel.objects.prefetch_related('site', 'location')
+ queryset = PowerPanel.objects.all()
filterset = filtersets.PowerPanelFilterSet
table = tables.PowerPanelTable
form = forms.PowerPanelBulkEditForm
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
- queryset = PowerPanel.objects.prefetch_related(
- 'site', 'location'
- ).annotate(
+ queryset = PowerPanel.objects.annotate(
powerfeed_count=count_related(PowerFeed, 'power_panel')
)
filterset = filtersets.PowerPanelFilterSet
@@ -3230,7 +3228,7 @@ class PowerFeedListView(generic.ObjectListView):
class PowerFeedView(generic.ObjectView):
- queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
+ queryset = PowerFeed.objects.all()
class PowerFeedEditView(generic.ObjectEditView):
@@ -3249,7 +3247,7 @@ class PowerFeedBulkImportView(generic.BulkImportView):
class PowerFeedBulkEditView(generic.BulkEditView):
- queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
+ queryset = PowerFeed.objects.all()
filterset = filtersets.PowerFeedFilterSet
table = tables.PowerFeedTable
form = forms.PowerFeedBulkEditForm
@@ -3260,6 +3258,6 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView):
class PowerFeedBulkDeleteView(generic.BulkDeleteView):
- queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
+ queryset = PowerFeed.objects.all()
filterset = filtersets.PowerFeedFilterSet
table = tables.PowerFeedTable
diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py
index 1ef723e93..bea1fbcc1 100644
--- a/netbox/extras/forms/models.py
+++ b/netbox/extras/forms/models.py
@@ -136,6 +136,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
+ 'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
}
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index bbc66f279..156e02f74 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -181,7 +181,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
model = ct.model_class()
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
for instance in instances:
- del(instance.custom_field_data[self.name])
+ del instance.custom_field_data[self.name]
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
def rename_object_data(self, old_name, new_name):
diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py
index 07fd4cc24..e1437c00e 100644
--- a/netbox/extras/registry.py
+++ b/netbox/extras/registry.py
@@ -28,3 +28,4 @@ registry = Registry()
registry['model_features'] = {
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
}
+registry['denormalized_fields'] = collections.defaultdict(list)
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 0a8a8d89b..43d916aff 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -3,7 +3,6 @@ import inspect
import logging
import pkgutil
import traceback
-from collections import OrderedDict
from django.conf import settings
from django.utils import timezone
@@ -114,7 +113,7 @@ class Report(object):
def __init__(self):
- self._results = OrderedDict()
+ self._results = {}
self.active_test = None
self.failed = False
@@ -125,13 +124,13 @@ class Report(object):
for method in dir(self):
if method.startswith('test_') and callable(getattr(self, method)):
test_methods.append(method)
- self._results[method] = OrderedDict([
- ('success', 0),
- ('info', 0),
- ('warning', 0),
- ('failure', 0),
- ('log', []),
- ])
+ self._results[method] = {
+ 'success': 0,
+ 'info': 0,
+ 'warning': 0,
+ 'failure': 0,
+ 'log': [],
+ }
if not test_methods:
raise Exception("A report must contain at least one test method.")
self.test_methods = test_methods
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index cee264878..6e4478304 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -6,7 +6,6 @@ import pkgutil
import sys
import traceback
import threading
-from collections import OrderedDict
import yaml
from django import forms
@@ -496,7 +495,7 @@ def get_scripts(use_names=False):
Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human-
defined name in place of the actual module name.
"""
- scripts = OrderedDict()
+ scripts = {}
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
@@ -510,7 +509,7 @@ def get_scripts(use_names=False):
if use_names and hasattr(module, 'name'):
module_name = module.name
- module_scripts = OrderedDict()
+ module_scripts = {}
script_order = getattr(module, "script_order", ())
ordered_scripts = [cls for cls in script_order if is_script(cls)]
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py
index d963bd25a..a73eb3fb4 100644
--- a/netbox/extras/templatetags/custom_links.py
+++ b/netbox/extras/templatetags/custom_links.py
@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
@@ -50,7 +48,7 @@ def custom_links(context, obj):
'perms': context['perms'], # django.contrib.auth.context_processors.auth
}
template_code = ''
- group_names = OrderedDict()
+ group_names = {}
for cl in custom_links:
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index 8dcb53b09..946999bc2 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -992,7 +992,7 @@ class CustomFieldModelTest(TestCase):
with self.assertRaises(ValidationError):
site.clean()
- del(site.cf['bar'])
+ del site.cf['bar']
site.clean()
def test_missing_required_field(self):
diff --git a/netbox/extras/tests/test_registry.py b/netbox/extras/tests/test_registry.py
index 53ba6584a..38a6b9f83 100644
--- a/netbox/extras/tests/test_registry.py
+++ b/netbox/extras/tests/test_registry.py
@@ -30,4 +30,4 @@ class RegistryTest(TestCase):
reg['foo'] = 123
with self.assertRaises(TypeError):
- del(reg['foo'])
+ del reg['foo']
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index bb99536c3..5b589c181 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -492,14 +492,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
class JournalEntryBulkEditView(generic.BulkEditView):
- queryset = JournalEntry.objects.prefetch_related('created_by')
+ queryset = JournalEntry.objects.all()
filterset = filtersets.JournalEntryFilterSet
table = tables.JournalEntryTable
form = forms.JournalEntryBulkEditForm
class JournalEntryBulkDeleteView(generic.BulkDeleteView):
- queryset = JournalEntry.objects.prefetch_related('created_by')
+ queryset = JournalEntry.objects.all()
filterset = filtersets.JournalEntryFilterSet
table = tables.JournalEntryTable
diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py
index b3a3589fd..91a81d3b2 100644
--- a/netbox/ipam/api/serializers.py
+++ b/netbox/ipam/api/serializers.py
@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
@@ -227,13 +225,13 @@ class AvailableVLANSerializer(serializers.Serializer):
group = NestedVLANGroupSerializer(read_only=True)
def to_representation(self, instance):
- return OrderedDict([
- ('vid', instance),
- ('group', NestedVLANGroupSerializer(
+ return {
+ 'vid': instance,
+ 'group': NestedVLANGroupSerializer(
self.context['group'],
context={'request': self.context['request']}
- ).data),
- ])
+ ).data,
+ }
class CreateAvailableVLANSerializer(NetBoxModelSerializer):
@@ -318,11 +316,11 @@ class AvailablePrefixSerializer(serializers.Serializer):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
else:
vrf = None
- return OrderedDict([
- ('family', instance.version),
- ('prefix', str(instance)),
- ('vrf', vrf),
- ])
+ return {
+ 'family': instance.version,
+ 'prefix': str(instance),
+ 'vrf': vrf,
+ }
#
@@ -397,11 +395,11 @@ class AvailableIPSerializer(serializers.Serializer):
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
else:
vrf = None
- return OrderedDict([
- ('family', self.context['parent'].family),
- ('address', f"{instance}/{self.context['parent'].mask_length}"),
- ('vrf', vrf),
- ])
+ return {
+ 'family': self.context['parent'].family,
+ 'address': f"{instance}/{self.context['parent'].mask_length}",
+ 'vrf': vrf,
+ }
#
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index edd1867ed..49ec15fc1 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -980,21 +980,65 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
to_field_name='slug',
label='L2VPN (slug)',
)
- device = MultiValueCharFilter(
- method='filter_device',
- field_name='name',
+ region = MultiValueCharFilter(
+ method='filter_region',
+ field_name='slug',
+ label='Region (slug)',
+ )
+ region_id = MultiValueNumberFilter(
+ method='filter_region',
+ field_name='pk',
+ label='Region (ID)',
+ )
+ site = MultiValueCharFilter(
+ method='filter_site',
+ field_name='slug',
+ label='Site (slug)',
+ )
+ site_id = MultiValueNumberFilter(
+ method='filter_site',
+ field_name='pk',
+ label='Site (ID)',
+ )
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__device__name',
+ queryset=Device.objects.all(),
+ to_field_name='name',
label='Device (name)',
)
- device_id = MultiValueNumberFilter(
- method='filter_device',
- field_name='pk',
+ device_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__device',
+ queryset=Device.objects.all(),
label='Device (ID)',
)
+ virtual_machine = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__virtual_machine__name',
+ queryset=VirtualMachine.objects.all(),
+ to_field_name='name',
+ label='Virtual machine (name)',
+ )
+ virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__virtual_machine',
+ queryset=VirtualMachine.objects.all(),
+ label='Virtual machine (ID)',
+ )
+ interface = django_filters.ModelMultipleChoiceFilter(
+ field_name='interface__name',
+ queryset=Interface.objects.all(),
+ to_field_name='name',
+ label='Interface (name)',
+ )
interface_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface',
queryset=Interface.objects.all(),
label='Interface (ID)',
)
+ vminterface = django_filters.ModelMultipleChoiceFilter(
+ field_name='vminterface__name',
+ queryset=VMInterface.objects.all(),
+ to_field_name='name',
+ label='VM interface (name)',
+ )
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.all(),
@@ -1027,13 +1071,22 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
qs_filter = Q(l2vpn__name__icontains=value)
return queryset.filter(qs_filter)
- def filter_device(self, queryset, name, value):
- devices = Device.objects.filter(**{'{}__in'.format(name): value})
- if not devices.exists():
- return queryset.none()
- interface_ids = []
- for device in devices:
- interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
- return queryset.filter(
- interface__in=interface_ids
+ def filter_site(self, queryset, name, value):
+ qs = queryset.filter(
+ Q(
+ Q(**{'vlan__site__{}__in'.format(name): value}) |
+ Q(**{'interface__device__site__{}__in'.format(name): value}) |
+ Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value})
+ )
)
+ return qs
+
+ def filter_region(self, queryset, name, value):
+ qs = queryset.filter(
+ Q(
+ Q(**{'vlan__site__region__{}__in'.format(name): value}) |
+ Q(**{'interface__device__site__region__{}__in'.format(name): value}) |
+ Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value})
+ )
+ )
+ return qs
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 384a4da33..ecf63b49f 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import TenancyFilterForm
from utilities.forms import (
add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
- MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+ MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple,
)
from virtualization.models import VirtualMachine
@@ -508,7 +508,8 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
model = L2VPNTermination
fieldsets = (
- (None, ('l2vpn_id', 'assigned_object_type_id')),
+ (None, ('l2vpn_id', )),
+ ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')),
)
l2vpn_id = DynamicModelChoiceField(
queryset=L2VPN.objects.all(),
@@ -516,7 +517,49 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
label='L2VPN'
)
assigned_object_type_id = ContentTypeMultipleChoiceField(
- queryset=ContentType.objects.all(),
+ queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
required=False,
- label='Object type'
+ label=_('Assigned Object Type'),
+ limit_choices_to=L2VPN_ASSIGNMENT_MODELS
+ )
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Region')
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'region_id': '$region_id'
+ },
+ label=_('Site')
+ )
+ device_id = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id'
+ },
+ label=_('Device')
+ )
+ vlan_id = DynamicModelMultipleChoiceField(
+ queryset=VLAN.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id'
+ },
+ label=_('VLAN')
+ )
+ virtual_machine_id = DynamicModelMultipleChoiceField(
+ queryset=VirtualMachine.objects.all(),
+ required=False,
+ null_option='None',
+ query_params={
+ 'site_id': '$site_id'
+ },
+ label=_('Virtual Machine')
)
diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py
index 0a22cbc21..34bf739f4 100644
--- a/netbox/ipam/forms/models.py
+++ b/netbox/ipam/forms/models.py
@@ -851,7 +851,7 @@ class ServiceCreateForm(ServiceForm):
# Fields which may be populated from a ServiceTemplate are not required
for field in ('name', 'protocol', 'ports'):
self.fields[field].required = False
- del(self.fields[field].widget.attrs['required'])
+ del self.fields[field].widget.attrs['required']
def clean(self):
if self.cleaned_data['service_template']:
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index ee5de8cf4..9ad763920 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -373,7 +373,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel):
# Cache the original prefix and VRF so we can check if they have changed on post_save
self._prefix = self.prefix
- self._vrf = self.vrf
+ self._vrf_id = self.vrf_id
def __str__(self):
return str(self.prefix)
diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py
index 5d85fe915..5adf5e05d 100644
--- a/netbox/ipam/models/l2vpn.py
+++ b/netbox/ipam/models/l2vpn.py
@@ -113,3 +113,18 @@ class L2VPNTermination(NetBoxModel):
f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already '
f'defined.'
)
+
+ @property
+ def assigned_object_parent(self):
+ obj_type = ContentType.objects.get_for_model(self.assigned_object)
+ if obj_type.model == 'vminterface':
+ return self.assigned_object.virtual_machine
+ elif obj_type.model == 'interface':
+ return self.assigned_object.device
+ elif obj_type.model == 'vminterface':
+ return self.assigned_object.virtual_machine
+ return None
+
+ @property
+ def assigned_object_site(self):
+ return self.assigned_object_parent.site
diff --git a/netbox/ipam/signals.py b/netbox/ipam/signals.py
index 3e8b86050..8555f5e67 100644
--- a/netbox/ipam/signals.py
+++ b/netbox/ipam/signals.py
@@ -30,14 +30,14 @@ def update_children_depth(prefix):
def handle_prefix_saved(instance, created, **kwargs):
# Prefix has changed (or new instance has been created)
- if created or instance.vrf != instance._vrf or instance.prefix != instance._prefix:
+ if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
update_parents_children(instance)
update_children_depth(instance)
# If this is not a new prefix, clean up parent/children of previous prefix
if not created:
- old_prefix = Prefix(vrf=instance._vrf, prefix=instance._prefix)
+ old_prefix = Prefix(vrf_id=instance._vrf_id, prefix=instance._prefix)
update_parents_children(old_prefix)
update_children_depth(old_prefix)
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index bec05eeff..087d0de73 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -369,6 +369,11 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name='NAT (Inside)'
)
+ nat_outside = tables.Column(
+ linkify=True,
+ orderable=False,
+ verbose_name='NAT (Outside)'
+ )
assigned = columns.BooleanColumn(
accessor='assigned_object_id',
linkify=True,
@@ -381,7 +386,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = IPAddress
fields = (
- 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'assigned', 'dns_name', 'description',
+ 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description',
'tags', 'created', 'last_updated',
)
default_columns = (
diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py
index 5be525343..e2eae7a32 100644
--- a/netbox/ipam/tables/l2vpn.py
+++ b/netbox/ipam/tables/l2vpn.py
@@ -53,8 +53,17 @@ class L2VPNTerminationTable(NetBoxTable):
linkify=True,
orderable=False
)
+ assigned_object_parent = tables.Column(
+ linkify=True,
+ orderable=False
+ )
+ assigned_object_site = tables.Column(
+ linkify=True,
+ orderable=False
+ )
class Meta(NetBoxTable.Meta):
model = L2VPNTermination
- fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')
+ fields = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent',
+ 'assigned_object_site', 'actions')
default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index 9106a4965..081f6e11d 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -1600,3 +1600,24 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vlan': ['VLAN 1', 'VLAN 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_site(self):
+ site = Site.objects.all().first()
+ params = {'site_id': [site.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'site': ['site-1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_device(self):
+ device = Device.objects.all().first()
+ params = {'device_id': [device.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'device': ['Device 1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_virtual_machine(self):
+ virtual_machine = VirtualMachine.objects.all().first()
+ params = {'virtual_machine_id': [virtual_machine.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'virtual_machine': ['Virtual Machine 1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 72b223b55..a086ab66d 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -40,11 +40,11 @@ class VRFView(generic.ObjectView):
ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count()
import_targets_table = tables.RouteTargetTable(
- instance.import_targets.prefetch_related('tenant'),
+ instance.import_targets.all(),
orderable=False
)
export_targets_table = tables.RouteTargetTable(
- instance.export_targets.prefetch_related('tenant'),
+ instance.export_targets.all(),
orderable=False
)
@@ -72,14 +72,14 @@ class VRFBulkImportView(generic.BulkImportView):
class VRFBulkEditView(generic.BulkEditView):
- queryset = VRF.objects.prefetch_related('tenant')
+ queryset = VRF.objects.all()
filterset = filtersets.VRFFilterSet
table = tables.VRFTable
form = forms.VRFBulkEditForm
class VRFBulkDeleteView(generic.BulkDeleteView):
- queryset = VRF.objects.prefetch_related('tenant')
+ queryset = VRF.objects.all()
filterset = filtersets.VRFFilterSet
table = tables.VRFTable
@@ -100,11 +100,11 @@ class RouteTargetView(generic.ObjectView):
def get_extra_context(self, request, instance):
importing_vrfs_table = tables.VRFTable(
- instance.importing_vrfs.prefetch_related('tenant'),
+ instance.importing_vrfs.all(),
orderable=False
)
exporting_vrfs_table = tables.VRFTable(
- instance.exporting_vrfs.prefetch_related('tenant'),
+ instance.exporting_vrfs.all(),
orderable=False
)
@@ -130,14 +130,14 @@ class RouteTargetBulkImportView(generic.BulkImportView):
class RouteTargetBulkEditView(generic.BulkEditView):
- queryset = RouteTarget.objects.prefetch_related('tenant')
+ queryset = RouteTarget.objects.all()
filterset = filtersets.RouteTargetFilterSet
table = tables.RouteTargetTable
form = forms.RouteTargetBulkEditForm
class RouteTargetBulkDeleteView(generic.BulkDeleteView):
- queryset = RouteTarget.objects.prefetch_related('tenant')
+ queryset = RouteTarget.objects.all()
filterset = filtersets.RouteTargetFilterSet
table = tables.RouteTargetTable
@@ -334,14 +334,18 @@ class AggregateBulkImportView(generic.BulkImportView):
class AggregateBulkEditView(generic.BulkEditView):
- queryset = Aggregate.objects.prefetch_related('rir')
+ queryset = Aggregate.objects.annotate(
+ child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
+ )
filterset = filtersets.AggregateFilterSet
table = tables.AggregateTable
form = forms.AggregateBulkEditForm
class AggregateBulkDeleteView(generic.BulkDeleteView):
- queryset = Aggregate.objects.prefetch_related('rir')
+ queryset = Aggregate.objects.annotate(
+ child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
+ )
filterset = filtersets.AggregateFilterSet
table = tables.AggregateTable
@@ -417,7 +421,7 @@ class PrefixListView(generic.ObjectListView):
class PrefixView(generic.ObjectView):
- queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
+ queryset = Prefix.objects.all()
def get_extra_context(self, request, instance):
try:
@@ -433,7 +437,7 @@ class PrefixView(generic.ObjectView):
).filter(
prefix__net_contains=str(instance.prefix)
).prefetch_related(
- 'site', 'role', 'tenant'
+ 'site', 'role', 'tenant', 'vlan',
)
parent_prefix_table = tables.PrefixTable(
list(parent_prefixes),
@@ -447,7 +451,7 @@ class PrefixView(generic.ObjectView):
).exclude(
pk=instance.pk
).prefetch_related(
- 'site', 'role'
+ 'site', 'role', 'tenant', 'vlan',
)
duplicate_prefix_table = tables.PrefixTable(
list(duplicate_prefixes),
@@ -500,7 +504,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
def get_children(self, request, parent):
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
- 'vrf', 'role', 'tenant', 'tenant__group',
+ 'tenant__group',
)
def get_extra_context(self, request, instance):
@@ -519,7 +523,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent):
- return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
+ return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true')
@@ -552,14 +556,14 @@ class PrefixBulkImportView(generic.BulkImportView):
class PrefixBulkEditView(generic.BulkEditView):
- queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
+ queryset = Prefix.objects.prefetch_related('vrf__tenant')
filterset = filtersets.PrefixFilterSet
table = tables.PrefixTable
form = forms.PrefixBulkEditForm
class PrefixBulkDeleteView(generic.BulkDeleteView):
- queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
+ queryset = Prefix.objects.prefetch_related('vrf__tenant')
filterset = filtersets.PrefixFilterSet
table = tables.PrefixTable
@@ -611,14 +615,14 @@ class IPRangeBulkImportView(generic.BulkImportView):
class IPRangeBulkEditView(generic.BulkEditView):
- queryset = IPRange.objects.prefetch_related('vrf', 'tenant')
+ queryset = IPRange.objects.all()
filterset = filtersets.IPRangeFilterSet
table = tables.IPRangeTable
form = forms.IPRangeBulkEditForm
class IPRangeBulkDeleteView(generic.BulkDeleteView):
- queryset = IPRange.objects.prefetch_related('vrf', 'tenant')
+ queryset = IPRange.objects.all()
filterset = filtersets.IPRangeFilterSet
table = tables.IPRangeTable
@@ -789,14 +793,14 @@ class IPAddressBulkImportView(generic.BulkImportView):
class IPAddressBulkEditView(generic.BulkEditView):
- queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
+ queryset = IPAddress.objects.prefetch_related('vrf__tenant')
filterset = filtersets.IPAddressFilterSet
table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm
class IPAddressBulkDeleteView(generic.BulkDeleteView):
- queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
+ queryset = IPAddress.objects.prefetch_related('vrf__tenant')
filterset = filtersets.IPAddressFilterSet
table = tables.IPAddressTable
@@ -819,7 +823,8 @@ class VLANGroupView(generic.ObjectView):
def get_extra_context(self, request, instance):
vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
- Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
+ Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)),
+ 'tenant', 'site', 'role',
).order_by('vid')
vlans_count = vlans.count()
vlans = add_available_vlans(vlans, vlan_group=instance)
@@ -894,7 +899,7 @@ class FHRPGroupView(generic.ObjectView):
def get_extra_context(self, request, instance):
# Get assigned IP addresses
ipaddress_table = tables.AssignedIPAddressesTable(
- data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
+ data=instance.ip_addresses.restrict(request.user, 'view'),
orderable=False
)
@@ -984,11 +989,11 @@ class VLANListView(generic.ObjectListView):
class VLANView(generic.ObjectView):
- queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
+ queryset = VLAN.objects.all()
def get_extra_context(self, request, instance):
prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
- 'vrf', 'site', 'role'
+ 'vrf', 'site', 'role', 'tenant'
)
prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
@@ -1046,14 +1051,14 @@ class VLANBulkImportView(generic.BulkImportView):
class VLANBulkEditView(generic.BulkEditView):
- queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
+ queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet
table = tables.VLANTable
form = forms.VLANBulkEditForm
class VLANBulkDeleteView(generic.BulkDeleteView):
- queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
+ queryset = VLAN.objects.all()
filterset = filtersets.VLANFilterSet
table = tables.VLANTable
@@ -1106,14 +1111,14 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
#
class ServiceListView(generic.ObjectListView):
- queryset = Service.objects.all()
+ queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable
class ServiceView(generic.ObjectView):
- queryset = Service.objects.prefetch_related('ipaddresses')
+ queryset = Service.objects.all()
class ServiceCreateView(generic.ObjectEditView):
@@ -1123,7 +1128,7 @@ class ServiceCreateView(generic.ObjectEditView):
class ServiceEditView(generic.ObjectEditView):
- queryset = Service.objects.prefetch_related('ipaddresses')
+ queryset = Service.objects.all()
form = forms.ServiceForm
template_name = 'ipam/service_edit.html'
diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py
index 1f3c40dc2..52343c2f6 100644
--- a/netbox/netbox/api/fields.py
+++ b/netbox/netbox/api/fields.py
@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
from django.core.exceptions import ObjectDoesNotExist
from netaddr import IPNetwork
from rest_framework import serializers
@@ -48,10 +46,10 @@ class ChoiceField(serializers.Field):
def to_representation(self, obj):
if obj == '':
return None
- return OrderedDict([
- ('value', obj),
- ('label', self._choices[obj])
- ])
+ return {
+ 'value': obj,
+ 'label': self._choices[obj],
+ }
def to_internal_value(self, data):
if data == '':
diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py
index 8b4069c98..5016bdaab 100644
--- a/netbox/netbox/api/serializers/generic.py
+++ b/netbox/netbox/api/serializers/generic.py
@@ -1,7 +1,10 @@
from django.contrib.contenttypes.models import ContentType
+from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from netbox.api.fields import ContentTypeField
+from netbox.constants import NESTED_SERIALIZER_PREFIX
+from utilities.api import get_serializer_for_model
from utilities.utils import content_type_identifier
__all__ = (
@@ -17,6 +20,7 @@ class GenericObjectSerializer(serializers.Serializer):
queryset=ContentType.objects.all()
)
object_id = serializers.IntegerField()
+ object = serializers.SerializerMethodField(read_only=True)
def to_internal_value(self, data):
data = super().to_internal_value(data)
@@ -25,7 +29,17 @@ class GenericObjectSerializer(serializers.Serializer):
def to_representation(self, instance):
ct = ContentType.objects.get_for_model(instance)
- return {
+ data = {
'object_type': content_type_identifier(ct),
'object_id': instance.pk,
}
+ if 'request' in self.context:
+ data['object'] = self.get_object(instance)
+
+ return data
+
+ @swagger_serializer_method(serializer_or_field=serializers.DictField)
+ def get_object(self, obj):
+ serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX)
+ # context = {'request': self.context['request']}
+ return serializer(obj, context=self.context).data
diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py
index 835ebc6a9..6c6083959 100644
--- a/netbox/netbox/api/views.py
+++ b/netbox/netbox/api/views.py
@@ -1,5 +1,4 @@
import platform
-from collections import OrderedDict
from django import __version__ as DJANGO_VERSION
from django.apps import apps
@@ -26,18 +25,18 @@ class APIRootView(APIView):
def get(self, request, format=None):
- return Response(OrderedDict((
- ('circuits', reverse('circuits-api:api-root', request=request, format=format)),
- ('dcim', reverse('dcim-api:api-root', request=request, format=format)),
- ('extras', reverse('extras-api:api-root', request=request, format=format)),
- ('ipam', reverse('ipam-api:api-root', request=request, format=format)),
- ('plugins', reverse('plugins-api:api-root', request=request, format=format)),
- ('status', reverse('api-status', request=request, format=format)),
- ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
- ('users', reverse('users-api:api-root', request=request, format=format)),
- ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
- ('wireless', reverse('wireless-api:api-root', request=request, format=format)),
- )))
+ return Response({
+ 'circuits': reverse('circuits-api:api-root', request=request, format=format),
+ 'dcim': reverse('dcim-api:api-root', request=request, format=format),
+ 'extras': reverse('extras-api:api-root', request=request, format=format),
+ 'ipam': reverse('ipam-api:api-root', request=request, format=format),
+ 'plugins': reverse('plugins-api:api-root', request=request, format=format),
+ 'status': reverse('api-status', request=request, format=format),
+ 'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
+ 'users': reverse('users-api:api-root', request=request, format=format),
+ 'virtualization': reverse('virtualization-api:api-root', request=request, format=format),
+ 'wireless': reverse('wireless-api:api-root', request=request, format=format),
+ })
class StatusView(APIView):
diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py
new file mode 100644
index 000000000..cd4a869d2
--- /dev/null
+++ b/netbox/netbox/denormalized.py
@@ -0,0 +1,58 @@
+import logging
+
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+
+from extras.registry import registry
+
+
+logger = logging.getLogger('netbox.denormalized')
+
+
+def register(model, field_name, mappings):
+ """
+ Register a denormalized model field to ensure that it is kept up-to-date with the related object.
+
+ Args:
+ model: The class being updated
+ field_name: The name of the field related to the triggering instance
+ mappings: Dictionary mapping of local to remote fields
+ """
+ logger.debug(f'Registering denormalized field {model}.{field_name}')
+
+ field = model._meta.get_field(field_name)
+ rel_model = field.related_model
+
+ registry['denormalized_fields'][rel_model].append(
+ (model, field_name, mappings)
+ )
+
+
+@receiver(post_save)
+def update_denormalized_fields(sender, instance, created, raw, **kwargs):
+ """
+ Check if the sender has denormalized fields registered, and update them as necessary.
+ """
+ def _get_field_value(instance, field_name):
+ field = instance._meta.get_field(field_name)
+ return field.value_from_object(instance)
+
+ # Skip for new objects or those being populated from raw data
+ if created or raw:
+ return
+
+ # Look up any denormalized fields referencing this model from the application registry
+ for model, field_name, mappings in registry['denormalized_fields'].get(sender, []):
+ logger.debug(f'Updating denormalized values for {model}.{field_name}')
+ filter_params = {
+ field_name: instance.pk,
+ }
+ update_params = {
+ # Map the denormalized field names to the instance's values
+ denorm: _get_field_value(instance, origin) for denorm, origin in mappings.items()
+ }
+
+ # TODO: Improve efficiency here by placing conditions on the query?
+ # Update all the denormalized fields with the triggering object's new values
+ count = model.objects.filter(**filter_params).update(**update_params)
+ logger.debug(f'Updated {count} rows')
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index e0ec8e1ec..0dcde9cf6 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
-VERSION = '3.3-beta1'
+VERSION = '3.3-beta2'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html
index 3a7fe986a..11e776872 100644
--- a/netbox/templates/dcim/interface.html
+++ b/netbox/templates/dcim/interface.html
@@ -219,7 +219,7 @@