diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 332a0ad75..c26584f32 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.2.7 + placeholder: v3.2.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index ff9b5e358..e6be95e49 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.2.7 + placeholder: v3.2.8 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 9dc85231b..672ce402c 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -4,7 +4,7 @@ bleach # The Python web framework on which NetBox is built # https://github.com/django/django -Django +Django<4.1 # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 35e9b9a22..bf6f2f848 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,33 @@ # NetBox v3.2 +## v3.2.8 (2022-08-08) + +### 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 +* [#9906](https://github.com/netbox-community/netbox/issues/9906) - Include `color` attribute in front & rear port YAML import/export + +### Bug Fixes + +* [#9827](https://github.com/netbox-community/netbox/issues/9827) - Fix assignment of module bay position during bulk creation +* [#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 +* [#9919](https://github.com/netbox-community/netbox/issues/9919) - Fix potential XSS avenue via linked objects in tables +* [#9948](https://github.com/netbox-community/netbox/issues/9948) - Fix TypeError exception when requesting API tokens list as non-authenticated user +* [#9949](https://github.com/netbox-community/netbox/issues/9949) - Fix KeyError exception resulting from invalid API token provisioning request +* [#9950](https://github.com/netbox-community/netbox/issues/9950) - Prevent redirection to arbitrary URLs via `next` parameter on login URL +* [#9952](https://github.com/netbox-community/netbox/issues/9952) - Prevent InvalidMove when attempting to assign a nested child object as parent + +--- + ## v3.2.7 (2022-07-20) ### Enhancements diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 38221b371..12905aec9 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -287,7 +287,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( @@ -295,25 +295,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 043af751d..fb09b9871 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -321,7 +321,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/forms/object_import.py b/netbox/dcim/forms/object_import.py index afbcd6543..606333e83 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -146,7 +146,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', 'description', ] @@ -158,7 +158,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description', ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 92658d310..74252e480 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): """ @@ -337,6 +376,15 @@ 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, + } + class FrontPortTemplate(ModularComponentTemplateModel): """ @@ -410,6 +458,17 @@ class FrontPortTemplate(ModularComponentTemplateModel): **kwargs ) + def to_yaml(self): + return { + 'name': self.name, + 'type': self.type, + 'color': self.color, + 'rear_port': self.rear_port.name, + 'rear_port_position': self.rear_port_position, + 'label': self.label, + 'description': self.description, + } + class RearPortTemplate(ModularComponentTemplateModel): """ @@ -449,6 +508,16 @@ class RearPortTemplate(ModularComponentTemplateModel): **kwargs ) + def to_yaml(self): + return { + 'name': self.name, + 'type': self.type, + 'color': self.color, + 'positions': self.positions, + 'label': self.label, + 'description': self.description, + } + class ModuleBayTemplate(ComponentTemplateModel): """ @@ -474,6 +543,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): """ @@ -498,6 +575,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/devices.py b/netbox/dcim/models/devices.py index e88af2d05..91227f1cf 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - import yaml from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError @@ -161,115 +159,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', 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': 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, - } - 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) @@ -395,91 +332,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/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 8566f969b..03438a441 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -194,14 +194,14 @@ class RackTestCase(TestCase): # Validate inventory (front face) rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) self.assertEqual(rack1_inventory_front[-10]['device'], device1) - del(rack1_inventory_front[-10]) + del rack1_inventory_front[-10] for u in rack1_inventory_front: self.assertIsNone(u['device']) # Validate inventory (rear face) rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) self.assertEqual(rack1_inventory_rear[-10]['device'], device1) - del(rack1_inventory_rear[-10]) + del rack1_inventory_rear[-10] for u in rack1_inventory_rear: self.assertIsNone(u['device']) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ec3e9152e..0bdca686d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2707,6 +2707,7 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView): filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' + patterned_fields = ('name', 'label', 'position') class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView): @@ -3082,7 +3083,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix if membership_form.is_valid(): membership_form.save() - msg = 'Added member {}'.format(device.get_absolute_url(), escape(device)) + msg = f'Added member {escape(device)}' messages.success(request, mark_safe(msg)) if '_addanother' in request.POST: @@ -3127,8 +3128,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL # Protect master device from being removed virtual_chassis = VirtualChassis.objects.filter(master=device).first() if virtual_chassis is not None: - msg = 'Unable to remove master device {} from the virtual chassis.'.format(escape(device)) - messages.error(request, mark_safe(msg)) + messages.error(request, f'Unable to remove master device {device} from the virtual chassis.') return redirect(device.get_absolute_url()) if form.is_valid(): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 112911f42..82575de21 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -133,6 +133,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 6a8c1dacf..b7d77e550 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -169,7 +169,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/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/ipam/forms/models.py b/netbox/ipam/forms/models.py index e86abc672..d3421f22b 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -848,7 +848,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 a3b8fb2c1..d1538953a 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/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/views.py b/netbox/ipam/views.py index 706670cad..9ae7cd4d7 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -333,14 +333,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 diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index b3bfe06c0..ea2feb8de 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -89,9 +89,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel): super().clean() # An MPTT model cannot be its own parent - if self.pk and self.parent_id == self.pk: + if self.pk and self.parent and self.parent in self.get_descendants(include_self=True): raise ValidationError({ - "parent": "Cannot assign self as parent." + "parent": f"Cannot assign self or child {self._meta.verbose_name} as parent." }) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 094771581..12ab44399 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.2.7' +VERSION = '3.2.8' # Hostname HOSTNAME = platform.node() diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 7da241566..f78b9f37c 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser from django.db.models import DateField, DateTimeField from django.template import Context, Template from django.urls import reverse +from django.utils.html import escape from django.utils.formats import date_format from django.utils.safestring import mark_safe from django_tables2.columns import library @@ -428,8 +429,8 @@ class CustomFieldColumn(tables.Column): @staticmethod def _likify_item(item): if hasattr(item, 'get_absolute_url'): - return f'{item}' - return item + return f'{escape(item)}' + return escape(item) def render(self, value): if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True: @@ -437,13 +438,13 @@ class CustomFieldColumn(tables.Column): if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False: return mark_safe('') if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: - return mark_safe(f'{value}') + return mark_safe(f'{escape(value)}') if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: return ', '.join(v for v in value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - return mark_safe(', '.join([ + return mark_safe(', '.join( self._likify_item(obj) for obj in self.customfield.deserialize(value) - ])) + )) if value is not None: obj = self.customfield.deserialize(value) return mark_safe(self._likify_item(obj)) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 5bdf5cbc9..7e07c57d0 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -795,6 +795,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): model_form = None filterset = None table = None + patterned_fields = ('name', 'label') def get_required_permission(self): return f'dcim.add_{self.queryset.model._meta.model_name}' @@ -830,16 +831,16 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): for obj in data['pk']: - names = data['name_pattern'] - labels = data['label_pattern'] if 'label_pattern' in data else None - for i, name in enumerate(names): - label = labels[i] if labels else None - + pattern_count = len(data[f'{self.patterned_fields[0]}_pattern']) + for i in range(pattern_count): component_data = { - self.parent_field: obj.pk, - 'name': name, - 'label': label + self.parent_field: obj.pk } + + for field_name in self.patterned_fields: + if data.get(f'{field_name}_pattern'): + component_data[field_name] = data[f'{field_name}_pattern'][i] + component_data.update(data) component_form = self.model_form(component_data) if component_form.is_valid(): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 4ebfe71cc..88e078ae3 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -386,10 +386,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): ) logger.info(f"{msg} {obj} (PK: {obj.pk})") if hasattr(obj, 'get_absolute_url'): - msg = '{} {}'.format(msg, obj.get_absolute_url(), escape(obj)) + msg = mark_safe(f'{msg} {escape(obj)}') else: - msg = '{} {}'.format(msg, escape(obj)) - messages.success(request, mark_safe(msg)) + msg = f'{msg} {obj}' + messages.success(request, msg) if '_addanother' in request.POST: redirect_url = request.path diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index c3495afdf..66ef92ab7 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -58,6 +58,8 @@ class TokenViewSet(NetBoxModelViewSet): # Workaround for schema generation (drf_yasg) if getattr(self, 'swagger_fake_view', False): return queryset.none() + if not self.request.user.is_authenticated: + return queryset.none() if self.request.user.is_superuser: return queryset return queryset.filter(user=self.request.user) @@ -74,11 +76,11 @@ class TokenProvisionView(APIView): serializer.is_valid() # Authenticate the user account based on the provided credentials - user = authenticate( - request=request, - username=serializer.data['username'], - password=serializer.data['password'] - ) + username = serializer.data.get('username') + password = serializer.data.get('password') + if not username or not password: + raise AuthenticationFailed("Username and password must be provided to provision a token.") + user = authenticate(request=request, username=username, password=password) if user is None: raise AuthenticationFailed("Invalid username/password") diff --git a/netbox/users/views.py b/netbox/users/views.py index 344f375fc..f08cac844 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -10,6 +10,7 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator +from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View from social_core.backends.utils import load_backends @@ -91,7 +92,7 @@ class LoginView(View): data = request.POST if request.method == "POST" else request.GET redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL) - if redirect_url and redirect_url.startswith('/'): + if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None): logger.debug(f"Redirecting user to {redirect_url}") else: if redirect_url: 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/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 5a6841286..bc395e438 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -86,8 +86,8 @@ def placeholder(value): """ if value not in ('', None): return value - placeholder = '' - return mark_safe(placeholder) + + return mark_safe('') @register.filter() diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index db4d14c24..67ed553b2 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -109,9 +109,7 @@ def annotated_date(date_value): long_ts = date(date_value, 'DATETIME_FORMAT') short_ts = date(date_value, 'SHORT_DATETIME_FORMAT') - span = f'{short_ts}' - - return mark_safe(span) + return mark_safe(f'{short_ts}') @register.simple_tag diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 2b939471c..da7cdde94 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -148,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: diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 8cff96227..cace51ccc 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -48,6 +48,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' ) @@ -55,8 +58,9 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', 'disk', - 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', + 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', @@ -77,9 +81,6 @@ class VMInterfaceTable(BaseInterfaceTable): vrf = tables.Column( linkify=True ) - contacts = columns.ManyToManyColumn( - linkify_item=True - ) tags = columns.TagColumn( url_name='virtualization:vminterface_list' ) @@ -88,8 +89,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/requirements.txt b/requirements.txt index f987ad7ba..59bd1e8cd 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 @@ -13,22 +13,22 @@ django-tables2==2.4.1 django-taggit==2.1.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.0 +sentry-sdk==1.9.2 social-auth-app-django==5.0.0 social-auth-core==4.3.0 -svgwrite==1.4.2 +svgwrite==1.4.3 tablib==3.2.1 tzdata==2022.1