diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 79fb0e334..4a6dba734 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.0.10
+ placeholder: v3.0.11
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 76944eecb..4c3ab0277 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.0.10
+ placeholder: v3.0.11
validations:
required: true
- type: dropdown
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 bf1b27895..8b31ed67d 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 d59aa50a2..4bc0b2377 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 4c263e78f..f76869f6e 100644
--- a/docs/release-notes/version-3.0.md
+++ b/docs/release-notes/version-3.0.md
@@ -1,5 +1,32 @@
# NetBox v3.0
+## v3.0.11 (2021-11-24)
+
+### 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
+* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
+* [#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)
### Enhancements
@@ -422,7 +449,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
-### Bug Fixes (from v3.2-beta2)
+### Bug Fixes (from v3.0-beta2)
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations
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 8e2fa15af..a731860fe 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -356,7 +356,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
required=False
)
power_port = NestedPowerPortTemplateSerializer(
- required=False
+ required=False,
+ allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@@ -538,7 +539,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
- allow_blank=True,
+ allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -562,7 +563,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
- allow_blank=True,
+ allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -585,7 +586,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
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 5a732fa8d..36eb24c96 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -428,7 +428,7 @@ class PowerPortTypeChoices(ChoiceSet):
)),
('International/ITA', (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
- (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
+ (TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@@ -640,8 +640,8 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8464C, 'CS8464C'),
)),
('ITA/International', (
- (TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
- (TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
+ (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
+ (TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
(TYPE_ITA_H, 'ITA Type H'),
(TYPE_ITA_I, 'ITA Type I'),
@@ -739,6 +739,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'
@@ -850,6 +851,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 df7f415e2..f7cf011ce 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -861,6 +861,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):
@@ -1394,6 +1405,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'
@@ -1416,6 +1431,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 a2ceea6cf..d72733911 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -215,8 +215,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
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', 'label', 'color', 'length', 'length_unit', 'tags',
@@ -277,8 +276,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
- class Meta:
- model = Cable
+ class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit', 'tags',
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 4ef53c469..70a20d8a5 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -92,12 +92,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'
@@ -888,7 +895,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,
@@ -908,7 +915,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,
@@ -928,7 +935,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,
@@ -943,7 +950,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,
@@ -958,7 +965,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
- ['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,
@@ -993,7 +1000,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(
@@ -1012,7 +1019,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,
@@ -1030,7 +1037,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)
@@ -1040,7 +1047,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(),
@@ -1068,6 +1075,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,
@@ -1095,6 +1107,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,
@@ -1122,6 +1139,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 5601bc591..a2d4d7099 100644
--- a/netbox/dcim/svg.py
+++ b/netbox/dcim/svg.py
@@ -442,15 +442,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 (either a Cable or attachment to a ProviderNetwork)
if connector is not None:
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index e5977b760..f6885806a 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -584,6 +584,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'),
@@ -595,14 +601,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,
},
]
@@ -1033,14 +1042,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,
},
]
@@ -1072,14 +1084,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,
},
]
@@ -1139,6 +1154,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'),
@@ -1150,14 +1171,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,
},
]
@@ -1524,7 +1548,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 fb94bde08..2b5da8576 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -2048,6 +2048,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'),
@@ -2157,6 +2162,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/context_managers.py b/netbox/extras/context_managers.py
index 66b5ff94d..9f73fe9c3 100644
--- a/netbox/extras/context_managers.py
+++ b/netbox/extras/context_managers.py
@@ -2,8 +2,9 @@ from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
-from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
-from utilities.utils import curry
+from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
+from netbox import thread_locals
+from netbox.request_context import set_request
from .webhooks import flush_webhooks
@@ -15,12 +16,8 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set
"""
- webhook_queue = []
-
- # Curry signals receivers to pass the current request
- handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
- handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
- clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
+ set_request(request)
+ thread_locals.webhook_queue = []
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
@@ -38,5 +35,8 @@ def change_logging(request):
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ
- flush_webhooks(webhook_queue)
- del webhook_queue
+ flush_webhooks(thread_locals.webhook_queue)
+ del thread_locals.webhook_queue
+
+ # Clear the request from thread-local storage
+ set_request(None)
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index af8d904f4..1ed25cdac 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -35,6 +35,10 @@ EXACT_FILTER_TYPES = (
class WebhookFilterSet(BaseFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
@@ -47,30 +51,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()
@@ -78,6 +133,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/signals.py b/netbox/extras/signals.py
index 4f09706be..ec3653e15 100644
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -6,6 +6,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates
+from netbox import thread_locals
+from netbox.request_context import get_request
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import CustomField, ObjectChange
@@ -20,10 +22,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal()
-def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
+def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
+ if not hasattr(instance, 'to_objectchange'):
+ return
+
+ request = get_request()
+ m2m_changed = False
+
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
@@ -31,11 +39,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
request.id == webhook_data['request_id']
)
- if not hasattr(instance, 'to_objectchange'):
- return
-
- m2m_changed = False
-
# Determine the type of change being made
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
@@ -65,6 +68,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
+ webhook_queue = thread_locals.webhook_queue
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
@@ -79,13 +83,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc()
-def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
+def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
return
+ request = get_request()
+
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
@@ -94,19 +100,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# Enqueue webhooks
+ webhook_queue = thread_locals.webhook_queue
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
-def _clear_webhook_queue(webhook_queue, sender, **kwargs):
+def clear_webhook_queue(sender, **kwargs):
"""
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('webhooks')
- logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
+ webhook_queue = thread_locals.webhook_queue
+ logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()
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 3b8c55bda..410af78f1 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -206,6 +206,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'
@@ -230,8 +235,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',
@@ -318,7 +323,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/__init__.py b/netbox/netbox/__init__.py
index e69de29bb..5cf431025 100644
--- a/netbox/netbox/__init__.py
+++ b/netbox/netbox/__init__.py
@@ -0,0 +1,3 @@
+import threading
+
+thread_locals = threading.local()
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/middleware.py b/netbox/netbox/middleware.py
index a8f989a2a..ed308ea54 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -1,10 +1,10 @@
+import logging
import uuid
from urllib import parse
-import logging
from django.conf import settings
-from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.contrib import auth
+from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect
@@ -114,7 +114,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return groups
-class ObjectChangeMiddleware(object):
+class ObjectChangeMiddleware:
"""
This middleware performs three functions in response to an object being created, updated, or deleted:
diff --git a/netbox/netbox/request_context.py b/netbox/netbox/request_context.py
new file mode 100644
index 000000000..41e8283e8
--- /dev/null
+++ b/netbox/netbox/request_context.py
@@ -0,0 +1,9 @@
+from netbox import thread_locals
+
+
+def set_request(request):
+ thread_locals.request = request
+
+
+def get_request():
+ return getattr(thread_locals, 'request', None)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 83655d0c5..8538c1d36 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -17,7 +17,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '3.0.10'
+VERSION = '3.0.11'
# Hostname
HOSTNAME = platform.node()
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/extras/webhook.html b/netbox/templates/extras/webhook.html
index c92ec4c99..266fa9263 100644
--- a/netbox/templates/extras/webhook.html
+++ b/netbox/templates/extras/webhook.html
@@ -137,7 +137,11 @@
Additional Headers
-
{{ object.additional_headers }}
+ {% if object.additional_headers %}
+
{{ object.additional_headers }}
+ {% else %}
+
None
+ {% endif %}
@@ -145,7 +149,11 @@
Body Template
-
{{ object.body_template }}
+ {% if object.body_template %}
+
{{ object.body_template }}
+ {% else %}
+
None
+ {% endif %}
{% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html
index a46bef3b0..daa2c8e8c 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 }}
@@ -68,7 +69,7 @@
VLANs
- {% include 'inc/table.html' with table=vlans_table %}
+ {% render_table vlans_table 'inc/table.html' %}
{% if perms.ipam.add_vlan %}