Просмотр исходного кода

Merge branch 'feature' into docs-refresh

jeremystretch 3 лет назад
Родитель
Сommit
a7bf7bf7a5
54 измененных файлов с 705 добавлено и 435 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 17 0
      docs/release-notes/version-3.2.md
  4. 5 1
      docs/release-notes/version-3.3.md
  5. 10 5
      netbox/circuits/views.py
  6. 10 11
      netbox/dcim/api/views.py
  7. 17 1
      netbox/dcim/apps.py
  8. 21 8
      netbox/dcim/forms/filtersets.py
  9. 1 1
      netbox/dcim/forms/models.py
  10. 8 0
      netbox/dcim/forms/object_create.py
  11. 13 11
      netbox/dcim/models/cables.py
  12. 85 1
      netbox/dcim/models/device_component_templates.py
  13. 7 4
      netbox/dcim/models/device_components.py
  14. 33 147
      netbox/dcim/models/devices.py
  15. 4 1
      netbox/dcim/signals.py
  16. 8 3
      netbox/dcim/svg/cables.py
  17. 10 2
      netbox/dcim/tables/modules.py
  18. 6 1
      netbox/dcim/tables/power.py
  19. 5 1
      netbox/dcim/tables/racks.py
  20. 4 4
      netbox/dcim/tests/test_models.py
  21. 3 2
      netbox/dcim/utils.py
  22. 66 68
      netbox/dcim/views.py
  23. 1 0
      netbox/extras/forms/models.py
  24. 1 1
      netbox/extras/models/customfields.py
  25. 1 0
      netbox/extras/registry.py
  26. 8 9
      netbox/extras/reports.py
  27. 2 3
      netbox/extras/scripts.py
  28. 1 3
      netbox/extras/templatetags/custom_links.py
  29. 1 1
      netbox/extras/tests/test_customfields.py
  30. 1 1
      netbox/extras/tests/test_registry.py
  31. 2 2
      netbox/extras/views.py
  32. 15 17
      netbox/ipam/api/serializers.py
  33. 68 15
      netbox/ipam/filtersets.py
  34. 47 4
      netbox/ipam/forms/filtersets.py
  35. 1 1
      netbox/ipam/forms/models.py
  36. 1 1
      netbox/ipam/models/ip.py
  37. 15 0
      netbox/ipam/models/l2vpn.py
  38. 2 2
      netbox/ipam/signals.py
  39. 6 1
      netbox/ipam/tables/ip.py
  40. 10 1
      netbox/ipam/tables/l2vpn.py
  41. 21 0
      netbox/ipam/tests/test_filtersets.py
  42. 35 30
      netbox/ipam/views.py
  43. 4 6
      netbox/netbox/api/fields.py
  44. 15 1
      netbox/netbox/api/serializers/generic.py
  45. 12 13
      netbox/netbox/api/views.py
  46. 58 0
      netbox/netbox/denormalized.py
  47. 1 1
      netbox/netbox/settings.py
  48. 1 1
      netbox/templates/dcim/interface.html
  49. 5 5
      netbox/tenancy/views.py
  50. 14 20
      netbox/utilities/templates/helpers/utilization_graph.html
  51. 2 3
      netbox/utilities/utils.py
  52. 6 7
      netbox/virtualization/tables/virtualmachines.py
  53. 8 7
      netbox/virtualization/views.py
  54. 5 5
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.3-beta1
+      placeholder: v3.3-beta2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.3-beta1
+      placeholder: v3.3-beta2
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 17 - 0
docs/release-notes/version-3.2.md

@@ -2,6 +2,23 @@
 
 
 ## v3.2.8 (FUTURE)
 ## 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)
 ## v3.2.7 (2022-07-20)

+ 5 - 1
docs/release-notes/version-3.3.md

@@ -1,6 +1,6 @@
 # NetBox v3.3
 # NetBox v3.3
 
 
-## v3.3.0 (FUTURE)
+## v3.3-beta2 (2022-08-03)
 
 
 ### Breaking Changes
 ### 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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 * [#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
 ### REST API Changes
 
 

+ 10 - 5
netbox/circuits/views.py

@@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView):
         circuits = Circuit.objects.restrict(request.user, 'view').filter(
         circuits = Circuit.objects.restrict(request.user, 'view').filter(
             provider=instance
             provider=instance
         ).prefetch_related(
         ).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 = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
         circuits_table.configure(request)
         circuits_table.configure(request)
@@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView):
             Q(termination_a__provider_network=instance.pk) |
             Q(termination_a__provider_network=instance.pk) |
             Q(termination_z__provider_network=instance.pk)
             Q(termination_z__provider_network=instance.pk)
         ).prefetch_related(
         ).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 = tables.CircuitTable(circuits, user=request.user)
         circuits_table.configure(request)
         circuits_table.configure(request)
@@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
 
 
 class CircuitListView(generic.ObjectListView):
 class CircuitListView(generic.ObjectListView):
     queryset = Circuit.objects.prefetch_related(
     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 = filtersets.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
     filterset_form = forms.CircuitFilterForm
@@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView):
 
 
 class CircuitBulkEditView(generic.BulkEditView):
 class CircuitBulkEditView(generic.BulkEditView):
     queryset = Circuit.objects.prefetch_related(
     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
     filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     table = tables.CircuitTable
@@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView):
 
 
 class CircuitBulkDeleteView(generic.BulkDeleteView):
 class CircuitBulkDeleteView(generic.BulkDeleteView):
     queryset = Circuit.objects.prefetch_related(
     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
     filterset = filtersets.CircuitFilterSet
     table = tables.CircuitTable
     table = tables.CircuitTable

+ 10 - 11
netbox/dcim/api/views.py

@@ -1,5 +1,4 @@
 import socket
 import socket
-from collections import OrderedDict
 
 
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 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')
             return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml')
 
 
         # Serialize path objects, iterating over each three-tuple in the path
         # 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:
             else:
                 # Path is split; stop here
                 # Path is split; stop here
                 break
                 break
-            if cable is not None:
+            if cable:
                 cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data
                 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)
         return Response(path)
 
 
@@ -484,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
             return HttpResponseForbidden()
             return HttpResponseForbidden()
 
 
         napalm_methods = request.GET.getlist('method')
         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()
         config = get_config()
         username = config.NAPALM_USERNAME
         username = config.NAPALM_USERNAME

+ 17 - 1
netbox/dcim/apps.py

@@ -1,10 +1,26 @@
 from django.apps import AppConfig
 from django.apps import AppConfig
 
 
+from netbox import denormalized
+
 
 
 class DCIMConfig(AppConfig):
 class DCIMConfig(AppConfig):
     name = "dcim"
     name = "dcim"
     verbose_name = "DCIM"
     verbose_name = "DCIM"
 
 
     def ready(self):
     def ready(self):
-
         import dcim.signals
         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',
+        })

+ 21 - 8
netbox/dcim/forms/filtersets.py

@@ -291,7 +291,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
         ('User', ('user_id',)),
         ('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')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
     )
     )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
@@ -299,25 +299,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Region')
         label=_('Region')
     )
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
     site_id = DynamicModelMultipleChoiceField(
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         query_params={
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         },
         label=_('Site')
         label=_('Site')
     )
     )
