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 %}
-
-
-
-
-
-
- Name |
- Status |
- Description |
- Last Run |
- |
-
-
-
- {% for report in module_reports %}
-
-
- {{ 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 %}
-
-
-
- {% endif %}
- |
-
- {% for method, stats in report.result.data.items %}
-
-
- {{ method }}
- |
-
- {{ stats.success }}
- {{ stats.info }}
- {{ stats.warning }}
- {{ stats.failure }}
- |
-
- {% endfor %}
- {% endfor %}
-
-
+{% block tabs %}
+
+{% endblock tabs %}
+
+{% block content-wrapper %}
+
+ {% if reports %}
+ {% for module, module_reports in reports %}
+
+
+
+
+
+
+ Name |
+ Status |
+ Description |
+ Last Run |
+ |
+
+
+
+ {% for report in module_reports %}
+
+
+ {{ 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 %}
+
+
-
+ {% endif %}
+ |
+
+ {% for method, stats in report.result.data.items %}
+
+
+ {{ method }}
+ |
+
+ {{ stats.success }}
+ {{ stats.info }}
+ {{ stats.warning }}
+ {{ stats.failure }}
+ |
+
+ {% endfor %}
{% endfor %}
- {% else %}
-
-
No Reports Found
- Reports should be saved to {{ settings.REPORTS_ROOT }}
.
-
- This path can be changed by setting REPORTS_ROOT
in NetBox's configuration.
-
- {% endif %}
+
+
+
-
- {% if reports %}
-
-
- {% for module, module_reports in reports %}
-
{{ module|bettertitle }}
-
- {% endfor %}
-
-
- {% endif %}
-
-
-{% endblock %}
+ {% endfor %}
+ {% else %}
+
+
No Reports Found
+ Reports should be saved to {{ settings.REPORTS_ROOT }}
.
+
+ This path can be changed by setting REPORTS_ROOT
in NetBox's configuration.
+
+ {% 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 }}
-
-
-
- Name |
- Status |
- Description |
- Last Run |
-
-
-
- {% for class_name, script in module_scripts.items %}
-
-
- {{ script }}
- |
-
- {% include 'extras/inc/job_label.html' with result=script.result %}
- |
- {{ script.Meta.description|render_markdown }} |
- {% if script.result %}
-
- {{ script.result.created|annotated_date }}
- |
- {% else %}
- Never |
- {% endif %}
-
- {% endfor %}
-
-
+{% block tabs %}
+
+{% endblock tabs %}
+
+{% block content-wrapper %}
+
+ {% if scripts %}
+ {% for module, module_scripts in scripts.items %}
+
+
+
+
+
+
+ Name |
+ Status |
+ Description |
+ Last Run |
+
+
+
+ {% for class_name, script in module_scripts.items %}
+
+
+ {{ script }}
+ |
+
+ {% include 'extras/inc/job_label.html' with result=script.result %}
+ |
+
+ {{ script.Meta.description|render_markdown|placeholder }}
+ |
+ {% if script.result %}
+
+ {{ script.result.created|annotated_date }}
+ |
+ {% else %}
+ Never |
+ {% 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 %}
+
+
+
-
- {% 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 %}