diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md index 9a3444ca0..6f231798d 100644 --- a/docs/administration/housekeeping.md +++ b/docs/administration/housekeeping.md @@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly. This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. ```shell -ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping +sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping ``` !!! note diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 0b78e0ef4..b1e1e832e 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.) ```shell -ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping +sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping ``` See the [housekeeping documentation](../administration/housekeeping.md) for further details. diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 6f199f9b0..d7f7156c8 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.) ```shell -ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping +sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping ``` See the [housekeeping documentation](../administration/housekeeping.md) for further details. diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 4e7ead79c..184b5debb 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -2,6 +2,28 @@ ## v3.0.11 (FUTURE) +### Enhancements + +* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models +* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components +* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting +* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table +* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts +* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type + +### Bug Fixes + +* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled +* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values +* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table +* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination +* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering +* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines +* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status +* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views +* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API +* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds + --- ## v3.0.10 (2021-11-12) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 67ae9b046..1fdde78d7 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): class Meta: model = models.VirtualChassis - fields = ['id', 'name', 'url', 'master', 'member_count'] + fields = ['id', 'url', 'display', 'name', 'master', 'member_count'] # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ede7e340f..45930c5f5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -352,7 +352,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): required=False ) power_port = NestedPowerPortTemplateSerializer( - required=False + required=False, + allow_null=True ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, @@ -524,7 +525,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali ) speed = ChoiceField( choices=ConsolePortSpeedChoices, - allow_blank=True, + allow_null=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -548,7 +549,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C ) speed = ChoiceField( choices=ConsolePortSpeedChoices, - allow_blank=True, + allow_null=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -571,7 +572,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C required=False ) power_port = NestedPowerPortSerializer( - required=False + required=False, + allow_null=True ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b2eacd172..b1ed5576d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -759,6 +759,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_80211AC = 'ieee802.11ac' TYPE_80211AD = 'ieee802.11ad' TYPE_80211AX = 'ieee802.11ax' + TYPE_802151 = 'ieee802.15.1' # Cellular TYPE_GSM = 'gsm' @@ -871,6 +872,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_80211AC, 'IEEE 802.11ac'), (TYPE_80211AD, 'IEEE 802.11ad'), (TYPE_80211AX, 'IEEE 802.11ax'), + (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), ) ), ( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f166a23de..bd2a75fe0 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -876,6 +876,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Device (name)', ) + virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__virtual_chassis', + queryset=VirtualChassis.objects.all(), + label='Virtual Chassis (ID)' + ) + virtual_chassis = django_filters.ModelMultipleChoiceFilter( + field_name='device__virtual_chassis__name', + queryset=VirtualChassis.objects.all(), + to_field_name='name', + label='Virtual Chassis', + ) tag = TagFilter() def search(self, queryset, name, value): @@ -1416,6 +1427,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE # class ConnectionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) site_id = MultiValueNumberFilter( method='filter_connections', field_name='device__site_id' @@ -1438,6 +1453,15 @@ class ConnectionFilterSet(BaseFilterSet): return queryset return queryset.filter(**{f'{name}__in': value}) + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(device__name__icontains=value) | + Q(cable__label__icontains=value) + ) + return queryset.filter(qs_filter) + class ConsoleConnectionFilterSet(ConnectionFilterSet): diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 770dc211b..4e5a64486 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -217,8 +217,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi required=False ) - class Meta: - model = Cable + class Meta(ConnectCableToDeviceForm.Meta): fields = [ 'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', @@ -280,8 +279,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelF required=False ) - class Meta: - model = Cable + class Meta(ConnectCableToDeviceForm.Meta): fields = [ 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 11fc69745..e61ff250e 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -93,12 +93,19 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Location'), fetch_trigger='open' ) + virtual_chassis_id = DynamicModelMultipleChoiceField( + queryset=VirtualChassis.objects.all(), + required=False, + label=_('Virtual Chassis'), + fetch_trigger='open' + ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, query_params={ 'site_id': '$site_id', 'location_id': '$location_id', + 'virtual_chassis_id': '$virtual_chassis_id' }, label=_('Device'), fetch_trigger='open' @@ -895,7 +902,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, @@ -915,7 +922,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=ConsolePortTypeChoices, @@ -935,7 +942,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=PowerPortTypeChoices, @@ -950,7 +957,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=PowerOutletTypeChoices, @@ -966,7 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ['q', 'tag'], ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] kind = forms.MultipleChoiceField( choices=InterfaceKindChoices, @@ -1031,7 +1038,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] model = FrontPort type = forms.MultipleChoiceField( @@ -1050,7 +1057,7 @@ class RearPortFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] type = forms.MultipleChoiceField( choices=PortTypeChoices, @@ -1068,7 +1075,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] tag = TagFilterField(model) @@ -1078,7 +1085,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], + ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], ] manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -1106,6 +1113,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1133,6 +1145,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1160,6 +1177,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index b7f1576ee..e90890eeb 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -478,15 +478,16 @@ class CableTraceSVG: parent_objects.append(parent_object) # Near end termination - termination = self._draw_box( - width=self.width * .8, - color=self._get_color(near_end), - url=near_end.get_absolute_url(), - labels=self._get_labels(near_end), - y_indent=PADDING, - radius=5 - ) - terminations.append(termination) + if near_end is not None: + termination = self._draw_box( + width=self.width * .8, + color=self._get_color(near_end), + url=near_end.get_absolute_url(), + labels=self._get_labels(near_end), + y_indent=PADDING, + radius=5 + ) + terminations.append(termination) # Connector (a Cable or WirelessLink) if connector is not None: diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 594bbddaf..bc6b18ead 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -595,6 +595,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + power_port_templates = ( + PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'), + ) + PowerPortTemplate.objects.bulk_create(power_port_templates) + power_outlet_templates = ( PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'), PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'), @@ -606,14 +612,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): { 'device_type': devicetype.pk, 'name': 'Power Outlet Template 4', + 'power_port': power_port_templates[0].pk, }, { 'device_type': devicetype.pk, 'name': 'Power Outlet Template 5', + 'power_port': power_port_templates[1].pk, }, { 'device_type': devicetype.pk, 'name': 'Power Outlet Template 6', + 'power_port': None, }, ] @@ -1044,14 +1053,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa { 'device': device.pk, 'name': 'Console Port 4', + 'speed': 9600, }, { 'device': device.pk, 'name': 'Console Port 5', + 'speed': 115200, }, { 'device': device.pk, 'name': 'Console Port 6', + 'speed': None, }, ] @@ -1083,14 +1095,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView { 'device': device.pk, 'name': 'Console Server Port 4', + 'speed': 9600, }, { 'device': device.pk, 'name': 'Console Server Port 5', + 'speed': 115200, }, { 'device': device.pk, 'name': 'Console Server Port 6', + 'speed': None, }, ] @@ -1150,6 +1165,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000') device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site) + power_ports = ( + PowerPort(device=device, name='Power Port 1'), + PowerPort(device=device, name='Power Port 2'), + ) + PowerPort.objects.bulk_create(power_ports) + power_outlets = ( PowerOutlet(device=device, name='Power Outlet 1'), PowerOutlet(device=device, name='Power Outlet 2'), @@ -1161,14 +1182,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa { 'device': device.pk, 'name': 'Power Outlet 4', + 'power_port': power_ports[0].pk, }, { 'device': device.pk, 'name': 'Power Outlet 5', + 'power_port': power_ports[1].pk, }, { 'device': device.pk, 'name': 'Power Outlet 6', + 'power_port': None, }, ] @@ -1548,7 +1572,7 @@ class ConnectedDeviceTest(APITestCase): class VirtualChassisTest(APIViewTestCases.APIViewTestCase): model = VirtualChassis - brief_fields = ['id', 'master', 'member_count', 'name', 'url'] + brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 0f3b994a2..6bca25d50 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2073,6 +2073,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + # VirtualChassis assignment for filtering + virtual_chassis = VirtualChassis.objects.create(master=devices[0]) + Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) + Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) + interfaces = ( Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), @@ -2197,6 +2202,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_virtual_chassis_id(self): + params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 0d44eab57..6233ca442 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -28,6 +28,10 @@ __all__ = ( class WebhookFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) content_types = ContentTypeFilter() http_method = django_filters.MultipleChoiceFilter( choices=WebhookHttpMethodChoices @@ -40,30 +44,81 @@ class WebhookFilterSet(BaseFilterSet): 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', ] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(payload_url__icontains=value) + ) + class CustomFieldFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) content_types = ContentTypeFilter() class Meta: model = CustomField fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(label__icontains=value) | + Q(description__icontains=value) + ) + class CustomLinkFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = CustomLink fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(link_text__icontains=value) | + Q(link_url__icontains=value) | + Q(group_name__icontains=value) + ) + class ExportTemplateFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = ExportTemplate fields = ['id', 'content_type', 'name'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + class ImageAttachmentFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) created = django_filters.DateTimeFilter() content_type = ContentTypeFilter() @@ -71,6 +126,11 @@ class ImageAttachmentFilterSet(BaseFilterSet): model = ImageAttachment fields = ['id', 'content_type_id', 'object_id', 'name'] + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(name__icontains=value) + class JournalEntryFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 9c46278ae..b128f7461 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -3,6 +3,7 @@ import json import logging import os import pkgutil +import sys import traceback from collections import OrderedDict @@ -477,6 +478,10 @@ def get_scripts(use_names=False): # Iterate through all modules within the reports path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): + # Remove cached module to ensure consistency with filesystem + if module_name in sys.modules: + del sys.modules[module_name] + module = importer.find_module(module_name).load_module(module_name) if use_names and hasattr(module, 'name'): module_name = module.name diff --git a/netbox/extras/views.py b/netbox/extras/views.py index b0387c73d..ab9e3ba52 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.tables import paginate_table -from utilities.utils import copy_safe_request, count_related, shallow_compare_dict +from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables from .choices import JobResultStatusChoices @@ -754,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View): def get(self, request, module, name): script = self._get_script(name, module) - form = script.as_form(initial=request.GET) + form = script.as_form(initial=normalize_querydict(request.GET)) # Look for a pending JobResult (use the latest one by creation timestamp) script_content_type = ContentType.objects.get(app_label='extras', model='script') diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 1d40acbe3..3fddbf48e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -235,6 +235,11 @@ class PrefixTable(BaseTable): site = tables.Column( linkify=True ) + vlan_group = tables.Column( + accessor='vlan__group', + linkify=True, + verbose_name='VLAN Group' + ) vlan = tables.Column( linkify=True, verbose_name='VLAN' @@ -259,8 +264,8 @@ class PrefixTable(BaseTable): class Meta(BaseTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', - 'is_pool', 'mark_utilized', 'description', 'tags', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group', + 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -347,7 +352,7 @@ class IPAddressTable(BaseTable): verbose_name='NAT (Inside)' ) assigned = BooleanColumn( - accessor='assigned_object', + accessor='assigned_object_id', linkify=True, verbose_name='Assigned' ) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 653fad3b0..a67ec451d 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -34,7 +34,7 @@ class ObjectPermissionMixin(): object_permissions = ObjectPermission.objects.filter( self.get_permission_filter(user_obj), enabled=True - ).prefetch_related('object_types') + ).order_by('id').distinct('id').prefetch_related('object_types') # Create a dictionary mapping permissions to their constraints perms = defaultdict(list) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 44e83f5ec..1c2ff9917 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') + def get_table(self, request, permissions): + table = self.table(self.queryset, user=request.user) + if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): + table.columns.show('pk') + + return table + def export_yaml(self): """ Export the queryset of objects as concatenated YAML documents. @@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' ) - def get(self, request): + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + :param template: ExportTemplate instance + :param request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + def get(self, request): model = self.queryset.model content_type = ContentType.objects.get_for_model(model) @@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): perm_name = get_permission_for_model(model, action) permissions[action] = request.user.has_perm(perm_name) - # Export template/YAML rendering - if 'export' in request.GET and request.GET['export'] != 'table': + if 'export' in request.GET: - # An export template has been specified - if request.GET['export']: - et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) - try: - return et.render_to_response(self.queryset) - except Exception as e: - messages.error( - request, - "There was an error rendering the selected export template ({}): {}".format( - et.name, e - ) - ) + # Export the current table view + if request.GET['export'] == 'table': + table = self.get_table(request, permissions) + columns = [name for name, _ in table.selected_columns] + return self.export_table(table, columns) - # Check for YAML export support + # Render an ExportTemplate + elif request.GET['export']: + template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + return self.export_template(template, request) + + # Check for YAML export support on the model elif hasattr(model, 'to_yaml'): response = HttpResponse(self.export_yaml(), content_type='text/yaml') filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) return response - # Construct the objects table - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') + # Fall back to default table/YAML export + else: + table = self.get_table(request, permissions) + return self.export_table(table) - # Handle table-based exports (current view or static CSV-based) - if request.GET.get('export') == 'table': - columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) - elif 'export' in request.GET: - return self.export_table(table) - - # Paginate the objects table + # Render the objects table + table = self.get_table(request, permissions) paginate_table(table, request) context = { diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 498e56b8d..99d6da730 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -3,108 +3,94 @@ {% block title %}Reports{% endblock %} -{% block content %} -
-
- {% if reports %} - {% for module, module_reports in reports %} -
-
{{ module|bettertitle }}
-
- - - - - - - - - - - - {% for report in module_reports %} - - - - - - - - {% for method, stats in report.result.data.items %} - - - - - {% endfor %} - {% endfor %} - -
NameStatusDescriptionLast Run
- {{ report.name }} - - {% include 'extras/inc/job_label.html' with result=report.result %} - {{ report.description|render_markdown|placeholder }} - {% if report.result %} - {{ report.result.created|annotated_date }} - {% else %} - Never - {% endif %} - - {% if perms.extras.run_report %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
+{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
+ {% if reports %} + {% for module, module_reports in reports %} +
+
+ + {{ module|bettertitle }} +
+
+ + + + + + + + + + + + {% for report in module_reports %} + + + + + + + + {% for method, stats in report.result.data.items %} + + + + + {% endfor %} {% endfor %} - {% else %} - - {% endif %} + +
NameStatusDescriptionLast Run
+ {{ report.name }} + + {% include 'extras/inc/job_label.html' with result=report.result %} + {{ report.description|render_markdown|placeholder }} + {% if report.result %} + {{ report.result.created|annotated_date }} + {% else %} + Never + {% endif %} + + {% if perms.extras.run_report %} +
+
+ {% csrf_token %} + +
- + {% endif %} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
+
-
- {% if reports %} -
-
- {% for module, module_reports in reports %} -
{{ module|bettertitle }}
- - {% endfor %} -
-
- {% endif %} -
-
-{% endblock %} + {% endfor %} + {% else %} + + {% endif %} +
+{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 1cc35d36c..ccbdca705 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -3,74 +3,66 @@ {% block title %}Scripts{% endblock %} -{% block content %} -
-
- {% if scripts %} - {% for module, module_scripts in scripts.items %} -

{{ module|bettertitle }}

- - - - - - - - - - - {% for class_name, script in module_scripts.items %} - - - - - {% if script.result %} - - {% else %} - - {% endif %} - - {% endfor %} - -
NameStatusDescriptionLast Run
- {{ script }} - - {% include 'extras/inc/job_label.html' with result=script.result %} - {{ script.Meta.description|render_markdown }} - {{ script.result.created|annotated_date }} - Never
+{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
+ {% if scripts %} + {% for module, module_scripts in scripts.items %} +
+
+ + {{ module|bettertitle }} +
+
+ + + + + + + + + + + {% for class_name, script in module_scripts.items %} + + + + + {% if script.result %} + + {% else %} + + {% endif %} + {% endfor %} - {% else %} -
-

No Scripts Found

- Scripts should be saved to {{ settings.SCRIPTS_ROOT }}. -
- This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration. -
- {% endif %} + +
NameStatusDescriptionLast Run
+ {{ script }} + + {% include 'extras/inc/job_label.html' with result=script.result %} + + {{ script.Meta.description|render_markdown|placeholder }} + + {{ script.result.created|annotated_date }} + Never
+
-
- {% if scripts %} -
-
- {% for module, module_scripts in scripts.items %} -
{{ module|bettertitle }}
-
- -
- {% endfor %} -
-
- {% endif %} -
-
-{% endblock %} + {% endfor %} + {% else %} +
+

No Scripts Found

+ Scripts should be saved to {{ settings.SCRIPTS_ROOT }}. +
+ This path can be changed by setting SCRIPTS_ROOT in NetBox's configuration. +
+ {% endif %} +
+{% endblock content-wrapper %} diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 1c36e92f6..d51253b62 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block breadcrumbs %} {{ block.super }} @@ -69,7 +70,7 @@ VLANs
- {% include 'inc/table.html' with table=vlans_table %} + {% render_table vlans_table 'inc/table.html' %}
{% if perms.ipam.add_vlan %}