-    site_group_id = DynamicModelMultipleChoiceField(
-        queryset=SiteGroup.objects.all(),
-        required=False,
-        label=_('Site group')
-    )
     location_id = DynamicModelMultipleChoiceField(
     location_id = DynamicModelMultipleChoiceField(
-        queryset=Location.objects.prefetch_related('site'),
+        queryset=Location.objects.all(),
         required=False,
         required=False,
+        query_params={
+            'site_id': '$site_id',
+        },
         label=_('Location'),
         label=_('Location'),
         null_option='None'
         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(
     user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
         queryset=User.objects.all(),
         required=False,
         required=False,

+ 1 - 1
netbox/dcim/forms/models.py

@@ -325,7 +325,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
     )
     )
 
 
     fieldsets = (
     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')),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
     )
 
 

+ 8 - 0
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.
     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: <code>[ge,xe]-0/0/[0-9]</code>.  {module} is accepted as a substitution for
+                the module bay position.
+                """
+    )
     device_type = DynamicModelChoiceField(
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
         required=False
         required=False

+ 13 - 11
netbox/dcim/models/cables.py

@@ -431,11 +431,7 @@ class CablePath(models.Model):
         """
         """
         Return the list of originating objects.
         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
     @property
     def destinations(self):
     def destinations(self):
@@ -444,11 +440,7 @@ class CablePath(models.Model):
         """
         """
         if not self.is_complete:
         if not self.is_complete:
             return []
             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
     @property
     def segment_count(self):
     def segment_count(self):
@@ -463,6 +455,9 @@ class CablePath(models.Model):
         """
         """
         from circuits.models import CircuitTermination
         from circuits.models import CircuitTermination
 
 
+        if not terminations:
+            return None
+
         # Ensure all originating terminations are attached to the same link
         # Ensure all originating terminations are attached to the same link
         if len(terminations) > 1:
         if len(terminations) > 1:
             assert all(t.link == terminations[0].link for t in 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
             # Step 6: Determine the "next hop" terminations, if applicable
+            if not remote_terminations:
+                break
+
             if isinstance(remote_terminations[0], FrontPort):
             if isinstance(remote_terminations[0], FrontPort):
                 # Follow FrontPorts to their corresponding RearPorts
                 # Follow FrontPorts to their corresponding RearPorts
                 rear_ports = RearPort.objects.filter(
                 rear_ports = RearPort.objects.filter(
@@ -640,7 +638,11 @@ class CablePath(models.Model):
             nodes = []
             nodes = []
             for node in step:
             for node in step:
                 ct_id, object_id = decompile_path_node(node)
                 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)
             path.append(nodes)
 
 
         return path
         return path

+ 85 - 1
netbox/dcim/models/device_component_templates.py

@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
         related_name='%(class)ss'
         related_name='%(class)ss'
     )
     )
     name = models.CharField(
     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(
     _name = NaturalOrderingField(
         target_field='name',
         target_field='name',
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
             **kwargs
             **kwargs
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 
 class ConsoleServerPortTemplate(ModularComponentTemplateModel):
 class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
             **kwargs
             **kwargs
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 
 class PowerPortTemplate(ModularComponentTemplateModel):
 class PowerPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
                     'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
                     '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):
 class PowerOutletTemplate(ModularComponentTemplateModel):
     """
     """
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class InterfaceTemplate(ModularComponentTemplateModel):
     """
     """
@@ -351,6 +390,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class FrontPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -424,6 +474,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             **kwargs
             **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):
 class RearPortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -463,6 +523,15 @@ class RearPortTemplate(ModularComponentTemplateModel):
             **kwargs
             **kwargs
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'type': self.type,
+            'positions': self.positions,
+            'label': self.label,
+            'description': self.description,
+        }
+
 
 
 class ModuleBayTemplate(ComponentTemplateModel):
 class ModuleBayTemplate(ComponentTemplateModel):
     """
     """
@@ -488,6 +557,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
             position=self.position
             position=self.position
         )
         )
 
 
+    def to_yaml(self):
+        return {
+            'name': self.name,
+            'label': self.label,
+            'position': self.position,
+            'description': self.description,
+        }
+
 
 
 class DeviceBayTemplate(ComponentTemplateModel):
 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."
                 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):
 class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
     """
     """

+ 7 - 4
netbox/dcim/models/device_components.py

@@ -212,10 +212,13 @@ class PathEndpoint(models.Model):
                 break
                 break
 
 
             path.extend(origin._path.path_objects)
             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
             # Check for a bridged relationship to continue the trace
             destinations = origin._path.destinations
             destinations = origin._path.destinations

+ 33 - 147
netbox/dcim/models/devices.py

@@ -1,5 +1,4 @@
 import decimal
 import decimal
-from collections import OrderedDict
 
 
 import yaml
 import yaml
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
@@ -164,117 +163,54 @@ class DeviceType(NetBoxModel):
         return reverse('dcim:devicetype', args=[self.pk])
         return reverse('dcim:devicetype', args=[self.pk])
 
 
     def to_yaml(self):
     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
         # Component templates
         if self.consoleporttemplates.exists():
         if self.consoleporttemplates.exists():
             data['console-ports'] = [
             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():
         if self.consoleserverporttemplates.exists():
             data['console-server-ports'] = [
             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():
         if self.powerporttemplates.exists():
             data['power-ports'] = [
             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():
         if self.poweroutlettemplates.exists():
             data['power-outlets'] = [
             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():
         if self.interfacetemplates.exists():
             data['interfaces'] = [
             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():
         if self.frontporttemplates.exists():
             data['front-ports'] = [
             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():
         if self.rearporttemplates.exists():
             data['rear-ports'] = [
             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():
         if self.modulebaytemplates.exists():
             data['module-bays'] = [
             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():
         if self.devicebaytemplates.exists():
             data['device-bays'] = [
             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)
         return yaml.dump(dict(data), sort_keys=False)
@@ -406,91 +342,41 @@ class ModuleType(NetBoxModel):
         return reverse('dcim:moduletype', args=[self.pk])
         return reverse('dcim:moduletype', args=[self.pk])
 
 
     def to_yaml(self):
     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
         # Component templates
         if self.consoleporttemplates.exists():
         if self.consoleporttemplates.exists():
             data['console-ports'] = [
             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():
         if self.consoleserverporttemplates.exists():
             data['console-server-ports'] = [
             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():
         if self.powerporttemplates.exists():
             data['power-ports'] = [
             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():
         if self.poweroutlettemplates.exists():
             data['power-outlets'] = [
             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():
         if self.interfacetemplates.exists():
             data['interfaces'] = [
             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():
         if self.frontporttemplates.exists():
             data['front-ports'] = [
             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():
         if self.rearporttemplates.exists():
             data['rear-ports'] = [
             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)
         return yaml.dump(dict(data), sort_keys=False)

+ 4 - 1
netbox/dcim/signals.py

@@ -116,7 +116,10 @@ def retrace_cable_paths(instance, **kwargs):
 @receiver(post_delete, sender=CableTermination)
 @receiver(post_delete, sender=CableTermination)
 def nullify_connected_endpoints(instance, **kwargs):
 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 = instance.termination_type.model_class()
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
+
+    for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
+        cablepath.retrace()

+ 8 - 3
netbox/dcim/svg/cables.py

@@ -362,21 +362,26 @@ class CableTraceSVG:
                     terminations = self.draw_terminations(far_ends)
                     terminations = self.draw_terminations(far_ends)
                     for term in terminations:
                     for term in terminations:
                         self.draw_fanout(term, cable)
                         self.draw_fanout(term, cable)
-                else:
+                elif far_ends:
                     self.draw_terminations(far_ends)
                     self.draw_terminations(far_ends)
+                else:
+                    # Link is not connected to anything
+                    break
 
 
                 # Far end parent
                 # Far end parent
                 parent_objects = set(end.parent_object for end in far_ends)
                 parent_objects = set(end.parent_object for end in far_ends)
                 self.draw_parent_objects(parent_objects)
                 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:
             elif far_ends:
 
 
                 # Attachment
                 # Attachment
                 attachment = self.draw_attachment()
                 attachment = self.draw_attachment()
                 self.connectors.append(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
         # Determine drawing size
         self.drawing = svgwrite.Drawing(
         self.drawing = svgwrite.Drawing(

+ 10 - 2
netbox/dcim/tables/modules.py

@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name='Module Type'
         verbose_name='Module Type'
     )
     )
+    manufacturer = tables.Column(
+        linkify=True
+    )
     instance_count = columns.LinkedCountColumn(
     instance_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
         url_params={'module_type_id': 'pk'},
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
     module_bay = tables.Column(
     module_bay = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    manufacturer = tables.Column(
+        accessor=tables.A('module_type__manufacturer'),
+        linkify=True
+    )
     module_type = tables.Column(
     module_type = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Module
         model = Module
         fields = (
         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 = (
         default_columns = (
-            'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
+            'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
         )
         )

+ 6 - 1
netbox/dcim/tables/power.py

@@ -21,6 +21,9 @@ class PowerPanelTable(NetBoxTable):
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    location = tables.Column(
+        linkify=True
+    )
     powerfeed_count = columns.LinkedCountColumn(
     powerfeed_count = columns.LinkedCountColumn(
         viewname='dcim:powerfeed_list',
         viewname='dcim:powerfeed_list',
         url_params={'power_panel_id': 'pk'},
         url_params={'power_panel_id': 'pk'},
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = PowerPanel
         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')
         default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
 
 
 
 

+ 5 - 1
netbox/dcim/tables/racks.py

@@ -109,6 +109,10 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
         accessor=Accessor('rack__site'),
         accessor=Accessor('rack__site'),
         linkify=True
         linkify=True
     )
     )
+    location = tables.Column(
+        accessor=Accessor('rack__location'),
+        linkify=True
+    )
     rack = tables.Column(
     rack = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -123,7 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = RackReservation
         model = RackReservation
         fields = (
         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',
             'actions', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

+ 4 - 4
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.0]['device'], device1)
         self.assertEqual(rack1_inventory_front[10.5]['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():
         for u in rack1_inventory_front.values():
             self.assertIsNone(u['device'])
             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.0]['device'], device1)
         self.assertEqual(rack1_inventory_rear[10.5]['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():
         for u in rack1_inventory_rear.values():
             self.assertIsNone(u['device'])
             self.assertIsNone(u['device'])
 
 

+ 3 - 2
netbox/dcim/utils.py

@@ -24,11 +24,12 @@ def object_to_path_node(obj):
 
 
 def path_node_to_object(repr):
 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_id, object_id = decompile_path_node(repr)
     ct = ContentType.objects.get_for_id(ct_id)
     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):
 def create_cablepath(terminations):

+ 66 - 68
netbox/dcim/views.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -324,7 +322,7 @@ class SiteListView(generic.ObjectListView):
 
 
 
 
 class SiteView(generic.ObjectView):
 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):
     def get_extra_context(self, request, instance):
         stats = {
         stats = {
@@ -359,7 +357,7 @@ class SiteView(generic.ObjectView):
             site=instance,
             site=instance,
             position__isnull=True,
             position__isnull=True,
             parent_bay__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)
         asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance)
         asn_count = asns.count()
         asn_count = asns.count()
@@ -391,14 +389,14 @@ class SiteBulkImportView(generic.BulkImportView):
 
 
 
 
 class SiteBulkEditView(generic.BulkEditView):
 class SiteBulkEditView(generic.BulkEditView):
-    queryset = Site.objects.prefetch_related('region', 'tenant')
+    queryset = Site.objects.all()
     filterset = filtersets.SiteFilterSet
     filterset = filtersets.SiteFilterSet
     table = tables.SiteTable
     table = tables.SiteTable
     form = forms.SiteBulkEditForm
     form = forms.SiteBulkEditForm
 
 
 
 
 class SiteBulkDeleteView(generic.BulkDeleteView):
 class SiteBulkDeleteView(generic.BulkDeleteView):
-    queryset = Site.objects.prefetch_related('region', 'tenant')
+    queryset = Site.objects.all()
     filterset = filtersets.SiteFilterSet
     filterset = filtersets.SiteFilterSet
     table = tables.SiteTable
     table = tables.SiteTable
 
 
@@ -454,7 +452,7 @@ class LocationView(generic.ObjectView):
             location=instance,
             location=instance,
             position__isnull=True,
             position__isnull=True,
             parent_bay__isnull=True
             parent_bay__isnull=True
-        ).prefetch_related('device_type__manufacturer')
+        ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
 
 
         return {
         return {
             'rack_count': rack_count,
             'rack_count': rack_count,
@@ -572,7 +570,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class RackListView(generic.ObjectListView):
 class RackListView(generic.ObjectListView):
-    queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
+    queryset = Rack.objects.annotate(
         device_count=count_related(Device, 'rack')
         device_count=count_related(Device, 'rack')
     )
     )
     filterset = filtersets.RackFilterSet
     filterset = filtersets.RackFilterSet
@@ -631,7 +629,7 @@ class RackView(generic.ObjectView):
             rack=instance,
             rack=instance,
             position__isnull=True,
             position__isnull=True,
             parent_bay__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)
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
 
 
@@ -682,14 +680,14 @@ class RackBulkImportView(generic.BulkImportView):
 
 
 
 
 class RackBulkEditView(generic.BulkEditView):
 class RackBulkEditView(generic.BulkEditView):
-    queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
+    queryset = Rack.objects.all()
     filterset = filtersets.RackFilterSet
     filterset = filtersets.RackFilterSet
     table = tables.RackTable
     table = tables.RackTable
     form = forms.RackBulkEditForm
     form = forms.RackBulkEditForm
 
 
 
 
 class RackBulkDeleteView(generic.BulkDeleteView):
 class RackBulkDeleteView(generic.BulkDeleteView):
-    queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role')
+    queryset = Rack.objects.all()
     filterset = filtersets.RackFilterSet
     filterset = filtersets.RackFilterSet
     table = tables.RackTable
     table = tables.RackTable
 
 
@@ -706,7 +704,7 @@ class RackReservationListView(generic.ObjectListView):
 
 
 
 
 class RackReservationView(generic.ObjectView):
 class RackReservationView(generic.ObjectView):
-    queryset = RackReservation.objects.prefetch_related('rack')
+    queryset = RackReservation.objects.all()
 
 
 
 
 class RackReservationEditView(generic.ObjectEditView):
 class RackReservationEditView(generic.ObjectEditView):
@@ -742,14 +740,14 @@ class RackReservationImportView(generic.BulkImportView):
 
 
 
 
 class RackReservationBulkEditView(generic.BulkEditView):
 class RackReservationBulkEditView(generic.BulkEditView):
-    queryset = RackReservation.objects.prefetch_related('rack', 'user')
+    queryset = RackReservation.objects.all()
     filterset = filtersets.RackReservationFilterSet
     filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     table = tables.RackReservationTable
     form = forms.RackReservationBulkEditForm
     form = forms.RackReservationBulkEditForm
 
 
 
 
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
 class RackReservationBulkDeleteView(generic.BulkDeleteView):
-    queryset = RackReservation.objects.prefetch_related('rack', 'user')
+    queryset = RackReservation.objects.all()
     filterset = filtersets.RackReservationFilterSet
     filterset = filtersets.RackReservationFilterSet
     table = tables.RackReservationTable
     table = tables.RackReservationTable
 
 
@@ -831,7 +829,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class DeviceTypeListView(generic.ObjectListView):
 class DeviceTypeListView(generic.ObjectListView):
-    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+    queryset = DeviceType.objects.annotate(
         instance_count=count_related(Device, 'device_type')
         instance_count=count_related(Device, 'device_type')
     )
     )
     filterset = filtersets.DeviceTypeFilterSet
     filterset = filtersets.DeviceTypeFilterSet
@@ -840,7 +838,7 @@ class DeviceTypeListView(generic.ObjectListView):
 
 
 
 
 class DeviceTypeView(generic.ObjectView):
 class DeviceTypeView(generic.ObjectView):
-    queryset = DeviceType.objects.prefetch_related('manufacturer')
+    queryset = DeviceType.objects.all()
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
         instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count()
@@ -945,18 +943,18 @@ class DeviceTypeImportView(generic.ObjectImportView):
     ]
     ]
     queryset = DeviceType.objects.all()
     queryset = DeviceType.objects.all()
     model_form = forms.DeviceTypeImportForm
     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):
     def prep_related_object_data(self, parent, data):
         data.update({'device_type': parent})
         data.update({'device_type': parent})
@@ -964,7 +962,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
 
 
 
 
 class DeviceTypeBulkEditView(generic.BulkEditView):
 class DeviceTypeBulkEditView(generic.BulkEditView):
-    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+    queryset = DeviceType.objects.annotate(
         instance_count=count_related(Device, 'device_type')
         instance_count=count_related(Device, 'device_type')
     )
     )
     filterset = filtersets.DeviceTypeFilterSet
     filterset = filtersets.DeviceTypeFilterSet
@@ -973,7 +971,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
 
 
 
 
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
 class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
-    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+    queryset = DeviceType.objects.annotate(
         instance_count=count_related(Device, 'device_type')
         instance_count=count_related(Device, 'device_type')
     )
     )
     filterset = filtersets.DeviceTypeFilterSet
     filterset = filtersets.DeviceTypeFilterSet
@@ -985,7 +983,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class ModuleTypeListView(generic.ObjectListView):
 class ModuleTypeListView(generic.ObjectListView):
-    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+    queryset = ModuleType.objects.annotate(
         instance_count=count_related(Module, 'module_type')
         instance_count=count_related(Module, 'module_type')
     )
     )
     filterset = filtersets.ModuleTypeFilterSet
     filterset = filtersets.ModuleTypeFilterSet
@@ -994,7 +992,7 @@ class ModuleTypeListView(generic.ObjectListView):
 
 
 
 
 class ModuleTypeView(generic.ObjectView):
 class ModuleTypeView(generic.ObjectView):
-    queryset = ModuleType.objects.prefetch_related('manufacturer')
+    queryset = ModuleType.objects.all()
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
         instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
@@ -1075,15 +1073,15 @@ class ModuleTypeImportView(generic.ObjectImportView):
     ]
     ]
     queryset = ModuleType.objects.all()
     queryset = ModuleType.objects.all()
     model_form = forms.ModuleTypeImportForm
     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):
     def prep_related_object_data(self, parent, data):
         data.update({'module_type': parent})
         data.update({'module_type': parent})
@@ -1091,7 +1089,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
 
 
 
 
 class ModuleTypeBulkEditView(generic.BulkEditView):
 class ModuleTypeBulkEditView(generic.BulkEditView):
-    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+    queryset = ModuleType.objects.annotate(
         instance_count=count_related(Module, 'module_type')
         instance_count=count_related(Module, 'module_type')
     )
     )
     filterset = filtersets.ModuleTypeFilterSet
     filterset = filtersets.ModuleTypeFilterSet
@@ -1100,7 +1098,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
 
 
 
 
 class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
 class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
-    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+    queryset = ModuleType.objects.annotate(
         instance_count=count_related(Module, 'module_type')
         instance_count=count_related(Module, 'module_type')
     )
     )
     filterset = filtersets.ModuleTypeFilterSet
     filterset = filtersets.ModuleTypeFilterSet
@@ -1611,9 +1609,7 @@ class DeviceListView(generic.ObjectListView):
 
 
 
 
 class DeviceView(generic.ObjectView):
 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):
     def get_extra_context(self, request, instance):
         # VirtualChassis members
         # VirtualChassis members
@@ -1790,14 +1786,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView):
 
 
 
 
 class DeviceBulkEditView(generic.BulkEditView):
 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
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
     form = forms.DeviceBulkEditForm
     form = forms.DeviceBulkEditForm
 
 
 
 
 class DeviceBulkDeleteView(generic.BulkDeleteView):
 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
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     table = tables.DeviceTable
 
 
@@ -1807,7 +1803,7 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class ModuleListView(generic.ObjectListView):
 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 = filtersets.ModuleFilterSet
     filterset_form = forms.ModuleFilterForm
     filterset_form = forms.ModuleFilterForm
     table = tables.ModuleTable
     table = tables.ModuleTable
@@ -1833,14 +1829,14 @@ class ModuleBulkImportView(generic.BulkImportView):
 
 
 
 
 class ModuleBulkEditView(generic.BulkEditView):
 class ModuleBulkEditView(generic.BulkEditView):
-    queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+    queryset = Module.objects.prefetch_related('module_type__manufacturer')
     filterset = filtersets.ModuleFilterSet
     filterset = filtersets.ModuleFilterSet
     table = tables.ModuleTable
     table = tables.ModuleTable
     form = forms.ModuleBulkEditForm
     form = forms.ModuleBulkEditForm
 
 
 
 
 class ModuleBulkDeleteView(generic.BulkDeleteView):
 class ModuleBulkDeleteView(generic.BulkDeleteView):
-    queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+    queryset = Module.objects.prefetch_related('module_type__manufacturer')
     filterset = filtersets.ModuleFilterSet
     filterset = filtersets.ModuleFilterSet
     table = tables.ModuleTable
     table = tables.ModuleTable
 
 
@@ -2566,7 +2562,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
 
 
 
 
 class InventoryItemBulkEditView(generic.BulkEditView):
 class InventoryItemBulkEditView(generic.BulkEditView):
-    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
+    queryset = InventoryItem.objects.all()
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     form = forms.InventoryItemBulkEditForm
     form = forms.InventoryItemBulkEditForm
@@ -2577,7 +2573,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
 
 
 
 
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
-    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
+    queryset = InventoryItem.objects.all()
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     template_name = 'dcim/inventoryitem_bulk_delete.html'
 
 
@@ -2867,14 +2863,20 @@ class CableBulkImportView(generic.BulkImportView):
 
 
 
 
 class CableBulkEditView(generic.BulkEditView):
 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
     filterset = filtersets.CableFilterSet
     table = tables.CableTable
     table = tables.CableTable
     form = forms.CableBulkEditForm
     form = forms.CableBulkEditForm
 
 
 
 
 class CableBulkDeleteView(generic.BulkDeleteView):
 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
     filterset = filtersets.CableFilterSet
     table = tables.CableTable
     table = tables.CableTable
 
 
@@ -2930,7 +2932,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
 #
 #
 
 
 class VirtualChassisListView(generic.ObjectListView):
 class VirtualChassisListView(generic.ObjectListView):
-    queryset = VirtualChassis.objects.prefetch_related('master').annotate(
+    queryset = VirtualChassis.objects.annotate(
         member_count=count_related(Device, 'virtual_chassis')
         member_count=count_related(Device, 'virtual_chassis')
     )
     )
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
@@ -3158,9 +3160,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class PowerPanelListView(generic.ObjectListView):
 class PowerPanelListView(generic.ObjectListView):
-    queryset = PowerPanel.objects.prefetch_related(
-        'site', 'location'
-    ).annotate(
+    queryset = PowerPanel.objects.annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )
     filterset = filtersets.PowerPanelFilterSet
     filterset = filtersets.PowerPanelFilterSet
@@ -3169,10 +3169,10 @@ class PowerPanelListView(generic.ObjectListView):
 
 
 
 
 class PowerPanelView(generic.ObjectView):
 class PowerPanelView(generic.ObjectView):
-    queryset = PowerPanel.objects.prefetch_related('site', 'location')
+    queryset = PowerPanel.objects.all()
 
 
     def get_extra_context(self, request, instance):
     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(
         powerfeed_table = tables.PowerFeedTable(
             data=power_feeds,
             data=power_feeds,
             orderable=False
             orderable=False
@@ -3202,16 +3202,14 @@ class PowerPanelBulkImportView(generic.BulkImportView):
 
 
 
 
 class PowerPanelBulkEditView(generic.BulkEditView):
 class PowerPanelBulkEditView(generic.BulkEditView):
-    queryset = PowerPanel.objects.prefetch_related('site', 'location')
+    queryset = PowerPanel.objects.all()
     filterset = filtersets.PowerPanelFilterSet
     filterset = filtersets.PowerPanelFilterSet
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
     form = forms.PowerPanelBulkEditForm
     form = forms.PowerPanelBulkEditForm
 
 
 
 
 class PowerPanelBulkDeleteView(generic.BulkDeleteView):
 class PowerPanelBulkDeleteView(generic.BulkDeleteView):
-    queryset = PowerPanel.objects.prefetch_related(
-        'site', 'location'
-    ).annotate(
+    queryset = PowerPanel.objects.annotate(
         powerfeed_count=count_related(PowerFeed, 'power_panel')
         powerfeed_count=count_related(PowerFeed, 'power_panel')
     )
     )
     filterset = filtersets.PowerPanelFilterSet
     filterset = filtersets.PowerPanelFilterSet
@@ -3230,7 +3228,7 @@ class PowerFeedListView(generic.ObjectListView):
 
 
 
 
 class PowerFeedView(generic.ObjectView):
 class PowerFeedView(generic.ObjectView):
-    queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
+    queryset = PowerFeed.objects.all()
 
 
 
 
 class PowerFeedEditView(generic.ObjectEditView):
 class PowerFeedEditView(generic.ObjectEditView):
@@ -3249,7 +3247,7 @@ class PowerFeedBulkImportView(generic.BulkImportView):
 
 
 
 
 class PowerFeedBulkEditView(generic.BulkEditView):
 class PowerFeedBulkEditView(generic.BulkEditView):
-    queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
+    queryset = PowerFeed.objects.all()
     filterset = filtersets.PowerFeedFilterSet
     filterset = filtersets.PowerFeedFilterSet
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
     form = forms.PowerFeedBulkEditForm
     form = forms.PowerFeedBulkEditForm
@@ -3260,6 +3258,6 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView):
 
 
 
 
 class PowerFeedBulkDeleteView(generic.BulkDeleteView):
 class PowerFeedBulkDeleteView(generic.BulkDeleteView):
-    queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
+    queryset = PowerFeed.objects.all()
     filterset = filtersets.PowerFeedFilterSet
     filterset = filtersets.PowerFeedFilterSet
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable

+ 1 - 0
netbox/extras/forms/models.py

@@ -136,6 +136,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
             'http_method': StaticSelect(),
             'http_method': StaticSelect(),
             'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
             'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
             'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
             'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
+            'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
         }
         }
 
 
 
 

+ 1 - 1
netbox/extras/models/customfields.py

@@ -181,7 +181,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
             model = ct.model_class()
             model = ct.model_class()
             instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
             instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
             for instance in instances:
             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)
             model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
 
 
     def rename_object_data(self, old_name, new_name):
     def rename_object_data(self, old_name, new_name):

+ 1 - 0
netbox/extras/registry.py

@@ -28,3 +28,4 @@ registry = Registry()
 registry['model_features'] = {
 registry['model_features'] = {
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
 }
 }
+registry['denormalized_fields'] = collections.defaultdict(list)

+ 8 - 9
netbox/extras/reports.py

@@ -3,7 +3,6 @@ import inspect
 import logging
 import logging
 import pkgutil
 import pkgutil
 import traceback
 import traceback
-from collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
 from django.utils import timezone
 from django.utils import timezone
@@ -114,7 +113,7 @@ class Report(object):
 
 
     def __init__(self):
     def __init__(self):
 
 
-        self._results = OrderedDict()
+        self._results = {}
         self.active_test = None
         self.active_test = None
         self.failed = False
         self.failed = False
 
 
@@ -125,13 +124,13 @@ class Report(object):
         for method in dir(self):
         for method in dir(self):
             if method.startswith('test_') and callable(getattr(self, method)):
             if method.startswith('test_') and callable(getattr(self, method)):
                 test_methods.append(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:
         if not test_methods:
             raise Exception("A report must contain at least one test method.")
             raise Exception("A report must contain at least one test method.")
         self.test_methods = test_methods
         self.test_methods = test_methods

+ 2 - 3
netbox/extras/scripts.py

@@ -6,7 +6,6 @@ import pkgutil
 import sys
 import sys
 import traceback
 import traceback
 import threading
 import threading
-from collections import OrderedDict
 
 
 import yaml
 import yaml
 from django import forms
 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-
     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.
     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
     # Iterate through all modules within the scripts path. These are the user-created files in which reports are
     # defined.
     # defined.
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
     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'):
         if use_names and hasattr(module, 'name'):
             module_name = module.name
             module_name = module.name
-        module_scripts = OrderedDict()
+        module_scripts = {}
         script_order = getattr(module, "script_order", ())
         script_order = getattr(module, "script_order", ())
         ordered_scripts = [cls for cls in script_order if is_script(cls)]
         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]
         unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]

+ 1 - 3
netbox/extras/templatetags/custom_links.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 from django import template
 from django import template
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.utils.safestring import mark_safe
 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
         'perms': context['perms'],  # django.contrib.auth.context_processors.auth
     }
     }
     template_code = ''
     template_code = ''
-    group_names = OrderedDict()
+    group_names = {}
 
 
     for cl in custom_links:
     for cl in custom_links:
 
 

+ 1 - 1
netbox/extras/tests/test_customfields.py

@@ -992,7 +992,7 @@ class CustomFieldModelTest(TestCase):
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             site.clean()
             site.clean()
 
 
-        del(site.cf['bar'])
+        del site.cf['bar']
         site.clean()
         site.clean()
 
 
     def test_missing_required_field(self):
     def test_missing_required_field(self):

+ 1 - 1
netbox/extras/tests/test_registry.py

@@ -30,4 +30,4 @@ class RegistryTest(TestCase):
         reg['foo'] = 123
         reg['foo'] = 123
 
 
         with self.assertRaises(TypeError):
         with self.assertRaises(TypeError):
-            del(reg['foo'])
+            del reg['foo']

+ 2 - 2
netbox/extras/views.py

@@ -492,14 +492,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView):
 
 
 
 
 class JournalEntryBulkEditView(generic.BulkEditView):
 class JournalEntryBulkEditView(generic.BulkEditView):
-    queryset = JournalEntry.objects.prefetch_related('created_by')
+    queryset = JournalEntry.objects.all()
     filterset = filtersets.JournalEntryFilterSet
     filterset = filtersets.JournalEntryFilterSet
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
     form = forms.JournalEntryBulkEditForm
     form = forms.JournalEntryBulkEditForm
 
 
 
 
 class JournalEntryBulkDeleteView(generic.BulkDeleteView):
 class JournalEntryBulkDeleteView(generic.BulkDeleteView):
-    queryset = JournalEntry.objects.prefetch_related('created_by')
+    queryset = JournalEntry.objects.all()
     filterset = filtersets.JournalEntryFilterSet
     filterset = filtersets.JournalEntryFilterSet
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
 
 

+ 15 - 17
netbox/ipam/api/serializers.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
@@ -227,13 +225,13 @@ class AvailableVLANSerializer(serializers.Serializer):
     group = NestedVLANGroupSerializer(read_only=True)
     group = NestedVLANGroupSerializer(read_only=True)
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
-        return OrderedDict([
-            ('vid', instance),
-            ('group', NestedVLANGroupSerializer(
+        return {
+            'vid': instance,
+            'group': NestedVLANGroupSerializer(
                 self.context['group'],
                 self.context['group'],
                 context={'request': self.context['request']}
                 context={'request': self.context['request']}
-            ).data),
-        ])
+            ).data,
+        }
 
 
 
 
 class CreateAvailableVLANSerializer(NetBoxModelSerializer):
 class CreateAvailableVLANSerializer(NetBoxModelSerializer):
@@ -318,11 +316,11 @@ class AvailablePrefixSerializer(serializers.Serializer):
             vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
             vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
         else:
         else:
             vrf = None
             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
             vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
         else:
         else:
             vrf = None
             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,
+        }
 
 
 
 
 #
 #

+ 68 - 15
netbox/ipam/filtersets.py

@@ -980,21 +980,65 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='L2VPN (slug)',
         label='L2VPN (slug)',
     )
     )
-    device = MultiValueCharFilter(
-        method='filter_device',
-        field_name='name',
-        label='Device (name)',
+    region = MultiValueCharFilter(
+        method='filter_region',
+        field_name='slug',
+        label='Region (slug)',
     )
     )
-    device_id = MultiValueNumberFilter(
-        method='filter_device',
+    region_id = MultiValueNumberFilter(
+        method='filter_region',
         field_name='pk',
         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 = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface__device',
+        queryset=Device.objects.all(),
         label='Device (ID)',
         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(
     interface_id = django_filters.ModelMultipleChoiceFilter(
         field_name='interface',
         field_name='interface',
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         label='Interface (ID)',
         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(
     vminterface_id = django_filters.ModelMultipleChoiceFilter(
         field_name='vminterface',
         field_name='vminterface',
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
@@ -1027,13 +1071,22 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
         qs_filter = Q(l2vpn__name__icontains=value)
         qs_filter = Q(l2vpn__name__icontains=value)
         return queryset.filter(qs_filter)
         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

+ 47 - 4
netbox/ipam/forms/filtersets.py

@@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import TenancyFilterForm
 from tenancy.forms import TenancyFilterForm
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     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
 from virtualization.models import VirtualMachine
 
 
@@ -508,7 +508,8 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
 class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
 class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
     model = L2VPNTermination
     model = L2VPNTermination
     fieldsets = (
     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(
     l2vpn_id = DynamicModelChoiceField(
         queryset=L2VPN.objects.all(),
         queryset=L2VPN.objects.all(),
@@ -516,7 +517,49 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm):
         label='L2VPN'
         label='L2VPN'
     )
     )
     assigned_object_type_id = ContentTypeMultipleChoiceField(
     assigned_object_type_id = ContentTypeMultipleChoiceField(
-        queryset=ContentType.objects.all(),
+        queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS),
         required=False,
         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')
     )
     )

+ 1 - 1
netbox/ipam/forms/models.py

@@ -851,7 +851,7 @@ class ServiceCreateForm(ServiceForm):
         # Fields which may be populated from a ServiceTemplate are not required
         # Fields which may be populated from a ServiceTemplate are not required
         for field in ('name', 'protocol', 'ports'):
         for field in ('name', 'protocol', 'ports'):
             self.fields[field].required = False
             self.fields[field].required = False
-            del(self.fields[field].widget.attrs['required'])
+            del self.fields[field].widget.attrs['required']
 
 
     def clean(self):
     def clean(self):
         if self.cleaned_data['service_template']:
         if self.cleaned_data['service_template']:

+ 1 - 1
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
         # Cache the original prefix and VRF so we can check if they have changed on post_save
         self._prefix = self.prefix
         self._prefix = self.prefix
-        self._vrf = self.vrf
+        self._vrf_id = self.vrf_id
 
 
     def __str__(self):
     def __str__(self):
         return str(self.prefix)
         return str(self.prefix)

+ 15 - 0
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'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already '
                     f'defined.'
                     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

+ 2 - 2
netbox/ipam/signals.py

@@ -30,14 +30,14 @@ def update_children_depth(prefix):
 def handle_prefix_saved(instance, created, **kwargs):
 def handle_prefix_saved(instance, created, **kwargs):
 
 
     # Prefix has changed (or new instance has been created)
     # 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_parents_children(instance)
         update_children_depth(instance)
         update_children_depth(instance)
 
 
         # If this is not a new prefix, clean up parent/children of previous prefix
         # If this is not a new prefix, clean up parent/children of previous prefix
         if not created:
         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_parents_children(old_prefix)
             update_children_depth(old_prefix)
             update_children_depth(old_prefix)
 
 

+ 6 - 1
netbox/ipam/tables/ip.py

@@ -369,6 +369,11 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
         orderable=False,
         orderable=False,
         verbose_name='NAT (Inside)'
         verbose_name='NAT (Inside)'
     )
     )
+    nat_outside = tables.Column(
+        linkify=True,
+        orderable=False,
+        verbose_name='NAT (Outside)'
+    )
     assigned = columns.BooleanColumn(
     assigned = columns.BooleanColumn(
         accessor='assigned_object_id',
         accessor='assigned_object_id',
         linkify=True,
         linkify=True,
@@ -381,7 +386,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = (
         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',
             'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

+ 10 - 1
netbox/ipam/tables/l2vpn.py

@@ -53,8 +53,17 @@ class L2VPNTerminationTable(NetBoxTable):
         linkify=True,
         linkify=True,
         orderable=False
         orderable=False
     )
     )
+    assigned_object_parent = tables.Column(
+        linkify=True,
+        orderable=False
+    )
+    assigned_object_site = tables.Column(
+        linkify=True,
+        orderable=False
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = L2VPNTermination
         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')
         default_columns = ('pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'actions')

+ 21 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1600,3 +1600,24 @@ class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'vlan': ['VLAN 1', 'VLAN 2']}
         params = {'vlan': ['VLAN 1', 'VLAN 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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)

+ 35 - 30
netbox/ipam/views.py

@@ -40,11 +40,11 @@ class VRFView(generic.ObjectView):
         ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count()
         ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count()
 
 
         import_targets_table = tables.RouteTargetTable(
         import_targets_table = tables.RouteTargetTable(
-            instance.import_targets.prefetch_related('tenant'),
+            instance.import_targets.all(),
             orderable=False
             orderable=False
         )
         )
         export_targets_table = tables.RouteTargetTable(
         export_targets_table = tables.RouteTargetTable(
-            instance.export_targets.prefetch_related('tenant'),
+            instance.export_targets.all(),
             orderable=False
             orderable=False
         )
         )
 
 
@@ -72,14 +72,14 @@ class VRFBulkImportView(generic.BulkImportView):
 
 
 
 
 class VRFBulkEditView(generic.BulkEditView):
 class VRFBulkEditView(generic.BulkEditView):
-    queryset = VRF.objects.prefetch_related('tenant')
+    queryset = VRF.objects.all()
     filterset = filtersets.VRFFilterSet
     filterset = filtersets.VRFFilterSet
     table = tables.VRFTable
     table = tables.VRFTable
     form = forms.VRFBulkEditForm
     form = forms.VRFBulkEditForm
 
 
 
 
 class VRFBulkDeleteView(generic.BulkDeleteView):
 class VRFBulkDeleteView(generic.BulkDeleteView):
-    queryset = VRF.objects.prefetch_related('tenant')
+    queryset = VRF.objects.all()
     filterset = filtersets.VRFFilterSet
     filterset = filtersets.VRFFilterSet
     table = tables.VRFTable
     table = tables.VRFTable
 
 
@@ -100,11 +100,11 @@ class RouteTargetView(generic.ObjectView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         importing_vrfs_table = tables.VRFTable(
         importing_vrfs_table = tables.VRFTable(
-            instance.importing_vrfs.prefetch_related('tenant'),
+            instance.importing_vrfs.all(),
             orderable=False
             orderable=False
         )
         )
         exporting_vrfs_table = tables.VRFTable(
         exporting_vrfs_table = tables.VRFTable(
-            instance.exporting_vrfs.prefetch_related('tenant'),
+            instance.exporting_vrfs.all(),
             orderable=False
             orderable=False
         )
         )
 
 
@@ -130,14 +130,14 @@ class RouteTargetBulkImportView(generic.BulkImportView):
 
 
 
 
 class RouteTargetBulkEditView(generic.BulkEditView):
 class RouteTargetBulkEditView(generic.BulkEditView):
-    queryset = RouteTarget.objects.prefetch_related('tenant')
+    queryset = RouteTarget.objects.all()
     filterset = filtersets.RouteTargetFilterSet
     filterset = filtersets.RouteTargetFilterSet
     table = tables.RouteTargetTable
     table = tables.RouteTargetTable
     form = forms.RouteTargetBulkEditForm
     form = forms.RouteTargetBulkEditForm
 
 
 
 
 class RouteTargetBulkDeleteView(generic.BulkDeleteView):
 class RouteTargetBulkDeleteView(generic.BulkDeleteView):
-    queryset = RouteTarget.objects.prefetch_related('tenant')
+    queryset = RouteTarget.objects.all()
     filterset = filtersets.RouteTargetFilterSet
     filterset = filtersets.RouteTargetFilterSet
     table = tables.RouteTargetTable
     table = tables.RouteTargetTable
 
 
@@ -334,14 +334,18 @@ class AggregateBulkImportView(generic.BulkImportView):
 
 
 
 
 class AggregateBulkEditView(generic.BulkEditView):
 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
     filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     table = tables.AggregateTable
     form = forms.AggregateBulkEditForm
     form = forms.AggregateBulkEditForm
 
 
 
 
 class AggregateBulkDeleteView(generic.BulkDeleteView):
 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
     filterset = filtersets.AggregateFilterSet
     table = tables.AggregateTable
     table = tables.AggregateTable
 
 
@@ -417,7 +421,7 @@ class PrefixListView(generic.ObjectListView):
 
 
 
 
 class PrefixView(generic.ObjectView):
 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):
     def get_extra_context(self, request, instance):
         try:
         try:
@@ -433,7 +437,7 @@ class PrefixView(generic.ObjectView):
         ).filter(
         ).filter(
             prefix__net_contains=str(instance.prefix)
             prefix__net_contains=str(instance.prefix)
         ).prefetch_related(
         ).prefetch_related(
-            'site', 'role', 'tenant'
+            'site', 'role', 'tenant', 'vlan',
         )
         )
         parent_prefix_table = tables.PrefixTable(
         parent_prefix_table = tables.PrefixTable(
             list(parent_prefixes),
             list(parent_prefixes),
@@ -447,7 +451,7 @@ class PrefixView(generic.ObjectView):
         ).exclude(
         ).exclude(
             pk=instance.pk
             pk=instance.pk
         ).prefetch_related(
         ).prefetch_related(
-            'site', 'role'
+            'site', 'role', 'tenant', 'vlan',
         )
         )
         duplicate_prefix_table = tables.PrefixTable(
         duplicate_prefix_table = tables.PrefixTable(
             list(duplicate_prefixes),
             list(duplicate_prefixes),
@@ -500,7 +504,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
 
 
     def get_children(self, request, parent):
     def get_children(self, request, parent):
         return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
         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):
     def get_extra_context(self, request, instance):
@@ -519,7 +523,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     template_name = 'ipam/prefix/ip_addresses.html'
     template_name = 'ipam/prefix/ip_addresses.html'
 
 
     def get_children(self, request, parent):
     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):
     def prep_table_data(self, request, queryset, parent):
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
@@ -552,14 +556,14 @@ class PrefixBulkImportView(generic.BulkImportView):
 
 
 
 
 class PrefixBulkEditView(generic.BulkEditView):
 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
     filterset = filtersets.PrefixFilterSet
     table = tables.PrefixTable
     table = tables.PrefixTable
     form = forms.PrefixBulkEditForm
     form = forms.PrefixBulkEditForm
 
 
 
 
 class PrefixBulkDeleteView(generic.BulkDeleteView):
 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
     filterset = filtersets.PrefixFilterSet
     table = tables.PrefixTable
     table = tables.PrefixTable
 
 
@@ -611,14 +615,14 @@ class IPRangeBulkImportView(generic.BulkImportView):
 
 
 
 
 class IPRangeBulkEditView(generic.BulkEditView):
 class IPRangeBulkEditView(generic.BulkEditView):
-    queryset = IPRange.objects.prefetch_related('vrf', 'tenant')
+    queryset = IPRange.objects.all()
     filterset = filtersets.IPRangeFilterSet
     filterset = filtersets.IPRangeFilterSet
     table = tables.IPRangeTable
     table = tables.IPRangeTable
     form = forms.IPRangeBulkEditForm
     form = forms.IPRangeBulkEditForm
 
 
 
 
 class IPRangeBulkDeleteView(generic.BulkDeleteView):
 class IPRangeBulkDeleteView(generic.BulkDeleteView):
-    queryset = IPRange.objects.prefetch_related('vrf', 'tenant')
+    queryset = IPRange.objects.all()
     filterset = filtersets.IPRangeFilterSet
     filterset = filtersets.IPRangeFilterSet
     table = tables.IPRangeTable
     table = tables.IPRangeTable
 
 
@@ -789,14 +793,14 @@ class IPAddressBulkImportView(generic.BulkImportView):
 
 
 
 
 class IPAddressBulkEditView(generic.BulkEditView):
 class IPAddressBulkEditView(generic.BulkEditView):
-    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant')
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
 
 
 
 
 class IPAddressBulkDeleteView(generic.BulkDeleteView):
 class IPAddressBulkDeleteView(generic.BulkDeleteView):
-    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant')
     filterset = filtersets.IPAddressFilterSet
     filterset = filtersets.IPAddressFilterSet
     table = tables.IPAddressTable
     table = tables.IPAddressTable
 
 
@@ -819,7 +823,8 @@ class VLANGroupView(generic.ObjectView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
         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')
         ).order_by('vid')
         vlans_count = vlans.count()
         vlans_count = vlans.count()
         vlans = add_available_vlans(vlans, vlan_group=instance)
         vlans = add_available_vlans(vlans, vlan_group=instance)
@@ -894,7 +899,7 @@ class FHRPGroupView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get assigned IP addresses
         # Get assigned IP addresses
         ipaddress_table = tables.AssignedIPAddressesTable(
         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
             orderable=False
         )
         )
 
 
@@ -984,11 +989,11 @@ class VLANListView(generic.ObjectListView):
 
 
 
 
 class VLANView(generic.ObjectView):
 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):
     def get_extra_context(self, request, instance):
         prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
         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)
         prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
 
 
@@ -1046,14 +1051,14 @@ class VLANBulkImportView(generic.BulkImportView):
 
 
 
 
 class VLANBulkEditView(generic.BulkEditView):
 class VLANBulkEditView(generic.BulkEditView):
-    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
+    queryset = VLAN.objects.all()
     filterset = filtersets.VLANFilterSet
     filterset = filtersets.VLANFilterSet
     table = tables.VLANTable
     table = tables.VLANTable
     form = forms.VLANBulkEditForm
     form = forms.VLANBulkEditForm
 
 
 
 
 class VLANBulkDeleteView(generic.BulkDeleteView):
 class VLANBulkDeleteView(generic.BulkDeleteView):
-    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
+    queryset = VLAN.objects.all()
     filterset = filtersets.VLANFilterSet
     filterset = filtersets.VLANFilterSet
     table = tables.VLANTable
     table = tables.VLANTable
 
 
@@ -1106,14 +1111,14 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
 #
 #
 
 
 class ServiceListView(generic.ObjectListView):
 class ServiceListView(generic.ObjectListView):
-    queryset = Service.objects.all()
+    queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filtersets.ServiceFilterSet
     filterset = filtersets.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
     table = tables.ServiceTable
 
 
 
 
 class ServiceView(generic.ObjectView):
 class ServiceView(generic.ObjectView):
-    queryset = Service.objects.prefetch_related('ipaddresses')
+    queryset = Service.objects.all()
 
 
 
 
 class ServiceCreateView(generic.ObjectEditView):
 class ServiceCreateView(generic.ObjectEditView):
@@ -1123,7 +1128,7 @@ class ServiceCreateView(generic.ObjectEditView):
 
 
 
 
 class ServiceEditView(generic.ObjectEditView):
 class ServiceEditView(generic.ObjectEditView):
-    queryset = Service.objects.prefetch_related('ipaddresses')
+    queryset = Service.objects.all()
     form = forms.ServiceForm
     form = forms.ServiceForm
     template_name = 'ipam/service_edit.html'
     template_name = 'ipam/service_edit.html'
 
 

+ 4 - 6
netbox/netbox/api/fields.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 from rest_framework import serializers
 from rest_framework import serializers
@@ -48,10 +46,10 @@ class ChoiceField(serializers.Field):
     def to_representation(self, obj):
     def to_representation(self, obj):
         if obj == '':
         if obj == '':
             return None
             return None
-        return OrderedDict([
-            ('value', obj),
-            ('label', self._choices[obj])
-        ])
+        return {
+            'value': obj,
+            'label': self._choices[obj],
+        }
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         if data == '':
         if data == '':

+ 15 - 1
netbox/netbox/api/serializers/generic.py

@@ -1,7 +1,10 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from netbox.api.fields import ContentTypeField
 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
 from utilities.utils import content_type_identifier
 
 
 __all__ = (
 __all__ = (
@@ -17,6 +20,7 @@ class GenericObjectSerializer(serializers.Serializer):
         queryset=ContentType.objects.all()
         queryset=ContentType.objects.all()
     )
     )
     object_id = serializers.IntegerField()
     object_id = serializers.IntegerField()
+    object = serializers.SerializerMethodField(read_only=True)
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         data = super().to_internal_value(data)
         data = super().to_internal_value(data)
@@ -25,7 +29,17 @@ class GenericObjectSerializer(serializers.Serializer):
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
         ct = ContentType.objects.get_for_model(instance)
         ct = ContentType.objects.get_for_model(instance)
-        return {
+        data = {
             'object_type': content_type_identifier(ct),
             'object_type': content_type_identifier(ct),
             'object_id': instance.pk,
             '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

+ 12 - 13
netbox/netbox/api/views.py

@@ -1,5 +1,4 @@
 import platform
 import platform
-from collections import OrderedDict
 
 
 from django import __version__ as DJANGO_VERSION
 from django import __version__ as DJANGO_VERSION
 from django.apps import apps
 from django.apps import apps
@@ -26,18 +25,18 @@ class APIRootView(APIView):
 
 
     def get(self, request, format=None):
     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):
 class StatusView(APIView):

+ 58 - 0
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')

+ 1 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.3-beta1'
+VERSION = '3.3-beta2'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 1 - 1
netbox/templates/dcim/interface.html

@@ -219,7 +219,7 @@
                 <tr>
                 <tr>
                   <th scope="row">Path Status</th>
                   <th scope="row">Path Status</th>
                   <td>
                   <td>
-                    {% if object.path.is_active %}
+                    {% if object.path.is_complete and object.path.is_active %}
                       <span class="badge bg-success">Reachable</span>
                       <span class="badge bg-success">Reachable</span>
                     {% else %}
                     {% else %}
                       <span class="badge bg-danger">Not Reachable</span>
                       <span class="badge bg-danger">Not Reachable</span>

+ 5 - 5
netbox/tenancy/views.py

@@ -95,7 +95,7 @@ class TenantListView(generic.ObjectListView):
 
 
 
 
 class TenantView(generic.ObjectView):
 class TenantView(generic.ObjectView):
-    queryset = Tenant.objects.prefetch_related('group')
+    queryset = Tenant.objects.all()
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         stats = {
         stats = {
@@ -140,14 +140,14 @@ class TenantBulkImportView(generic.BulkImportView):
 
 
 
 
 class TenantBulkEditView(generic.BulkEditView):
 class TenantBulkEditView(generic.BulkEditView):
-    queryset = Tenant.objects.prefetch_related('group')
+    queryset = Tenant.objects.all()
     filterset = filtersets.TenantFilterSet
     filterset = filtersets.TenantFilterSet
     table = tables.TenantTable
     table = tables.TenantTable
     form = forms.TenantBulkEditForm
     form = forms.TenantBulkEditForm
 
 
 
 
 class TenantBulkDeleteView(generic.BulkDeleteView):
 class TenantBulkDeleteView(generic.BulkDeleteView):
-    queryset = Tenant.objects.prefetch_related('group')
+    queryset = Tenant.objects.all()
     filterset = filtersets.TenantFilterSet
     filterset = filtersets.TenantFilterSet
     table = tables.TenantTable
     table = tables.TenantTable
 
 
@@ -337,14 +337,14 @@ class ContactBulkImportView(generic.BulkImportView):
 
 
 
 
 class ContactBulkEditView(generic.BulkEditView):
 class ContactBulkEditView(generic.BulkEditView):
-    queryset = Contact.objects.prefetch_related('group')
+    queryset = Contact.objects.all()
     filterset = filtersets.ContactFilterSet
     filterset = filtersets.ContactFilterSet
     table = tables.ContactTable
     table = tables.ContactTable
     form = forms.ContactBulkEditForm
     form = forms.ContactBulkEditForm
 
 
 
 
 class ContactBulkDeleteView(generic.BulkDeleteView):
 class ContactBulkDeleteView(generic.BulkDeleteView):
-    queryset = Contact.objects.prefetch_related('group')
+    queryset = Contact.objects.all()
     filterset = filtersets.ContactFilterSet
     filterset = filtersets.ContactFilterSet
     table = tables.ContactTable
     table = tables.ContactTable
 
 

+ 14 - 20
netbox/utilities/templates/helpers/utilization_graph.html

@@ -1,21 +1,15 @@
-{% if utilization == 0 %}
-  <div class="progress align-items-center justify-content-center">
-    <span class="w-100 text-center">{{ utilization }}%</span>
+<div class="progress">
+  <div
+    role="progressbar"
+    aria-valuemin="0"
+    aria-valuemax="100"
+    aria-valuenow="{{ utilization }}"
+    class="progress-bar {{ bar_class }}"
+    style="width: {{ utilization }}%;"
+  >
+    {% if utilization >= 35 %}{{ utilization|floatformat:1 }}%{% endif %}
   </div>
   </div>
-{% else %}
-  <div class="progress">
-    <div
-      role="progressbar"
-      aria-valuemin="0"
-      aria-valuemax="100"
-      aria-valuenow="{{ utilization }}"
-      class="progress-bar {{ bar_class }}"
-      style="width: {{ utilization }}%;"
-    >
-      {% if utilization >= 25 %}{{ utilization|floatformat:0 }}%{% endif %}
-    </div>
-    {% if utilization < 25 %}
-      <span class="ps-1">{{ utilization|floatformat:0 }}%</span>
-    {% endif %}
-  </div>
-{% endif %}
+  {% if utilization < 35 %}
+    <span class="ps-1">{{ utilization|floatformat:1 }}%</span>
+  {% endif %}
+</div>

+ 2 - 3
netbox/utilities/utils.py

@@ -1,7 +1,6 @@
 import datetime
 import datetime
 import decimal
 import decimal
 import json
 import json
-from collections import OrderedDict
 from decimal import Decimal
 from decimal import Decimal
 from itertools import count, groupby
 from itertools import count, groupby
 
 
@@ -149,7 +148,7 @@ def serialize_object(obj, extra=None):
     # Include any tags. Check for tags cached on the instance; fall back to using the manager.
     # Include any tags. Check for tags cached on the instance; fall back to using the manager.
     if is_taggable(obj):
     if is_taggable(obj):
         tags = getattr(obj, '_tags', None) or obj.tags.all()
         tags = getattr(obj, '_tags', None) or obj.tags.all()
-        data['tags'] = [tag.name for tag in tags]
+        data['tags'] = sorted([tag.name for tag in tags])
 
 
     # Append any extra data
     # Append any extra data
     if extra is not None:
     if extra is not None:
@@ -218,7 +217,7 @@ def deepmerge(original, new):
     """
     """
     Deep merge two dictionaries (new into original) and return a new dict
     Deep merge two dictionaries (new into original) and return a new dict
     """
     """
-    merged = OrderedDict(original)
+    merged = dict(original)
     for key, val in new.items():
     for key, val in new.items():
         if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
         if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
             merged[key] = deepmerge(original[key], val)
             merged[key] = deepmerge(original[key], val)

+ 6 - 7
netbox/virtualization/tables/virtualmachines.py

@@ -54,6 +54,9 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         order_by=('primary_ip4', 'primary_ip6'),
         order_by=('primary_ip4', 'primary_ip6'),
         verbose_name='IP Address'
         verbose_name='IP Address'
     )
     )
+    contacts = columns.ManyToManyColumn(
+        linkify_item=True
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:virtualmachine_list'
         url_name='virtualization:virtualmachine_list'
     )
     )
@@ -62,8 +65,8 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
             'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
-            'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created',
-            'last_updated',
+            'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags',
+            'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
@@ -84,9 +87,6 @@ class VMInterfaceTable(BaseInterfaceTable):
     vrf = tables.Column(
     vrf = tables.Column(
         linkify=True
         linkify=True
     )
     )
-    contacts = columns.ManyToManyColumn(
-        linkify_item=True
-    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='virtualization:vminterface_list'
         url_name='virtualization:vminterface_list'
     )
     )
@@ -95,8 +95,7 @@ class VMInterfaceTable(BaseInterfaceTable):
         model = VMInterface
         model = VMInterface
         fields = (
         fields = (
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
             'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
-            'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'contacts', 'created',
-            'last_updated',
+            'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
         default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
 
 

+ 8 - 7
netbox/virtualization/views.py

@@ -209,14 +209,14 @@ class ClusterBulkImportView(generic.BulkImportView):
 
 
 
 
 class ClusterBulkEditView(generic.BulkEditView):
 class ClusterBulkEditView(generic.BulkEditView):
-    queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
+    queryset = Cluster.objects.all()
     filterset = filtersets.ClusterFilterSet
     filterset = filtersets.ClusterFilterSet
     table = tables.ClusterTable
     table = tables.ClusterTable
     form = forms.ClusterBulkEditForm
     form = forms.ClusterBulkEditForm
 
 
 
 
 class ClusterBulkDeleteView(generic.BulkDeleteView):
 class ClusterBulkDeleteView(generic.BulkDeleteView):
-    queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
+    queryset = Cluster.objects.all()
     filterset = filtersets.ClusterFilterSet
     filterset = filtersets.ClusterFilterSet
     table = tables.ClusterTable
     table = tables.ClusterTable
 
 
@@ -308,7 +308,7 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
 #
 #
 
 
 class VirtualMachineListView(generic.ObjectListView):
 class VirtualMachineListView(generic.ObjectListView):
-    queryset = VirtualMachine.objects.all()
+    queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
     filterset_form = forms.VirtualMachineFilterForm
     filterset_form = forms.VirtualMachineFilterForm
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
@@ -334,7 +334,8 @@ class VirtualMachineView(generic.ObjectView):
         services = Service.objects.restrict(request.user, 'view').filter(
         services = Service.objects.restrict(request.user, 'view').filter(
             virtual_machine=instance
             virtual_machine=instance
         ).prefetch_related(
         ).prefetch_related(
-            Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user))
+            Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)),
+            'virtual_machine'
         )
         )
 
 
         return {
         return {
@@ -383,14 +384,14 @@ class VirtualMachineBulkImportView(generic.BulkImportView):
 
 
 
 
 class VirtualMachineBulkEditView(generic.BulkEditView):
 class VirtualMachineBulkEditView(generic.BulkEditView):
-    queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
+    queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     form = forms.VirtualMachineBulkEditForm
     form = forms.VirtualMachineBulkEditForm
 
 
 
 
 class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
 class VirtualMachineBulkDeleteView(generic.BulkDeleteView):
-    queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
+    queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6')
     filterset = filtersets.VirtualMachineFilterSet
     filterset = filtersets.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
 
 
@@ -413,7 +414,7 @@ class VMInterfaceView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get assigned IP addresses
         # Get assigned IP addresses
         ipaddress_table = AssignedIPAddressesTable(
         ipaddress_table = AssignedIPAddressesTable(
-            data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
+            data=instance.ip_addresses.restrict(request.user, 'view'),
             orderable=False
             orderable=False
         )
         )
 
 

+ 5 - 5
requirements.txt

@@ -1,5 +1,5 @@
 bleach==5.0.1
 bleach==5.0.1
-Django==4.0.6
+Django==4.0.7
 django-cors-headers==3.13.0
 django-cors-headers==3.13.0
 django-debug-toolbar==3.5.0
 django-debug-toolbar==3.5.0
 django-filter==22.1
 django-filter==22.1
@@ -14,19 +14,19 @@ django-tables2==2.4.1
 django-taggit==3.0.0
 django-taggit==3.0.0
 django-timezone-field==5.0
 django-timezone-field==5.0
 djangorestframework==3.13.1
 djangorestframework==3.13.1
-drf-yasg[validation]==1.20.0
+drf-yasg[validation]==1.21.3
 graphene-django==2.15.0
 graphene-django==2.15.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
-Markdown==3.3.7
-markdown-include==0.6.0
+Markdown==3.4.1
+markdown-include==0.7.0
 mkdocs-material==8.3.9
 mkdocs-material==8.3.9
 mkdocstrings[python-legacy]==0.19.0
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.2.0
 Pillow==9.2.0
 psycopg2-binary==2.9.3
 psycopg2-binary==2.9.3
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.7.1
+sentry-sdk==1.9.0
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 social-auth-core==4.3.0
 social-auth-core==4.3.0
 svgwrite==1.4.3
 svgwrite==1.4.3