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