From 9d846d7b8729d70e86a106a15f751abc3136a856 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Wed, 15 Jan 2020 12:23:34 +0000 Subject: [PATCH 001/106] Fixes #3840: Only show valid interface VLAN choices --- docs/release-notes/version-2.6.md | 3 ++- netbox/dcim/forms.py | 42 ++++++++++++++++++++++++++----- netbox/project-static/js/forms.js | 17 +++++++------ netbox/utilities/forms.py | 9 +++++-- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index 792e8990a..b31e769a3 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -3,6 +3,7 @@ ## Enhancements * [#3525](https://github.com/netbox-community/netbox/issues/3525) - Enable IP address filtering with multiple address terms +* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Only show the valid list of interface VLAN choices ## Bug Fixes @@ -42,7 +43,7 @@ * [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs on the IP address view * [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fix minimum/maximum value rendering for site ASN field * [#3882](https://github.com/netbox-community/netbox/issues/3882) - Fix filtering of devices by rack group -* [#3898](https://github.com/netbox-community/netbox/issues/3898) - Fix references to deleted cables without a label +* [#3898](https://github.com/netbox-community/netbox/issues/3898) - Fix references to deleted cables without a label * [#3905](https://github.com/netbox-community/netbox/issues/3905) - Fix divide-by-zero on power feeds with low power values --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index cd356cc09..4b5dd33cf 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2238,7 +2238,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = forms.ModelMultipleChoiceField( @@ -2247,7 +2250,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -2289,6 +2295,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG ) + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) + class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): name_pattern = ExpandableNameField( @@ -2340,7 +2350,10 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = forms.ModelMultipleChoiceField( @@ -2349,7 +2362,10 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -2366,6 +2382,10 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.filter( device__in=[self.parent, self.parent.get_vc_master()], type=IFACE_TYPE_LAG ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', self.parent.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', self.parent.site.pk) else: self.fields['lag'].queryset = Interface.objects.none() @@ -2420,7 +2440,10 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = forms.ModelMultipleChoiceField( @@ -2429,7 +2452,10 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -2448,6 +2474,10 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG ) + + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) else: self.fields['lag'].choices = [] diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b7dbb1cfa..1fbd211a7 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -187,15 +187,18 @@ $(document).ready(function() { $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-additional-query-param-")){ var param_name = attr.name.split("data-additional-query-param-")[1]; - if (param_name in parameters) { - if (Array.isArray(parameters[param_name])) { - parameters[param_name].push(attr.value) + + $.each($.parseJSON(attr.value), function(index, value) { + if (param_name in parameters) { + if (Array.isArray(parameters[param_name])) { + parameters[param_name].push(value) + } else { + parameters[param_name] = [parameters[param_name], value] + } } else { - parameters[param_name] = [parameters[param_name], attr.value] + parameters[param_name] = value; } - } else { - parameters[param_name] = attr.value; - } + }) } }); diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 39422c265..ba16774bb 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -346,12 +346,17 @@ class APISelect(SelectWithDisabled): def add_additional_query_param(self, name, value): """ - Add details for an additional query param in the form of a data-* attribute. + Add details for an additional query param in the form of a data-* JSON-encoded list attribute. :param name: The name of the query param :param value: The value of the query param """ - self.attrs['data-additional-query-param-{}'.format(name)] = value + key = 'data-additional-query-param-{}'.format(name) + + values = json.loads(self.attrs.get(key, '[]')) + values.append(value) + + self.attrs[key] = json.dumps(values) def add_conditional_query_param(self, condition, value): """ From 201416ba526dad9d0fa003bd12d63727f439107b Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Wed, 15 Jan 2020 12:38:09 +0000 Subject: [PATCH 002/106] Semicolons for completeness --- netbox/project-static/js/forms.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 1fbd211a7..60bc32849 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -191,14 +191,14 @@ $(document).ready(function() { $.each($.parseJSON(attr.value), function(index, value) { if (param_name in parameters) { if (Array.isArray(parameters[param_name])) { - parameters[param_name].push(value) + parameters[param_name].push(value); } else { - parameters[param_name] = [parameters[param_name], value] + parameters[param_name] = [parameters[param_name], value]; } } else { parameters[param_name] = value; } - }) + }); } }); From c8997868cee94c7ca8319e10725851578b30f375 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 16 Jan 2020 15:10:25 +0000 Subject: [PATCH 003/106] Added #3840 changelog --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ac9d81e2c..5bf9fc314 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -237,6 +237,7 @@ PATCH) to maintain backward compatibility. This behavior will be discontinued be * [#3706](https://github.com/netbox-community/netbox/issues/3706) - Increase `available_power` maximum value on PowerFeed * [#3731](https://github.com/netbox-community/netbox/issues/3731) - Change Graph.type to a ContentType foreign key field * [#3801](https://github.com/netbox-community/netbox/issues/3801) - Use YAML for export of device types +* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Only show the valid list of interface VLAN choices ## Bug Fixes From c31c8b1a2566b62b64ba0fad05d6341534aed365 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 16 Jan 2020 21:51:37 +0000 Subject: [PATCH 004/106] Moved into v2.7.1 --- docs/release-notes/version-2.7.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 5bf9fc314..45223a056 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.1 (FUTURE) + +## Enhancements + +* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Only show the valid list of interface VLAN choices + +--- + # v2.7.0 (FUTURE) **Note:** NetBox v2.7 is the last major release that will support Python 3.5. Beginning with NetBox v2.8, Python 3.6 or @@ -237,7 +245,6 @@ PATCH) to maintain backward compatibility. This behavior will be discontinued be * [#3706](https://github.com/netbox-community/netbox/issues/3706) - Increase `available_power` maximum value on PowerFeed * [#3731](https://github.com/netbox-community/netbox/issues/3731) - Change Graph.type to a ContentType foreign key field * [#3801](https://github.com/netbox-community/netbox/issues/3801) - Use YAML for export of device types -* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Only show the valid list of interface VLAN choices ## Bug Fixes From 09faaff8492147546d024fca676872951d8012e4 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 20:34:06 +0000 Subject: [PATCH 005/106] Fixes #3995: Navbar scroll when overflowing --- docs/release-notes/version-2.7.md | 1 + netbox/project-static/css/base.css | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index befa9c58f..0ee9dd763 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -8,6 +8,7 @@ ## Bug Fixes * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices +* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Fixed overflowing dropdown menus becoming unreachable --- diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 45babe70b..704a7e9b0 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -62,8 +62,20 @@ footer p { } } +/* Scroll the drop-down menus at or above 768px wide to match bootstrap's behavior for hiding dropdown menus */ +@media (min-width: 768px) { + .navbar-nav>li>ul { + max-height: 80vh; + overflow-y: scroll; + } +} + /* Collapse the nav menu on displays less than 980px wide */ @media (max-width: 979px) { + #navbar { + max-height: 80vh; + overflow-y: scroll; + } .navbar-header { float: none; } From 084a68f6d1984c90d6f8790b72b9f38d6b2b5085 Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Tue, 28 Jan 2020 22:11:31 -0600 Subject: [PATCH 006/106] #4034 - Create tests for prefixes --- netbox/ipam/tests/test_ordering.py | 163 +++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 netbox/ipam/tests/test_ordering.py diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py new file mode 100644 index 000000000..495f7702e --- /dev/null +++ b/netbox/ipam/tests/test_ordering.py @@ -0,0 +1,163 @@ +from django.test import TestCase + +from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices +from ipam.models import Aggregate, IPAddress, Prefix, RIR, VLAN, VLANGroup, VRF + +import netaddr + + +class PrefixOrderingTestCase(TestCase): + + def _create_prefix(self, prefixes): + prefixobjects = [] + for pfx in prefixes: + status, vrf, prefix = pfx + family = 4 + if not netaddr.valid_ipv4(prefix): + family = 6 + pfx = Prefix(prefix=prefix, family=family, vrf=vrf, status=status) + prefixobjects.append(pfx) + + return prefixobjects + + def _compare_prefix(self, queryset, prefixes): + + for i, obj in enumerate(queryset): + status, vrf, prefix = prefixes[i] + self.assertEqual((obj.vrf, obj.prefix), (vrf, prefix)) + + def _compare_complex(self, queryset, prefixes): + qsprefixes, regprefixes = [], [] + for i, obj in enumerate(queryset): + qsprefixes.append(obj.prefix) + for pfx in prefixes: + regprefixes.append(pfx[2]) + return (qsprefixes, regprefixes) + + + + def test_prefix_ordering(self): + # Setup Prefixes + prefixes = ( + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/8')), + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.4.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.1.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.4.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.2.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.4.0/24')), + + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('172.16.0.0/12')), + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('172.16.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.4.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('172.17.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.4.0/24')), + + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('192.168.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.0/24')) + ) + Prefix.objects.bulk_create(self._create_prefix(prefixes)) + + # Test + self._compare_prefix(Prefix.objects.all(), prefixes) + + def test_prefix_vrf_ordering(self): + # Setup VRFs + vrfa = VRF(name='VRF A') + vrfb = VRF(name='VRF B') + vrfs = [vrfa, vrfb] + VRF.objects.bulk_create(vrfs) + + # Setup Prefixes + prefixes = ( + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('192.168.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.0/24')), + + (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.0.0.0/8')), + (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.0.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.4.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.1.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.4.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.2.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.4.0/24')), + + (PrefixStatusChoices.STATUS_CONTAINER, vrfb, netaddr.IPNetwork('172.16.0.0/12')), + (PrefixStatusChoices.STATUS_CONTAINER, vrfb, netaddr.IPNetwork('172.16.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.4.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, vrfb, netaddr.IPNetwork('172.17.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.2.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.3.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.4.0/24')), + ) + Prefix.objects.bulk_create(self._create_prefix(prefixes)) + + # Test + self._compare_prefix(Prefix.objects.all(), prefixes) + + def test_prefix_complex_ordering(self): + # Setup VRF's + vrf = VRF(name='VRF A') + vrfs = [vrf] + VRF.objects.bulk_create(vrfs) + + # Setup Prefixes + prefixes = [ + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/8')), + (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/16')), + (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.0.0.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, vrf, netaddr.IPNetwork('10.0.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.0.1.0/25')), + (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.1.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.1.1.0/24')) + ] + Prefix.objects.bulk_create(self._create_prefix(prefixes)) + + # Test + qsprefixes, compprefixes = self._compare_complex(Prefix.objects.all(), prefixes) + self.assertEquals(qsprefixes, compprefixes) From 22228b58f17e490ae333e27797df849d048777c4 Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Wed, 29 Jan 2020 12:52:48 -0600 Subject: [PATCH 007/106] #4034 - Create tests for addresses --- netbox/ipam/tests/test_ordering.py | 176 ++++++++++++++++++++++++++--- 1 file changed, 162 insertions(+), 14 deletions(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 495f7702e..729d28d12 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -1,12 +1,20 @@ from django.test import TestCase -from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices +from ipam.choices import IPAddressStatusChoices, PrefixStatusChoices from ipam.models import Aggregate, IPAddress, Prefix, RIR, VLAN, VLANGroup, VRF import netaddr class PrefixOrderingTestCase(TestCase): + vrfs = None + + def setUp(self): + vrfa = VRF(name="VRF A") + vrfb = VRF(name="VRF B") + vrfc = VRF(name="VRF C") + VRF.objects.bulk_create([vrfa, vrfb, vrfc]) + self.vrfs = (vrfa, vrfb, vrfc) def _create_prefix(self, prefixes): prefixobjects = [] @@ -86,10 +94,7 @@ class PrefixOrderingTestCase(TestCase): def test_prefix_vrf_ordering(self): # Setup VRFs - vrfa = VRF(name='VRF A') - vrfb = VRF(name='VRF B') - vrfs = [vrfa, vrfb] - VRF.objects.bulk_create(vrfs) + vrfa, vrfb, vrfc = self.vrfs # Setup Prefixes prefixes = ( @@ -139,10 +144,8 @@ class PrefixOrderingTestCase(TestCase): self._compare_prefix(Prefix.objects.all(), prefixes) def test_prefix_complex_ordering(self): - # Setup VRF's - vrf = VRF(name='VRF A') - vrfs = [vrf] - VRF.objects.bulk_create(vrfs) + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs # Setup Prefixes prefixes = [ @@ -150,14 +153,159 @@ class PrefixOrderingTestCase(TestCase): (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/16')), (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.0.0/16')), (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.0.0.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, vrf, netaddr.IPNetwork('10.0.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.0.1.0/25')), - (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.1.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrf, netaddr.IPNetwork('10.1.1.0/24')) + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.0/24')), + (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.0.1.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.0/25')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.0.0/24')), + (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.0/24')) ] Prefix.objects.bulk_create(self._create_prefix(prefixes)) # Test qsprefixes, compprefixes = self._compare_complex(Prefix.objects.all(), prefixes) self.assertEquals(qsprefixes, compprefixes) + + +class IPAddressOrderingTestCase(TestCase): + vrfs = None + + def setUp(self): + vrfa = VRF(name="VRF A") + vrfb = VRF(name="VRF B") + vrfc = VRF(name="VRF C") + VRF.objects.bulk_create([vrfa, vrfb, vrfc]) + self.vrfs = (vrfa, vrfb, vrfc) + + def _create_address(self, addresses): + addressobjects = [] + for addr in addresses: + status, vrf, address = addr + family = 4 + if not netaddr.valid_ipv4(address): + family = 6 + addressobj = IPAddress(address=address, vrf=vrf, status=status, family=family) + addressobjects.append(addressobj) + + return addressobjects + + def _compare_address(self, queryset, addresses): + + for i, obj in enumerate(queryset): + status, vrf, address = addresses[i] + self.assertEqual((obj.vrf, obj.address), (vrf, address)) + + def _compare_complex(self, queryset, addresses): + qsaddress, regaddress = [], [] + for i, obj in enumerate(queryset): + qsaddress.append(obj.address) + for addr in addresses: + regaddress.append(addr[2]) + return (qsaddress, regaddress) + + + + def test_address_ordering(self): + # Setup Addresses + addresses = ( + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.4.0/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.4.1/24')), + + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.4.1/24')), + + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.1/24')) + ) + IPAddress.objects.bulk_create(self._create_address(addresses)) + + # Test + self._compare_address(IPAddress.objects.all(), addresses) + + def test_address_vrf_ordering(self): + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup Addresses + addresses = ( + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.4.1/24')), + + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.4.1/24')), + + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.1/24')), + ) + IPAddress.objects.bulk_create(self._create_address(addresses)) + + # Test + self._compare_address(IPAddress.objects.all(), addresses) + + def test_address_complex_ordering(self): + # Setup VRFs + vrfa, vrfb, vrfc = self.vrfs + + # Setup addresses + addresses = [ + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.1/25')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.0.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.1/24')), + (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.1/24')), + ] + IPAddress.objects.bulk_create(self._create_address(addresses)) + + # Test + qsaddresses, compaddresses = self._compare_complex(IPAddress.objects.all(), addresses) + self.assertEquals(qsaddresses, compaddresses) From 23155551d1cbed1646163e68308fa0f6b417ef41 Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Wed, 29 Jan 2020 12:54:55 -0600 Subject: [PATCH 008/106] Remove complex ordering for IP addresses After review complex ordering does not appear to be required --- netbox/ipam/tests/test_ordering.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 729d28d12..8ee71c04f 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -194,15 +194,6 @@ class IPAddressOrderingTestCase(TestCase): status, vrf, address = addresses[i] self.assertEqual((obj.vrf, obj.address), (vrf, address)) - def _compare_complex(self, queryset, addresses): - qsaddress, regaddress = [], [] - for i, obj in enumerate(queryset): - qsaddress.append(obj.address) - for addr in addresses: - regaddress.append(addr[2]) - return (qsaddress, regaddress) - - def test_address_ordering(self): # Setup Addresses @@ -290,22 +281,3 @@ class IPAddressOrderingTestCase(TestCase): # Test self._compare_address(IPAddress.objects.all(), addresses) - - def test_address_complex_ordering(self): - # Setup VRFs - vrfa, vrfb, vrfc = self.vrfs - - # Setup addresses - addresses = [ - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.1/25')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.1/24')), - ] - IPAddress.objects.bulk_create(self._create_address(addresses)) - - # Test - qsaddresses, compaddresses = self._compare_complex(IPAddress.objects.all(), addresses) - self.assertEquals(qsaddresses, compaddresses) From d30d79b4e34a7a7d815379a2ec742ae6bb415984 Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Wed, 29 Jan 2020 12:55:19 -0600 Subject: [PATCH 009/106] Cleanup Imports --- netbox/ipam/tests/test_ordering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 8ee71c04f..6b2d6ad08 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -1,7 +1,7 @@ from django.test import TestCase from ipam.choices import IPAddressStatusChoices, PrefixStatusChoices -from ipam.models import Aggregate, IPAddress, Prefix, RIR, VLAN, VLANGroup, VRF +from ipam.models import IPAddress, Prefix, VRF import netaddr From ff822743cc1d4de33fe94a5cbb8700c97dfc63b3 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 30 Jan 2020 18:10:39 +0000 Subject: [PATCH 010/106] Corrected linter warning --- netbox/dcim/models/device_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 68bab8037..e37569f79 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -676,7 +676,7 @@ class Interface(CableTermination, ComponentModel): self.untagged_vlan = None # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) - if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED: + if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: self.tagged_vlans.clear() return super().save(*args, **kwargs) From ae95b159bc033787886bc5b65ccf65bb1ebe840c Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 30 Jan 2020 18:26:30 +0000 Subject: [PATCH 011/106] Virtualization interfaces VLAN filtering --- netbox/virtualization/forms.py | 143 +++++++++------------------------ 1 file changed, 38 insertions(+), 105 deletions(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index ae516fcb3..018e14e85 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -648,7 +648,10 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = forms.ModelMultipleChoiceField( @@ -657,7 +660,10 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tags = TagField( @@ -685,51 +691,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site - vlan_choices = [] - global_vlans = VLAN.objects.filter(site=None, group=None) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - + # Add current site to VLANs query params site = getattr(self.instance.parent, 'site', None) if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices - self.fields['tagged_vlans'].choices = vlan_choices - - def clean(self): - super().clean() - - # Validate VLAN assignments - tagged_vlans = self.cleaned_data['tagged_vlans'] - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: - raise forms.ValidationError({ - 'mode': "An access interface cannot have tagged VLANs assigned." - }) - - # Remove all tagged VLAN assignments from "tagged all" interfaces - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) class InterfaceCreateForm(ComponentForm): @@ -769,7 +736,10 @@ class InterfaceCreateForm(ComponentForm): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = forms.ModelMultipleChoiceField( @@ -778,7 +748,10 @@ class InterfaceCreateForm(ComponentForm): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tags = TagField( @@ -793,35 +766,12 @@ class InterfaceCreateForm(ComponentForm): super().__init__(*args, **kwargs) - # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site - vlan_choices = [] - global_vlans = VLAN.objects.filter(site=None, group=None) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - + # Add current site to VLANs query params site = getattr(self.parent.cluster, 'site', None) if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices - self.fields['tagged_vlans'].choices = vlan_choices + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): @@ -854,7 +804,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) tagged_vlans = forms.ModelMultipleChoiceField( @@ -863,7 +816,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', - full=True + full=True, + additional_query_params={ + 'site_id': 'null', + }, ) ) @@ -875,35 +831,12 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site - vlan_choices = [] - global_vlans = VLAN.objects.filter(site=None, group=None) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - if self.parent_obj.cluster is not None: - site = getattr(self.parent_obj.cluster, 'site', None) - if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices - self.fields['tagged_vlans'].choices = vlan_choices + # Add current site to VLANs query params + site = getattr(self.parent_obj.cluster, 'site', None) + if site is not None: + # Add current site to VLANs query params + self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) + self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) # From ace8fac2c1232e5b93917d7c0c7afb24519c30be Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 30 Jan 2020 18:29:08 +0000 Subject: [PATCH 012/106] Removed changelog to avoid merge conflicts --- docs/release-notes/version-2.7.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 8caf5c17b..5c489a96c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -4,7 +4,6 @@ * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts -* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Only show the valid list of interface VLAN choices * [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps ## Bug Fixes From 8e9a0eeef00a9c2c76d9fce99969a040e73c1c15 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 31 Jan 2020 10:05:57 -0600 Subject: [PATCH 013/106] Fix PEP8 errors and document functions --- netbox/ipam/tests/test_ordering.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 6b2d6ad08..6818df399 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -16,6 +16,7 @@ class PrefixOrderingTestCase(TestCase): VRF.objects.bulk_create([vrfa, vrfb, vrfc]) self.vrfs = (vrfa, vrfb, vrfc) + # Function to create all prefixes def _create_prefix(self, prefixes): prefixobjects = [] for pfx in prefixes: @@ -28,22 +29,26 @@ class PrefixOrderingTestCase(TestCase): return prefixobjects + # Prefix Comparison function def _compare_prefix(self, queryset, prefixes): - + # Loop and compare each prefix for i, obj in enumerate(queryset): status, vrf, prefix = prefixes[i] self.assertEqual((obj.vrf, obj.prefix), (vrf, prefix)) + # Complex prefix comparison function def _compare_complex(self, queryset, prefixes): qsprefixes, regprefixes = [], [] + # Loop and build list of queryset prefixes to compare for i, obj in enumerate(queryset): qsprefixes.append(obj.prefix) + # Loop and build list of prefixes to compare for pfx in prefixes: regprefixes.append(pfx[2]) + + # Return two lists as a tuple return (qsprefixes, regprefixes) - - def test_prefix_ordering(self): # Setup Prefixes prefixes = ( @@ -176,8 +181,10 @@ class IPAddressOrderingTestCase(TestCase): VRF.objects.bulk_create([vrfa, vrfb, vrfc]) self.vrfs = (vrfa, vrfb, vrfc) + # Function to build all addresses def _create_address(self, addresses): addressobjects = [] + # Loop and build IPAddress object for addr in addresses: status, vrf, address = addr family = 4 @@ -188,13 +195,13 @@ class IPAddressOrderingTestCase(TestCase): return addressobjects + # Function to compare all addresses def _compare_address(self, queryset, addresses): - + # Loop and compare addresses for i, obj in enumerate(queryset): status, vrf, address = addresses[i] self.assertEqual((obj.vrf, obj.address), (vrf, address)) - def test_address_ordering(self): # Setup Addresses addresses = ( From 21f2e0b131776b600e7b231073441f45017704d5 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 6 Feb 2020 23:30:58 +0000 Subject: [PATCH 014/106] Changed navbar scrollbar to auto --- netbox/project-static/css/base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 704a7e9b0..7aee648aa 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -66,7 +66,7 @@ footer p { @media (min-width: 768px) { .navbar-nav>li>ul { max-height: 80vh; - overflow-y: scroll; + overflow-y: auto; } } @@ -74,7 +74,7 @@ footer p { @media (max-width: 979px) { #navbar { max-height: 80vh; - overflow-y: scroll; + overflow-y: auto; } .navbar-header { float: none; From d0e00162ed3164ebea2c288ef51d6878b81d4105 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 7 Feb 2020 00:14:05 +0000 Subject: [PATCH 015/106] Account for header height --- netbox/project-static/css/base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 7aee648aa..456eeab6f 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -65,7 +65,7 @@ footer p { /* Scroll the drop-down menus at or above 768px wide to match bootstrap's behavior for hiding dropdown menus */ @media (min-width: 768px) { .navbar-nav>li>ul { - max-height: 80vh; + max-height: calc(80vh - 50px); overflow-y: auto; } } @@ -73,7 +73,7 @@ footer p { /* Collapse the nav menu on displays less than 980px wide */ @media (max-width: 979px) { #navbar { - max-height: 80vh; + max-height: calc(80vh - 50px); overflow-y: auto; } .navbar-header { From 885ea8a4d5f7191afd564501e3b013b1c9ff85f6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 18:04:40 -0500 Subject: [PATCH 016/106] Override get_bound_field() on FilterChoiceFieldMixin to restrict the queryset of bound fields --- netbox/utilities/forms.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 355484673..28b906140 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -8,6 +8,7 @@ from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count +from django.forms import BoundField from mptt.forms import TreeNodeMultipleChoiceField from .choices import unpack_grouped_choices @@ -607,12 +608,26 @@ class FilterChoiceFieldMixin(object): kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) super().__init__(*args, **kwargs) - def label_from_instance(self, obj): - label = super().label_from_instance(obj) - obj_count = getattr(obj, self.count_attr, None) - if obj_count is not None: - return '{} ({})'.format(label, obj_count) - return label + # def label_from_instance(self, obj): + # label = super().label_from_instance(obj) + # obj_count = getattr(obj, self.count_attr, None) + # if obj_count is not None: + # return '{} ({})'.format(label, obj_count) + # return label + + def get_bound_field(self, form, field_name): + + bound_field = BoundField(form, self, field_name) + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + if bound_field.data: + kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} + self.queryset = self.queryset.filter(**kwargs) + else: + self.queryset = self.queryset.none() + + return bound_field class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): From 26ddd96e303b95e1c7f5224f652007a9bdebc941 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Sat, 8 Feb 2020 16:18:58 +0000 Subject: [PATCH 017/106] Cleaned duplicate code --- netbox/dcim/forms.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ed42e9914..52047151b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2871,18 +2871,16 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device (or VC master) if self.is_bound: device = Device.objects.get(pk=self.data['device']) - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) else: - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.instance.device, self.instance.device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) + device = self.instance.device + + # Limit LAG choices to interfaces belonging to this device (or VC master) + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], + type=InterfaceTypeChoices.TYPE_LAG + ) # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk) From d5c4a9d159cf2d76c589e175883f60ed6e8179cb Mon Sep 17 00:00:00 2001 From: kobayashi Date: Sun, 2 Feb 2020 15:45:34 -0500 Subject: [PATCH 018/106] Fixes #3507: Filtering IP by multiple devices --- docs/release-notes/version-2.7.md | 1 + netbox/ipam/filters.py | 15 ++++++++------- netbox/ipam/tests/test_filters.py | 11 +++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 44298fec3..81c4edd39 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -26,6 +26,7 @@ ## Bug Fixes +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices * [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised) * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 67ad769cc..082890f82 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -304,14 +304,15 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF to_field_name='rd', label='VRF (RD)', ) - device = django_filters.CharFilter( - method='filter_device', - field_name='name', - label='Device', + device = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', ) - device_id = django_filters.NumberFilter( - method='filter_device', - field_name='pk', + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device', + queryset=Device.objects.all(), label='Device (ID)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 80ada0df8..4737a0f53 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - # TODO: Test for multiple values def test_device(self): - device = Device.objects.first() - params = {'device_id': device.pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'device': device.name} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_virtual_machine(self): vms = VirtualMachine.objects.all()[:2] From 505cb9cab88ec735d99e4f2510abde20187ac88c Mon Sep 17 00:00:00 2001 From: kobayashi Date: Sun, 9 Feb 2020 00:58:54 -0500 Subject: [PATCH 019/106] Enabled filtering virtual chassis devices --- docs/release-notes/version-2.7.md | 2 +- netbox/ipam/filters.py | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 81c4edd39..7992e9ae9 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ ## Bug Fixes +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form @@ -26,7 +27,6 @@ ## Bug Fixes -* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices * [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised) * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 082890f82..5f8bcabff 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -304,15 +304,14 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF to_field_name='rd', label='VRF (RD)', ) - device = django_filters.ModelMultipleChoiceFilter( - field_name='interface__device__name', - queryset=Device.objects.all(), - to_field_name='name', + device = MultiValueCharFilter( + method='filter_device', + field_name='name', label='Device (name)', ) - device_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface__device', - queryset=Device.objects.all(), + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', label='Device (ID)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( @@ -386,8 +385,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF def filter_device(self, queryset, name, value): try: - device = Device.objects.prefetch_related('device_type').get(**{name: value}) - vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] + devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) + vc_interface_ids = [] + for device in devices: + vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) return queryset.filter(interface_id__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() From 5ddfde2214f535239652fd6ab3ef00f7436a24a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 09:40:34 -0500 Subject: [PATCH 020/106] Clean up unneeded code relevant to FilterChoiceField --- netbox/dcim/forms.py | 11 -------- netbox/ipam/forms.py | 8 ------ netbox/tenancy/forms.py | 3 --- netbox/utilities/forms.py | 47 ++++++---------------------------- netbox/virtualization/forms.py | 7 ----- 5 files changed, 8 insertions(+), 68 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3c3ae8b2e..81a0be2d2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -734,7 +734,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): 'site' ), label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True @@ -748,7 +747,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): role = FilterChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-roles/", value_field="slug", @@ -874,7 +872,6 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): group_id = FilterChoiceField( queryset=RackGroup.objects.prefetch_related('site'), label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -2182,7 +2179,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, @@ -2219,7 +2215,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt platform = FilterChoiceField( queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", @@ -3913,7 +3908,6 @@ class CableFilterForm(BootstrapMixin, forms.Form): rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, @@ -4471,7 +4465,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -4484,7 +4477,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", @@ -4592,7 +4584,6 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_group_id = FilterChoiceField( queryset=RackGroup.objects.all(), label='Rack group (ID)', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -4826,7 +4817,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): power_panel_id = FilterChoiceField( queryset=PowerPanel.objects.all(), label='Power panel', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/power-panels/", null_option=True, @@ -4835,7 +4825,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 24f044f79..71aa73d18 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -528,7 +528,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) vrf_id = FilterChoiceField( queryset=VRF.objects.all(), label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -554,7 +553,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -564,7 +562,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) role = FilterChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", @@ -999,7 +996,6 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo vrf_id = FilterChoiceField( queryset=VRF.objects.all(), label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -1080,7 +1076,6 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1279,7 +1274,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1289,7 +1283,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): group_id = FilterChoiceField( queryset=VLANGroup.objects.all(), label='VLAN group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/vlan-groups/", null_option=True, @@ -1303,7 +1296,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): role = FilterChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b0468b37a..4babd753f 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -108,7 +108,6 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -163,7 +162,6 @@ class TenancyFilterForm(forms.Form): tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -176,7 +174,6 @@ class TenancyFilterForm(forms.Form): tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 28b906140..464495fa0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -582,47 +582,24 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class FilterChoiceIterator(forms.models.ModelChoiceIterator): - - def __iter__(self): - # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string) - if self.field.null_label is not None: - yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label) - queryset = self.queryset.all() - # Can't use iterator() when queryset uses prefetch_related() - if not queryset._prefetch_related_lookups: - queryset = queryset.iterator() - for obj in queryset: - yield self.choice(obj) - - -class FilterChoiceFieldMixin(object): - iterator = FilterChoiceIterator - - def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs): - self.null_label = null_label - self.count_attr = count_attr +class FilterChoiceField(forms.ModelMultipleChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ + def __init__(self, *args, **kwargs): + # Filter fields are not required by default if 'required' not in kwargs: kwargs['required'] = False - if 'widget' not in kwargs: - kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) super().__init__(*args, **kwargs) - # def label_from_instance(self, obj): - # label = super().label_from_instance(obj) - # obj_count = getattr(obj, self.count_attr, None) - # if obj_count is not None: - # return '{} ({})'.format(label, obj_count) - # return label - def get_bound_field(self, form, field_name): - bound_field = BoundField(form, self, field_name) # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. if bound_field.data: - kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} + kwargs = {'{}__in'.format(self.to_field_name): bound_field.data} self.queryset = self.queryset.filter(**kwargs) else: self.queryset = self.queryset.none() @@ -630,14 +607,6 @@ class FilterChoiceFieldMixin(object): return bound_field -class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): - pass - - -class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): - pass - - class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 1560a683f..96136b4de 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -213,7 +213,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", @@ -224,7 +223,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --', required=False, widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", @@ -562,7 +560,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/virtualization/cluster-groups/', value_field="slug", @@ -572,7 +569,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_type = FilterChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/virtualization/cluster-types/', value_field="slug", @@ -601,7 +597,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/dcim/sites/', value_field="slug", @@ -611,7 +606,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil role = FilterChoiceField( queryset=DeviceRole.objects.filter(vm_role=True), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/dcim/device-roles/', value_field="slug", @@ -629,7 +623,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil platform = FilterChoiceField( queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/dcim/platforms/', value_field="slug", From 55f5ede9708d23b5daaa79d687efde19929b3569 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 09:58:33 -0500 Subject: [PATCH 021/106] Standardize usage of FilterChoiceField --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 3 +-- netbox/extras/forms.py | 7 +++++-- netbox/utilities/forms.py | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index caf8d9d36..39b694b1c 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -311,7 +311,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = FilterChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 81a0be2d2..b12d273a9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -369,10 +369,9 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = FilterChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 8c9113d39..f9b765379 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -387,11 +387,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): ) action = forms.ChoiceField( choices=add_blank_choice(ObjectChangeActionChoices), - required=False + required=False, + widget=StaticSelect2() ) + # TODO: Convert to FilterChoiceField once we have an API endpoint for users user = forms.ModelChoiceField( queryset=User.objects.order_by('username'), - required=False + required=False, + widget=StaticSelect2() ) changed_object_type = forms.ModelChoiceField( queryset=ContentType.objects.order_by('model'), diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 464495fa0..7b31f1e94 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -212,7 +212,7 @@ class SelectWithPK(StaticSelect2): option_template_name = 'widgets/select_option_with_pk.html' -class ContentTypeSelect(forms.Select): +class ContentTypeSelect(StaticSelect2): """ Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example: From 009fc4f301e9a74016b03ecbaea6e9095c8f6f9c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:02:42 -0500 Subject: [PATCH 022/106] Remove custom template for APISelect widget --- netbox/utilities/forms.py | 3 --- netbox/utilities/templates/widgets/select_api.html | 9 --------- 2 files changed, 12 deletions(-) delete mode 100644 netbox/utilities/templates/widgets/select_api.html diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 7b31f1e94..85fb762bb 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -260,9 +260,6 @@ class APISelect(SelectWithDisabled): name of the query param and the value if the query param's value. :param null_option: If true, include the static null option in the selection list. """ - # Only preload the selected option(s); new options are dynamically displayed and added via the API - template_name = 'widgets/select_api.html' - def __init__( self, api_url, diff --git a/netbox/utilities/templates/widgets/select_api.html b/netbox/utilities/templates/widgets/select_api.html deleted file mode 100644 index d9516086b..000000000 --- a/netbox/utilities/templates/widgets/select_api.html +++ /dev/null @@ -1,9 +0,0 @@ - From 5008526db145059f66031f2e7b0fc6180327ed22 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:08:20 -0500 Subject: [PATCH 023/106] Set a default self.to_field_name for FilterChoiceField --- netbox/utilities/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 85fb762bb..cd78b249e 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -596,7 +596,7 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. if bound_field.data: - kwargs = {'{}__in'.format(self.to_field_name): bound_field.data} + kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} self.queryset = self.queryset.filter(**kwargs) else: self.queryset = self.queryset.none() From d4789b7c9e14ed5b180230c0511774c4c8c01de2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:20:06 -0500 Subject: [PATCH 024/106] Changelog for #4108 --- docs/release-notes/version-2.7.md | 1 + netbox/utilities/forms.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 44298fec3..fedf9f170 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -13,6 +13,7 @@ * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list +* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms # v2.7.4 (2020-02-04) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index cd78b249e..8c0f0d8d1 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -9,7 +9,6 @@ from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count from django.forms import BoundField -from mptt.forms import TreeNodeMultipleChoiceField from .choices import unpack_grouped_choices from .constants import * From 26ca6b4a8445cea3645334f7e14f1a718edbfebd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:57:23 -0500 Subject: [PATCH 025/106] #4108: Fix null choice population --- netbox/project-static/js/forms.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b1ba8a37c..4e1c9b0cc 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -220,19 +220,19 @@ $(document).ready(function() { } if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { - results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] } + results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }; results[record.site.name + ":" + record.group.name].children.push(record); } else if( record.group !== undefined && record.group !== null ) { - results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] } + results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }; results[record.group.name].children.push(record); } else if( record.site !== undefined && record.site !== null ) { - results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] } + results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }; results[record.site.name].children.push(record); } else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { - results['global'] = results['global'] || { text: 'Global', children: [] } + results['global'] = results['global'] || { text: 'Global', children: [] }; results['global'].children.push(record); } else { @@ -246,10 +246,9 @@ $(document).ready(function() { // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { - var null_option = $(element).children()[0]; results.unshift({ - id: null_option.value, - text: null_option.text + id: 'null', + text: 'None' }); } From a966a4c8ace7594062326172c5f9ea71b159ac00 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 11:43:51 -0500 Subject: [PATCH 026/106] Closes #4129: Add individual deletion views for device type components --- docs/release-notes/version-2.7.md | 3 ++ netbox/dcim/tables.py | 5 +++ netbox/dcim/tests/test_views.py | 8 ---- netbox/dcim/urls.py | 8 ++++ netbox/dcim/views.py | 70 ++++++++++++++++++++++++++++++- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 1415be129..0aa62555b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -6,6 +6,7 @@ * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views +* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components ## Bug Fixes @@ -16,6 +17,8 @@ * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms +--- + # v2.7.4 (2020-02-04) ## Enhancements diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 473d465bd..1f67b93f1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -200,6 +200,11 @@ def get_component_template_actions(model_name): {{% endif %}} + {{% if perms.dcim.delete_{model_name} %}} + + + + {{% endif %}} """.format(model_name=model_name).strip() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index f8282833c..75e3f9871 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -535,7 +535,6 @@ class ConsolePortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -580,7 +579,6 @@ class ConsoleServerPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -625,7 +623,6 @@ class PowerPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -676,7 +673,6 @@ class PowerOutletTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -727,7 +723,6 @@ class InterfaceTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -775,7 +770,6 @@ class FrontPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -831,7 +825,6 @@ class RearPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -878,7 +871,6 @@ class DeviceBayTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None test_bulk_edit_objects = None diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 07d86cc36..165ca9e02 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -95,48 +95,56 @@ urlpatterns = [ path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'), path('console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), + path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'), # Console server port templates path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'), path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'), path('console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), + path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'), # Power port templates path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'), path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'), path('power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), + path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'), # Power outlet templates path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'), path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'), path('power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), + path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'), # Interface templates path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'), path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), + path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'), # Front port templates path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'), path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'), path('front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), + path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'), # Rear port templates path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'), path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'), path('rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), + path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'), # Device bay templates path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), + path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 824961b3e..ae59890a3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -700,7 +700,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Device type components +# Console port templates # class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -717,6 +717,11 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.ConsolePortTemplateForm +class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleporttemplate' + model = ConsolePortTemplate + + class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_consoleporttemplate' queryset = ConsolePortTemplate.objects.all() @@ -730,6 +735,10 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) table = tables.ConsolePortTemplateTable +# +# Console server port templates +# + class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverporttemplate' model = ConsoleServerPortTemplate @@ -744,6 +753,11 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView) model_form = forms.ConsoleServerPortTemplateForm +class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleserverporttemplate' + model = ConsoleServerPortTemplate + + class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_consoleserverporttemplate' queryset = ConsoleServerPortTemplate.objects.all() @@ -757,6 +771,10 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet table = tables.ConsoleServerPortTemplateTable +# +# Power port templates +# + class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerporttemplate' model = PowerPortTemplate @@ -771,6 +789,11 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerPortTemplateForm +class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerporttemplate' + model = PowerPortTemplate + + class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_powerporttemplate' queryset = PowerPortTemplate.objects.all() @@ -784,6 +807,10 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.PowerPortTemplateTable +# +# Power outlet templates +# + class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlettemplate' model = PowerOutletTemplate @@ -798,6 +825,11 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerOutletTemplateForm +class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_poweroutlettemplate' + model = PowerOutletTemplate + + class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_poweroutlettemplate' queryset = PowerOutletTemplate.objects.all() @@ -811,6 +843,10 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) table = tables.PowerOutletTemplateTable +# +# Interface templates +# + class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interfacetemplate' model = InterfaceTemplate @@ -825,6 +861,11 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.InterfaceTemplateForm +class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_interfacetemplate' + model = InterfaceTemplate + + class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' queryset = InterfaceTemplate.objects.all() @@ -838,6 +879,10 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTemplateTable +# +# Front port templates +# + class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontporttemplate' model = FrontPortTemplate @@ -852,6 +897,11 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.FrontPortTemplateForm +class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_frontporttemplate' + model = FrontPortTemplate + + class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_frontporttemplate' queryset = FrontPortTemplate.objects.all() @@ -865,6 +915,10 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.FrontPortTemplateTable +# +# Rear port templates +# + class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearporttemplate' model = RearPortTemplate @@ -879,6 +933,11 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.RearPortTemplateForm +class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rearporttemplate' + model = RearPortTemplate + + class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rearporttemplate' queryset = RearPortTemplate.objects.all() @@ -892,6 +951,10 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.RearPortTemplateTable +# +# Device bay templates +# + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' model = DeviceBayTemplate @@ -906,6 +969,11 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.DeviceBayTemplateForm +class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_devicebaytemplate' + model = DeviceBayTemplate + + # class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): # permission_required = 'dcim.change_devicebaytemplate' # queryset = DeviceBayTemplate.objects.all() From 7388fa35568f9605473457fbf97fe5acd7cc6992 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 15:10:33 -0500 Subject: [PATCH 027/106] Fixes #4083: Permit nullifying applicable choice fields via API requests --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/api/serializers.py | 28 +++++++++++++++--------- netbox/ipam/api/serializers.py | 4 ++-- netbox/utilities/api.py | 21 ++++++++++++++++-- netbox/virtualization/api/serializers.py | 2 +- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 0aa62555b..e789c9803 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -11,6 +11,7 @@ ## Bug Fixes * [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices +* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f0382a3f5..234a9fb1c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True) + subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) @@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) @@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = PowerPortTemplateSerializer( @@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) class Meta: @@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) @@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = NestedPowerPortSerializer( @@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) cable = NestedCableSerializer( read_only=True @@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) - length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True) + length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: model = Cable diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e52c172e5..e6d9adecd 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) - role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True) + role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) @@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) - protocol = ChoiceField(choices=ServiceProtocolChoices) + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), serializer=NestedIPAddressSerializer, diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 5ef4156aa..95de2a25d 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): class ChoiceField(Field): """ - Represent a ChoiceField as {'value': , 'label': }. + Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. + + :param choices: An iterable of choices in the form (value, key). + :param allow_blank: Allow blank values in addition to the listed choices. """ - def __init__(self, choices, **kwargs): + def __init__(self, choices, allow_blank=False, **kwargs): self.choiceset = choices + self.allow_blank = allow_blank self._choices = dict() # Unpack grouped choices @@ -77,6 +81,15 @@ class ChoiceField(Field): super().__init__(**kwargs) + def validate_empty_values(self, data): + # Convert null to an empty string unless allow_null == True + if data is None: + if self.allow_null: + return True, None + else: + data = '' + return super().validate_empty_values(data) + def to_representation(self, obj): if obj is '': return None @@ -93,6 +106,10 @@ class ChoiceField(Field): return data def to_internal_value(self, data): + if data is '': + if self.allow_blank: + return data + raise ValidationError("This field may not be blank.") # Provide an explicit error message if the request is trying to write a dict or list if isinstance(data, (dict, list)): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 8725cbee1..a294cdb6f 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) - mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True) + mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), From 5ea30c862846e82b5778c99419b4be340e929657 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 17:23:52 -0500 Subject: [PATCH 028/106] Replace ChainedModelChoiceField with DynamicModelChoiceField --- netbox/dcim/forms.py | 90 ++++++++----------------------- netbox/ipam/forms.py | 40 ++++---------- netbox/tenancy/forms.py | 15 +++--- netbox/utilities/forms.py | 97 ++++++++-------------------------- netbox/virtualization/forms.py | 29 +++------- 5 files changed, 67 insertions(+), 204 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b12d273a9..29d5ba551 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -22,9 +22,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ConfirmationForm, - CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, - SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, + ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -472,11 +472,8 @@ class RackRoleCSVForm(forms.ModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - group = ChainedModelChoiceField( + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -761,13 +758,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] - id = ChainedModelChoiceField( + id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - chains=( - ('site', 'site'), - ('group_id', 'group_id'), - ), required=False, widget=APISelectMultiple( api_url='/api/dcim/racks/', @@ -1706,11 +1699,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -1737,11 +1727,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - device_type = ChainedModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - chains=( - ('manufacturer', 'manufacturer'), - ), label='Device type', widget=APISelect( api_url='/api/dcim/device-types/', @@ -1761,11 +1748,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), required=False, widget=APISelect( api_url='/api/virtualization/clusters/', @@ -3433,7 +3417,7 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ @@ -3449,11 +3433,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_rack = ChainedModelChoiceField( + termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'termination_b_site'), - ), label='Rack', required=False, widget=APISelect( @@ -3466,12 +3447,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_device = ChainedModelChoiceField( + termination_b_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack', 'termination_b_rack'), - ), label='Device', required=False, widget=APISelect( @@ -3569,7 +3546,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): termination_b_provider = forms.ModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -3581,7 +3558,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3592,11 +3569,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_circuit = ChainedModelChoiceField( + termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), - chains=( - ('provider', 'termination_b_provider'), - ), label='Circuit', widget=APISelect( api_url='/api/circuits/circuits/', @@ -3623,7 +3597,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f ] -class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', @@ -3637,12 +3611,9 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_rackgroup = ChainedModelChoiceField( + termination_b_rackgroup = DynamicModelChoiceField( queryset=RackGroup.objects.all(), label='Rack Group', - chains=( - ('site', 'termination_b_site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -3652,12 +3623,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_powerpanel = ChainedModelChoiceField( + termination_b_powerpanel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack_group', 'termination_b_rackgroup'), - ), label='Power Panel', required=False, widget=APISelect( @@ -4380,10 +4347,9 @@ class DeviceVCMembershipForm(forms.ModelForm): return vc_position -class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): +class VCMemberSelectForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField( queryset=Site.objects.all(), - label='Site', required=False, widget=APISelect( api_url="/api/dcim/sites/", @@ -4393,12 +4359,8 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -4410,15 +4372,10 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - device = ChainedModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.filter( virtual_chassis__isnull=True ), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', widget=APISelect( api_url='/api/dcim/devices/', display_field='display_name', @@ -4490,11 +4447,8 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): - rack_group = ChainedModelChoiceField( + rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -4595,7 +4549,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 71aa73d18..059587082 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,9 +10,9 @@ from extras.forms import ( from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, - CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, - SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, + DatePicker, DynamicModelChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, + ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine from .constants import * @@ -271,7 +271,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', widget=APISelect( api_url="/api/dcim/sites/", filter_for={ @@ -283,11 +282,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - vlan_group = ChainedModelChoiceField( + vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, label='VLAN group', widget=APISelect( @@ -300,12 +296,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - vlan = ChainedModelChoiceField( + vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), required=False, label='VLAN', widget=APISelect( @@ -603,11 +595,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_rack = ChainedModelChoiceField( + nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'nat_site'), - ), required=False, label='Rack', widget=APISelect( @@ -621,12 +610,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_device = ChainedModelChoiceField( + nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'nat_site'), - ('rack', 'nat_rack'), - ), required=False, label='Device', widget=APISelect( @@ -648,11 +633,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_inside = ChainedModelChoiceField( + nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), - chains=( - ('interface__device', 'nat_device'), - ), required=False, label='IP Address', widget=APISelect( @@ -1102,13 +1084,9 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - group = ChainedModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, - label='Group', widget=APISelect( api_url='/api/ipam/vlan-groups/', ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 4babd753f..553e79f1b 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,11 +2,11 @@ from django import forms from taggit.forms import TagField from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, - FilterChoiceField, SlugField, TagFilterField + APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, FilterChoiceField, SlugField, + TagFilterField, ) from .models import Tenant, TenantGroup @@ -121,8 +121,8 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): # Form extensions # -class TenancyForm(ChainedFieldsMixin, forms.Form): - tenant_group = forms.ModelChoiceField( +class TenancyForm(forms.Form): + tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, widget=APISelect( @@ -135,11 +135,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): } ) ) - tenant = ChainedModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - chains=( - ('group', 'tenant_group'), - ), required=False, widget=APISelect( api_url='/api/tenancy/tenants/' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 8c0f0d8d1..49d7e882d 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -522,34 +522,6 @@ class FlexibleModelChoiceField(forms.ModelChoiceField): return value -class ChainedModelChoiceField(forms.ModelChoiceField): - """ - A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary - mapping of model fields to peer fields within the form. For example: - - country1 = forms.ModelChoiceField(queryset=Country.objects.all()) - city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'} - - The queryset of the `city1` field will be modified as - - .filter(country=) - - where is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.) - """ - def __init__(self, chains=None, *args, **kwargs): - self.chains = chains - super().__init__(*args, **kwargs) - - -class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): - """ - See ChainedModelChoiceField - """ - def __init__(self, chains=None, *args, **kwargs): - self.chains = chains - super().__init__(*args, **kwargs) - - class SlugField(forms.SlugField): """ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. @@ -578,16 +550,12 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class FilterChoiceField(forms.ModelMultipleChoiceField): +class DynamicModelChoiceField(forms.ModelChoiceField): """ Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. """ - def __init__(self, *args, **kwargs): - # Filter fields are not required by default - if 'required' not in kwargs: - kwargs['required'] = False - super().__init__(*args, **kwargs) + field_modifier = '' def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) @@ -595,7 +563,8 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. if bound_field.data: - kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} + field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) + kwargs = {field_name: bound_field.data} self.queryset = self.queryset.filter(**kwargs) else: self.queryset = self.queryset.none() @@ -603,6 +572,24 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): return bound_field +class DynamicModelMultipleChoiceField(DynamicModelChoiceField): + """ + A multiple-choice version of DynamicModelChoiceField. + """ + field_modifier = '__in' + + +class FilterChoiceField(DynamicModelMultipleChoiceField): + """ + A version of DynamicModelMultipleChoiceField which defaults to required=False. + """ + def __init__(self, *args, **kwargs): + # Filter fields are not required by default + if 'required' not in kwargs: + kwargs['required'] = False + super().__init__(*args, **kwargs) + + class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: @@ -655,46 +642,6 @@ class BootstrapMixin(forms.BaseForm): field.widget.attrs['placeholder'] = field.label -class ChainedFieldsMixin(forms.BaseForm): - """ - Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - for field_name, field in self.fields.items(): - - if isinstance(field, ChainedModelChoiceField): - - filters_dict = {} - for (db_field, parent_field) in field.chains: - if self.is_bound and parent_field in self.data and self.data[parent_field]: - filters_dict[db_field] = self.data[parent_field] or None - elif self.initial.get(parent_field): - filters_dict[db_field] = self.initial[parent_field] - elif self.fields[parent_field].widget.attrs.get('nullable'): - filters_dict[db_field] = None - else: - break - - # Limit field queryset by chained field values - if filters_dict: - field.queryset = field.queryset.filter(**filters_dict) - # Editing an existing instance; limit field to its current value - elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name): - obj = getattr(self.instance, field_name) - if obj is not None: - field.queryset = field.queryset.filter(pk=obj.pk) - else: - field.queryset = field.queryset.none() - # Creating a new instance with no bound data; nullify queryset - elif not self.data.get(field_name): - field.queryset = field.queryset.none() - # Creating a new instance with bound data; limit queryset to the specified value - else: - field.queryset = field.queryset.filter(pk=self.data.get(field_name)) - - class ReturnURLForm(forms.Form): """ Provides a hidden return URL field to control where the user is directed after the form is submitted. diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 96136b4de..8c66f1c23 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -14,9 +14,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ConfirmationForm, - CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, + TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -233,7 +233,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm tag = TagFilterField(model) -class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): +class ClusterAddDevicesForm(BootstrapMixin, forms.Form): region = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, @@ -247,11 +247,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), - chains=( - ('region', 'region'), - ), required=False, widget=APISelect( api_url='/api/dcim/sites/', @@ -261,11 +258,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -277,12 +271,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - devices = ChainedModelMultipleChoiceField( + devices = DynamicModelMultipleChoiceField( queryset=Device.objects.filter(cluster__isnull=True), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), widget=APISelectMultiple( api_url='/api/dcim/devices/', display_field='display_name', @@ -342,11 +332,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), widget=APISelect( api_url='/api/virtualization/clusters/' ) From ff4e6bd1667048efe60f4ae4f53330fbd49eff44 Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Mon, 10 Feb 2020 21:37:40 -0600 Subject: [PATCH 029/106] Update tests Add docstrings Consolidate tests --- netbox/ipam/tests/test_ordering.py | 358 +++++++++++------------------ 1 file changed, 128 insertions(+), 230 deletions(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 6b2d6ad08..c1707740e 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -5,279 +5,177 @@ from ipam.models import IPAddress, Prefix, VRF import netaddr - -class PrefixOrderingTestCase(TestCase): +class OrderingTestBase(TestCase): vrfs = None + """ + Setup the VRFs for the class as a whole + """ def setUp(self): - vrfa = VRF(name="VRF A") - vrfb = VRF(name="VRF B") - vrfc = VRF(name="VRF C") - VRF.objects.bulk_create([vrfa, vrfb, vrfc]) - self.vrfs = (vrfa, vrfb, vrfc) - - def _create_prefix(self, prefixes): - prefixobjects = [] - for pfx in prefixes: - status, vrf, prefix = pfx - family = 4 - if not netaddr.valid_ipv4(prefix): - family = 6 - pfx = Prefix(prefix=prefix, family=family, vrf=vrf, status=status) - prefixobjects.append(pfx) - - return prefixobjects - - def _compare_prefix(self, queryset, prefixes): + self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C")) + VRF.objects.bulk_create(self.vrfs) + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ + def _compare(self, queryset, objectset): for i, obj in enumerate(queryset): - status, vrf, prefix = prefixes[i] - self.assertEqual((obj.vrf, obj.prefix), (vrf, prefix)) + if isinstance(obj, Prefix): + self.assertEqual((obj.vrf, obj.prefix), (objectset[i]['vrf'], objectset[i]['prefix'])) + elif isinstance(obj, IPAddress): + self.assertEqual((obj.vrf, obj.address), (objectset[i]['vrf'], objectset[i]['address'])) + +class PrefixOrderingTestCase(OrderingTestBase): + + """ + This is for comparing the complex ordering test case + """ def _compare_complex(self, queryset, prefixes): qsprefixes, regprefixes = [], [] for i, obj in enumerate(queryset): qsprefixes.append(obj.prefix) for pfx in prefixes: - regprefixes.append(pfx[2]) - return (qsprefixes, regprefixes) - - - - def test_prefix_ordering(self): - # Setup Prefixes - prefixes = ( - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/8')), - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.4.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.1.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.4.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.2.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.4.0/24')), - - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('172.16.0.0/12')), - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('172.16.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.4.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('172.17.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.4.0/24')), - - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('192.168.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.0/24')) - ) - Prefix.objects.bulk_create(self._create_prefix(prefixes)) - - # Test - self._compare_prefix(Prefix.objects.all(), prefixes) + regprefixes.append(pfx['prefix']) + self.assertEquals(qsprefixes, regprefixes) + """ + This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs + """ def test_prefix_vrf_ordering(self): # Setup VRFs vrfa, vrfb, vrfc = self.vrfs # Setup Prefixes prefixes = ( - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('192.168.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.0/24')), + {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.0.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.2.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.3.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.4.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.5.0/24')}, - (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.0.0.0/8')), - (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.0.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.4.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.1.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.4.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.2.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.4.0/24')), + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/8')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.2.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.3.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.4.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.2.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.3.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.4.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.2.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.3.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.4.0/24')}, - (PrefixStatusChoices.STATUS_CONTAINER, vrfb, netaddr.IPNetwork('172.16.0.0/12')), - (PrefixStatusChoices.STATUS_CONTAINER, vrfb, netaddr.IPNetwork('172.16.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.4.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, vrfb, netaddr.IPNetwork('172.17.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.2.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.3.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.4.0/24')), + {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.0.0/12')}, + {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.0.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.2.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.3.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.4.0/24')}, + {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.0.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.2.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.3.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.4.0/24')}, ) - Prefix.objects.bulk_create(self._create_prefix(prefixes)) + + Prefix.objects.bulk_create([Prefix(status=args['status'], vrf=args['vrf'], family=args['family'], prefix=args['prefix']) for args in prefixes]) # Test - self._compare_prefix(Prefix.objects.all(), prefixes) + self._compare(Prefix.objects.all(), prefixes) + """ + This function tests a compex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs + This includes the testing of the Container status. + + The proper ordering, to get proper containerization should be: + None:10.0.0.0/8 + None:10.0.0.0/16 + VRF A:10.0.0.0/24 + VRF A:10.0.1.0/24 + VRF A:10.0.1.0/25 + None:10.1.0.0/16 + VRF A:10.1.0.0/24 + VRF A:10.1.1.0/24 + None: 192.168.0.0/16 + """ def test_prefix_complex_ordering(self): # Setup VRFs vrfa, vrfb, vrfc = self.vrfs # Setup Prefixes prefixes = [ - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/8')), - (PrefixStatusChoices.STATUS_CONTAINER, None, netaddr.IPNetwork('10.0.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.0/16')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.0/24')), - (PrefixStatusChoices.STATUS_CONTAINER, vrfa, netaddr.IPNetwork('10.0.1.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.0/25')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.0.0/24')), - (PrefixStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.0/24')) + {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/8')}, + {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('10.1.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.0.0/16')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.1.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.1.0/25')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.0.0/24')}, + {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.1.0/24')}, ] - Prefix.objects.bulk_create(self._create_prefix(prefixes)) + Prefix.objects.bulk_create([Prefix(status=args['status'], vrf=args['vrf'], family=args['family'], prefix=args['prefix']) for args in prefixes]) # Test - qsprefixes, compprefixes = self._compare_complex(Prefix.objects.all(), prefixes) - self.assertEquals(qsprefixes, compprefixes) + self._compare_complex(Prefix.objects.all(), prefixes) -class IPAddressOrderingTestCase(TestCase): - vrfs = None - - def setUp(self): - vrfa = VRF(name="VRF A") - vrfb = VRF(name="VRF B") - vrfc = VRF(name="VRF C") - VRF.objects.bulk_create([vrfa, vrfb, vrfc]) - self.vrfs = (vrfa, vrfb, vrfc) - - def _create_address(self, addresses): - addressobjects = [] - for addr in addresses: - status, vrf, address = addr - family = 4 - if not netaddr.valid_ipv4(address): - family = 6 - addressobj = IPAddress(address=address, vrf=vrf, status=status, family=family) - addressobjects.append(addressobj) - - return addressobjects - - def _compare_address(self, queryset, addresses): - - for i, obj in enumerate(queryset): - status, vrf, address = addresses[i] - self.assertEqual((obj.vrf, obj.address), (vrf, address)) - - - def test_address_ordering(self): - # Setup Addresses - addresses = ( - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.0.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.1.4.0/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('10.2.4.1/24')), - - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.16.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('172.17.4.1/24')), - - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.1/24')) - ) - IPAddress.objects.bulk_create(self._create_address(addresses)) - - # Test - self._compare_address(IPAddress.objects.all(), addresses) - +class IPAddressOrderingTestCase(OrderingTestBase): + """ + This function tests ordering with the inclusion of vrfs + """ def test_address_vrf_ordering(self): # Setup VRFs vrfa, vrfb, vrfc = self.vrfs # Setup Addresses addresses = ( - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.0.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.1.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfa, netaddr.IPNetwork('10.2.4.1/24')), + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.0.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.1.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.2.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.3.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.4.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.0.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.1.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.2.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.3.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.4.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.0.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.1.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.2.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.3.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.4.1/24')}, - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.16.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, vrfb, netaddr.IPNetwork('172.17.4.1/24')), + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.0.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.1.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.2.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.3.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.4.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.0.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.1.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.2.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.3.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.4.1/24')}, - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.0.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.1.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.2.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.3.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.4.1/24')), - (IPAddressStatusChoices.STATUS_ACTIVE, None, netaddr.IPNetwork('192.168.5.1/24')), + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.0.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.1.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.2.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.3.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.4.1/24')}, + {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.5.1/24')}, ) - IPAddress.objects.bulk_create(self._create_address(addresses)) + IPAddress.objects.bulk_create([IPAddress(status=args['status'], vrf=args['vrf'], family=args['family'], address=args['address']) for args in addresses]) # Test - self._compare_address(IPAddress.objects.all(), addresses) + self._compare(IPAddress.objects.all(), addresses) From da68968d7561f7d6a51f9db42a53c8551a9d6bb3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 09:26:39 -0500 Subject: [PATCH 030/106] Replace FilterChoiceField with DynamicModelMultipleChoiceField --- netbox/circuits/forms.py | 18 +++-- netbox/dcim/forms.py | 138 ++++++++++++++++++++------------- netbox/extras/forms.py | 33 +++++--- netbox/ipam/forms.py | 38 +++++---- netbox/secrets/forms.py | 5 +- netbox/tenancy/forms.py | 13 ++-- netbox/utilities/forms.py | 11 --- netbox/virtualization/forms.py | 31 ++++---- 8 files changed, 168 insertions(+), 119 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 39b694b1c..77683e530 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,7 +9,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField + DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -107,7 +107,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -119,9 +119,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -290,17 +291,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label='Search' ) - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/circuit-types/", value_field="slug", ) ) - provider = FilterChoiceField( + provider = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/providers/", value_field="slug", @@ -311,7 +314,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -323,9 +326,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 29d5ba551..478474fa3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,8 +23,8 @@ from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, - ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, - StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -66,7 +66,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -78,9 +78,10 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -89,7 +90,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -369,9 +370,10 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", @@ -419,7 +421,7 @@ class RackGroupCSVForm(forms.ModelForm): class RackGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -431,9 +433,10 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -702,7 +705,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -714,9 +717,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -725,10 +729,11 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related( 'site' ), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -740,9 +745,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/rack-roles/", value_field="slug", @@ -758,7 +764,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] - id = FilterChoiceField( + id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label='Rack', required=False, @@ -853,16 +859,18 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related('site'), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -968,9 +976,10 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -2123,7 +2132,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -2135,9 +2144,10 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -2147,10 +2157,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.prefetch_related( - 'site' - ), + rack_group_id = DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -2159,24 +2168,27 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - manufacturer_id = FilterChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), + required=False, label='Manufacturer', widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", @@ -2185,19 +2197,19 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - device_type_id = FilterChoiceField( - queryset=DeviceType.objects.prefetch_related( - 'manufacturer' - ), + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, label='Model', widget=APISelectMultiple( api_url="/api/dcim/device-types/", display_field="model", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", @@ -3848,9 +3860,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -3860,9 +3873,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field='slug', @@ -3871,8 +3885,9 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', widget=APISelectMultiple( api_url="/api/dcim/racks/", @@ -3897,7 +3912,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4029,9 +4044,10 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4040,7 +4056,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4051,9 +4067,10 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4062,7 +4079,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4073,9 +4090,10 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4084,7 +4102,7 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4219,7 +4237,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4231,9 +4249,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4242,7 +4261,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4250,9 +4269,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): api_url='/api/dcim/devices/', ) ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -4398,7 +4418,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4410,17 +4430,19 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -4430,9 +4452,10 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", @@ -4511,7 +4534,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4523,9 +4546,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4534,8 +4558,9 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - rack_group_id = FilterChoiceField( + rack_group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.all(), + required=False, label='Rack group (ID)', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -4743,7 +4768,7 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4755,9 +4780,10 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4767,16 +4793,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - power_panel_id = FilterChoiceField( + power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), + required=False, label='Power panel', widget=APISelectMultiple( api_url="/api/dcim/power-panels/", null_option=True, ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', widget=APISelectMultiple( api_url="/api/dcim/racks/", diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index f9b765379..a110c75e1 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -7,8 +7,8 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2, - BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, + StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -265,72 +265,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", ) ) - cluster_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", value_field="slug", ) ) - cluster_id = FilterChoiceField( + cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), + required=False, label='Cluster', widget=APISelectMultiple( api_url="/api/virtualization/clusters/", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", ) ) - tag = FilterChoiceField( + tag = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/extras/tags/", value_field="slug", @@ -390,7 +399,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2() ) - # TODO: Convert to FilterChoiceField once we have an API endpoint for users + # TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users user = forms.ModelChoiceField( queryset=User.objects.order_by('username'), required=False, diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 059587082..6a65c4e1d 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -11,8 +11,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, - DatePicker, DynamicModelChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, - ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, + FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine from .constants import * @@ -226,9 +227,10 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Address family', widget=StaticSelect2() ) - rir = FilterChoiceField( + rir = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), to_field_name='slug', + required=False, label='RIR', widget=APISelectMultiple( api_url="/api/ipam/rirs/", @@ -517,8 +519,9 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", @@ -530,7 +533,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -542,18 +545,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", @@ -975,8 +980,9 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", @@ -1043,7 +1049,7 @@ class VLANGroupCSVForm(forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1055,9 +1061,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1236,7 +1243,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1249,17 +1256,19 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=VLANGroup.objects.all(), + required=False, label='VLAN group', widget=APISelectMultiple( api_url="/api/ipam/vlan-groups/", @@ -1271,9 +1280,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 2b5e059ca..7554e6278 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -8,7 +8,7 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, + APISelect, APISelectMultiple, BootstrapMixin, DynamicModelMultipleChoiceField, FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField ) from .constants import * @@ -181,9 +181,10 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', + required=True, widget=APISelectMultiple( api_url="/api/secrets/secret-roles/", value_field="slug", diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 553e79f1b..f3379faa0 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -5,8 +5,8 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, FilterChoiceField, SlugField, - TagFilterField, + APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -105,9 +105,10 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - group = FilterChoiceField( + group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -156,9 +157,10 @@ class TenancyForm(forms.Form): class TenancyFilterForm(forms.Form): - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -168,9 +170,10 @@ class TenancyFilterForm(forms.Form): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 49d7e882d..494556b5f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -579,17 +579,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceField): field_modifier = '__in' -class FilterChoiceField(DynamicModelMultipleChoiceField): - """ - A version of DynamicModelMultipleChoiceField which defaults to required=False. - """ - def __init__(self, *args, **kwargs): - # Filter fields are not required by default - if 'required' not in kwargs: - kwargs['required'] = False - super().__init__(*args, **kwargs) - - class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 8c66f1c23..41b94a3c9 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -15,8 +15,7 @@ from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, - TagFilterField, + ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -189,7 +188,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm 'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant' ] q = forms.CharField(required=False, label='Search') - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', required=False, @@ -198,7 +197,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm value_field='slug', ) ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -210,7 +209,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, @@ -220,7 +219,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm null_option=True, ) ) - group = FilterChoiceField( + group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', required=False, @@ -544,32 +543,35 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, label='Search' ) - cluster_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/virtualization/cluster-groups/', value_field="slug", null_option=True, ) ) - cluster_type = FilterChoiceField( + cluster_type = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/virtualization/cluster-types/', value_field="slug", null_option=True, ) ) - cluster_id = FilterChoiceField( + cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), + required=False, label='Cluster', widget=APISelectMultiple( api_url='/api/virtualization/clusters/', ) ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -581,18 +583,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/dcim/sites/', value_field="slug", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.filter(vm_role=True), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/dcim/device-roles/', value_field="slug", @@ -607,9 +611,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, widget=StaticSelect2Multiple() ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/dcim/platforms/', value_field="slug", From 221805a63efa965b62f3b646d707ffbaa2c639ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 09:50:33 -0500 Subject: [PATCH 031/106] Use TreeNode choice fields for region assignment --- netbox/dcim/forms.py | 22 +++++++++------------- netbox/extras/forms.py | 8 +++++++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 478474fa3..4de1c1eca 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -182,18 +182,18 @@ class MACAddressField(forms.Field): # class RegionForm(BootstrapMixin, forms.ModelForm): + parent = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2() + ) slug = SlugField() class Meta: model = Region - fields = [ + fields = ( 'parent', 'name', 'slug', - ] - widgets = { - 'parent': APISelect( - api_url="/api/dcim/regions/" - ) - } + ) class RegionCSVForm(forms.ModelForm): @@ -232,9 +232,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) slug = SlugField() comments = CommentField() @@ -325,9 +323,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index a110c75e1..45a42135d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from dcim.models import DeviceRole, Platform, Region, Site @@ -8,7 +9,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, - StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -190,6 +191,11 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): # class ConfigContextForm(BootstrapMixin, forms.ModelForm): + regions = TreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2Multiple() + ) tags = forms.ModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', From fb56d5bc660fef2555e5d7ea69b8306c7a6f2de8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 10:21:44 -0500 Subject: [PATCH 032/106] Account for initial data when binding a DynamicModelChoiceField --- netbox/utilities/forms.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 494556b5f..fc724224b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -562,10 +562,11 @@ class DynamicModelChoiceField(forms.ModelChoiceField): # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. + field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) if bound_field.data: - field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) - kwargs = {field_name: bound_field.data} - self.queryset = self.queryset.filter(**kwargs) + self.queryset = self.queryset.filter(**{field_name: bound_field.data}) + elif bound_field.initial: + self.queryset = self.queryset.filter(**{field_name: bound_field.initial}) else: self.queryset = self.queryset.none() From 7177fcfa61456f58efe511b67ad066526da7f791 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 10:43:22 -0500 Subject: [PATCH 033/106] Use DynamicModelChoiceField for all fields using APISelect --- netbox/circuits/forms.py | 27 ++-- netbox/dcim/forms.py | 244 +++++++++++++++++++-------------- netbox/extras/forms.py | 81 +++++++---- netbox/ipam/forms.py | 105 ++++++++------ netbox/secrets/forms.py | 17 +-- netbox/tenancy/forms.py | 18 +-- netbox/utilities/forms.py | 20 +-- netbox/virtualization/forms.py | 97 +++++++------ 8 files changed, 363 insertions(+), 246 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 77683e530..0b0378a7a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,7 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, + StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -165,6 +166,18 @@ class CircuitTypeCSVForm(forms.ModelForm): # class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + widget=APISelect( + api_url="/api/circuits/providers/" + ) + ) + type = DynamicModelChoiceField( + queryset=CircuitType.objects.all(), + widget=APISelect( + api_url="/api/circuits/circuit-types/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -181,12 +194,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'commit_rate': "Committed rate", } widgets = { - 'provider': APISelect( - api_url="/api/circuits/providers/" - ), - 'type': APISelect( - api_url="/api/circuits/circuit-types/" - ), 'status': StaticSelect2(), 'install_date': DatePicker(), } @@ -236,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False, widget=APISelect( api_url="/api/circuits/circuit-types/" ) ) - provider = forms.ModelChoiceField( + provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, widget=APISelect( @@ -256,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit initial='', widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4de1c1eca..99bb470f8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -325,7 +325,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -383,18 +383,20 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: model = RackGroup - fields = [ + fields = ( 'site', 'name', 'slug', - ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } + ) class RackGroupCSVForm(forms.ModelForm): @@ -471,6 +473,15 @@ class RackRoleCSVForm(forms.ModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'group': 'site_id', + } + ) + ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, @@ -478,6 +489,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/dcim/rack-groups/', ) ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-roles/', + ) + ) comments = CommentField() tags = TagField( required=False @@ -496,16 +514,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'u_height': "Height in rack units", } widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'group': 'site_id', - } - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/dcim/rack-roles/" - ), 'type': StaticSelect2(), 'width': StaticSelect2(), 'outer_unit': StaticSelect2(), @@ -605,7 +614,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -615,14 +624,14 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor } ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/rack-groups", ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -635,7 +644,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor initial='', widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False, widget=APISelect( @@ -833,7 +842,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -905,6 +914,12 @@ class ManufacturerCSVForm(forms.ModelForm): # class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( slug_source='model' ) @@ -920,9 +935,6 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'tags', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'subdevice_role': StaticSelect2() } @@ -945,11 +957,11 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput() ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/manufactureres" + api_url="/api/dcim/manufacturers" ) ) u_height = forms.IntegerField( @@ -1048,7 +1060,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1091,7 +1103,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1134,7 +1146,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1207,7 +1219,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1276,7 +1288,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1339,7 +1351,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1433,7 +1445,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1482,7 +1494,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1653,6 +1665,13 @@ class DeviceRoleCSVForm(forms.ModelForm): # class PlatformForm(BootstrapMixin, forms.ModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( max_length=64 ) @@ -1663,9 +1682,6 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'napalm_args': SmallTextarea(), } @@ -1695,7 +1711,7 @@ class PlatformCSVForm(forms.ModelForm): # class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( api_url="/api/dcim/sites/", @@ -1721,7 +1737,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): disabled_indicator='device' ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -1734,13 +1750,28 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - label='Device type', widget=APISelect( api_url='/api/dcim/device-types/', display_field='model' ) ) - cluster_group = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-roles/' + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/platforms/", + additional_query_params={ + "manufacturer_id": "null" + } + ) + ) + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -1786,16 +1817,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'position': 'face' } ), - 'device_role': APISelect( - api_url='/api/dcim/device-roles/' - ), 'status': StaticSelect2(), - 'platform': APISelect( - api_url="/api/dcim/platforms/", - additional_query_params={ - "manufacturer_id": "null" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), } @@ -2069,31 +2091,29 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() ) - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - label='Type', widget=APISelect( api_url="/api/dcim/device-types/", display_field='display_name' ) ) - device_role = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, - label='Role', widget=APISelect( api_url="/api/dcim/device-roles/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( api_url="/api/tenancy/tenants/" ) ) - platform = forms.ModelChoiceField( + platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( @@ -2103,7 +2123,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), required=False, - initial='', widget=StaticSelect2() ) serial = forms.CharField( @@ -2345,7 +2364,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2430,7 +2449,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2529,7 +2548,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2647,7 +2666,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class PowerOutletCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2806,25 +2825,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm): class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + label='Untagged VLAN', widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, + label='Tagged VLANs', widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', full=True ) ) - tags = TagField( required=False ) @@ -2866,7 +2886,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2916,7 +2936,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): tags = TagField( required=False ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -2925,7 +2945,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -3064,7 +3084,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -3073,7 +3093,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -3166,7 +3186,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -3344,7 +3364,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -3429,7 +3449,7 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3555,7 +3575,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): - termination_b_provider = forms.ModelChoiceField( + termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', required=False, @@ -3606,7 +3626,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3943,7 +3963,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -4113,6 +4133,19 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/" + ) + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) + ) tags = TagField( required=False ) @@ -4122,18 +4155,10 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): fields = [ 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ] - widgets = { - 'device': APISelect( - api_url="/api/dcim/devices/" - ), - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ) - } class InventoryItemCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -4142,7 +4167,7 @@ class InventoryItemCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -4197,14 +4222,14 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/devices/" ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -4364,7 +4389,7 @@ class DeviceVCMembershipForm(forms.ModelForm): class VCMemberSelectForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -4466,6 +4491,16 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ) + ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, @@ -4479,14 +4514,6 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'rack_group', 'name', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'rack_group': 'site_id', - } - ), - } class PowerPanelCSVForm(forms.ModelForm): @@ -4581,6 +4608,19 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): } ) ) + power_panel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + widget=APISelect( + api_url="/api/dcim/power-panels/" + ) + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/racks/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -4593,12 +4633,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'max_utilization', 'comments', 'tags', ] widgets = { - 'power_panel': APISelect( - api_url="/api/dcim/power-panels/" - ), - 'rack': APISelect( - api_url="/api/dcim/racks/" - ), 'status': StaticSelect2(), 'type': StaticSelect2(), 'supply': StaticSelect2(), @@ -4697,7 +4731,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput ) - power_panel = forms.ModelChoiceField( + power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False, widget=APISelect( @@ -4707,7 +4741,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd } ) ) - rack = forms.ModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, widget=APISelect( diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 45a42135d..d6a5406b7 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -196,7 +196,56 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): required=False, widget=StaticSelect2Multiple() ) - tags = forms.ModelMultipleChoiceField( + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/sites/" + ) + ) + roles = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/device-roles/" + ) + ) + platforms = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/platforms/" + ) + ) + cluster_groups = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/" + ) + ) + clusters = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/" + ) + ) + tenant_groups = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/" + ) + ) + tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/" + ) + ) + tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', required=False, @@ -210,36 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext - fields = [ + fields = ( 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', - ] - widgets = { - 'regions': APISelectMultiple( - api_url="/api/dcim/regions/" - ), - 'sites': APISelectMultiple( - api_url="/api/dcim/sites/" - ), - 'roles': APISelectMultiple( - api_url="/api/dcim/device-roles/" - ), - 'platforms': APISelectMultiple( - api_url="/api/dcim/platforms/" - ), - 'cluster_groups': APISelectMultiple( - api_url="/api/virtualization/cluster-groups/" - ), - 'clusters': APISelectMultiple( - api_url="/api/virtualization/clusters/" - ), - 'tenant_groups': APISelectMultiple( - api_url="/api/tenancy/tenant-groups/" - ), - 'tenants': APISelectMultiple( - api_url="/api/tenancy/tenants/" - ), - } + ) class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6a65c4e1d..2b7fb2a6b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -76,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -149,6 +149,12 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + widget=APISelect( + api_url="/api/ipam/rirs/" + ) + ) tags = TagField( required=False ) @@ -163,9 +169,6 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): 'rir': "Regional Internet Registry responsible for this prefix", } widgets = { - 'rir': APISelect( - api_url="/api/ipam/rirs/" - ), 'date_added': DatePicker(), } @@ -190,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput() ) - rir = forms.ModelChoiceField( + rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, label='RIR', @@ -270,7 +273,14 @@ class RoleCSVForm(forms.ModelForm): # class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vrfs/", + ) + ) + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -307,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name' ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -316,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'tags', ] widgets = { - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } def __init__(self, *args, **kwargs): @@ -433,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -453,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF max_value=PREFIX_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -465,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( @@ -588,7 +599,15 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel queryset=Interface.objects.all(), required=False ) - nat_site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', @@ -664,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -741,6 +757,14 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) class Meta: model = IPAddress @@ -750,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -888,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput() ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -901,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_value=IPADDRESS_MASK_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -934,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf_id = forms.ModelChoiceField( + vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -1014,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: @@ -1021,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'name', 'slug', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } class VLANGroupCSVForm(forms.ModelForm): @@ -1078,7 +1101,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -1098,6 +1121,13 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/ipam/vlan-groups/', ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -1115,9 +1145,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } widgets = { 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } @@ -1192,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, widget=APISelect( api_url="/api/ipam/vlan-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -1218,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 7554e6278..79064e0dd 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -8,8 +8,8 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, DynamicModelMultipleChoiceField, FlexibleModelChoiceField, SlugField, - StaticSelect2Multiple, TagFilterField + APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, ) from .constants import * from .models import Secret, SecretRole, UserKey @@ -87,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) + role = DynamicModelChoiceField( + queryset=SecretRole.objects.all(), + widget=APISelect( + api_url="/api/secrets/secret-roles/" + ) + ) tags = TagField( required=False ) @@ -96,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'role', 'name', 'plaintext', 'plaintext2', 'tags', ] - widgets = { - 'role': APISelect( - api_url="/api/secrets/secret-roles/" - ) - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -157,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=SecretRole.objects.all(), required=False, widget=APISelect( diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index f3379faa0..5b828b661 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -42,6 +42,13 @@ class TenantGroupCSVForm(forms.ModelForm): class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenant-groups/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -49,14 +56,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Tenant - fields = [ + fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', - ] - widgets = { - 'group': APISelect( - api_url="/api/tenancy/tenant-groups/" - ) - } + ) class TenantCSVForm(CustomFieldModelForm): @@ -85,7 +87,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput() ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, widget=APISelect( diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index fc724224b..c9a857ad0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -550,11 +550,7 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class DynamicModelChoiceField(forms.ModelChoiceField): - """ - Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be - rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. - """ +class DynamicModelChoiceMixin: field_modifier = '' def get_bound_field(self, form, field_name): @@ -564,16 +560,24 @@ class DynamicModelChoiceField(forms.ModelChoiceField): # will be populated on-demand via the APISelect widget. field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) if bound_field.data: - self.queryset = self.queryset.filter(**{field_name: bound_field.data}) + self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)}) elif bound_field.initial: - self.queryset = self.queryset.filter(**{field_name: bound_field.initial}) + self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.initial)}) else: self.queryset = self.queryset.none() return bound_field -class DynamicModelMultipleChoiceField(DynamicModelChoiceField): +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ + pass + + +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): """ A multiple-choice version of DynamicModelChoiceField. """ diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 41b94a3c9..12393d400 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -76,6 +76,26 @@ class ClusterGroupCSVForm(forms.ModelForm): # class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + type = DynamicModelChoiceField( + queryset=ClusterType.objects.all(), + widget=APISelect( + api_url="/api/virtualization/cluster-types/" + ) + ) + group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/virtualization/cluster-groups/" + ) + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -83,20 +103,9 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster - fields = [ + fields = ( 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', - ] - widgets = { - 'type': APISelect( - api_url="/api/virtualization/cluster-types/" - ), - 'group': APISelect( - api_url="/api/virtualization/cluster-groups/" - ), - 'site': APISelect( - api_url="/api/dcim/sites/" - ), - } + ) class ClusterCSVForm(CustomFieldModelCSVForm): @@ -146,25 +155,28 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), required=False, widget=APISelect( api_url="/api/virtualization/cluster-types/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( api_url="/api/virtualization/cluster-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -233,7 +245,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm class ClusterAddDevicesForm(BootstrapMixin, forms.Form): - region = forms.ModelChoiceField( + region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, widget=APISelect( @@ -318,7 +330,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): # class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - cluster_group = forms.ModelChoiceField( + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -337,6 +349,22 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/virtualization/clusters/' ) ) + role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url="/api/dcim/device-roles/", + additional_query_params={ + "vm_role": "True" + } + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/platforms/' + ) + ) tags = TagField( required=False ) @@ -357,17 +385,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } widgets = { "status": StaticSelect2(), - "role": APISelect( - api_url="/api/dcim/device-roles/", - additional_query_params={ - "vm_role": "True" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), - 'platform': APISelect( - api_url='/api/dcim/platforms/' - ) } def __init__(self, *args, **kwargs): @@ -477,14 +496,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB initial='', widget=StaticSelect2(), ) - cluster = forms.ModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, widget=APISelect( api_url='/api/virtualization/clusters/' ) ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True ), @@ -496,14 +515,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB } ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( api_url='/api/tenancy/tenants/' ) ) - platform = forms.ModelChoiceField( + platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( @@ -633,7 +652,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # class InterfaceForm(BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -642,7 +661,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -759,7 +778,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2(), ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -768,7 +787,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -847,7 +866,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -856,7 +875,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( From 009c0ba31c445ddc9ff4323c83ae99c869fad0b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 14:18:45 -0500 Subject: [PATCH 034/106] Fixes #4134: Device power ports and outlets should inherit type from the parent device type --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/device_component_templates.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e789c9803..a796ea924 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -17,6 +17,7 @@ * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms +* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type --- diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index ab4a078cf..faa42b035 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -168,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel): return PowerPort( device=device, name=self.name, + type=self.type, maximum_draw=self.maximum_draw, allocated_draw=self.allocated_draw ) @@ -232,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel): return PowerOutlet( device=device, name=self.name, + type=self.type, power_port=power_port, feed_leg=self.feed_leg ) From 55886d679379845f0d1b1c9089c936a4e8f91264 Mon Sep 17 00:00:00 2001 From: Anton Tokarev Date: Tue, 11 Feb 2020 22:22:32 +0300 Subject: [PATCH 035/106] fix typo (#4135) Co-authored-by: Jeremy Stretch --- docs/installation/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index 4962eb7d0..59631bf7a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) 2. [NetBox components](2-netbox.md) -3. [HTTP dameon](3-http-daemon.md) +3. [HTTP daemon](3-http-daemon.md) 4. [LDAP authentication](4-ldap.md) (optional) # Upgrading From 2503978555e2dcb569d048581b862838cd524bf4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 14:32:41 -0500 Subject: [PATCH 036/106] Add navigation menu link for adding config contexts --- netbox/templates/inc/nav_menu.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index eeb520a57..5e6621813 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -479,6 +479,11 @@ Config Contexts + {% if perms.extras.add_configcontext %} +
+ +
+ {% endif %} Scripts From 2ab382eec5a6a282bc99209073690d9c19cb55a8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 14:56:02 -0500 Subject: [PATCH 037/106] Fixes #4137: Disable occupied terminations when connecting a cable to a circuit --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index a796ea924..4fea49d6b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -18,6 +18,7 @@ * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms * [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type +* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 99bb470f8..8f035ccbb 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3613,7 +3613,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): widget=APISelect( api_url='/api/circuits/circuit-terminations/', disabled_indicator='cable', - display_field='term_side' + display_field='term_side', + full=True ) ) From 2e83ce76eddc7f5d7cfb38430c70efc352b0b10e Mon Sep 17 00:00:00 2001 From: Matt Olenik Date: Fri, 13 Dec 2019 11:52:59 -0800 Subject: [PATCH 038/106] Fix race condition in available-prefix/ip APIs Implement advisory lock to prevent duplicate records being inserted when making simultaneous calls. Fixes #2519 --- base_requirements.txt | 4 ++++ netbox/ipam/api/views.py | 10 ++++++++++ netbox/utilities/constants.py | 11 +++++++++++ requirements.txt | 1 + 4 files changed, 26 insertions(+) diff --git a/base_requirements.txt b/base_requirements.txt index 8b42c835d..ed42b6c08 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -22,6 +22,10 @@ django-filter # https://github.com/django-mptt/django-mptt django-mptt +# Context managers for PostgreSQL advisory locks +# https://github.com/Xof/django-pglocks +django-pglocks + # Prometheus metrics library for Django # https://github.com/korfuri/django-prometheus django-prometheus diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 08e21367c..262ca7908 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,6 +1,7 @@ from django.conf import settings from django.db.models import Count from django.shortcuts import get_object_or_404 +from django_pglocks import advisory_lock from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import get_subquery from . import serializers @@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet): filterset_class = filters.PrefixFilterSet @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) + @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def available_prefixes(self, request, pk=None): """ A convenience method for returning available child prefixes within a parent. + + The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being + invoked in parallel, which results in a race condition where multiple insertions can occur. """ prefix = get_object_or_404(Prefix, pk=pk) available_prefixes = prefix.get_available_prefixes() @@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) @action(detail=True, url_path='available-ips', methods=['get', 'post']) + @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def available_ips(self, request, pk=None): """ A convenience method for returning available IP addresses within a prefix. By default, the number of IPs returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed, however results will not be paginated. + + The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being + invoked in parallel, which results in a race condition where multiple insertions can occur. """ prefix = get_object_or_404(Prefix, pk=pk) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index ad6e8fd90..2cec2b532 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -27,3 +27,14 @@ COLOR_CHOICES = ( ('111111', 'Black'), ('ffffff', 'White'), ) + +# Keys for PostgreSQL advisory locks. These are arbitrary bigints used by +# the advisory_lock contextmanager. When a lock is acquired, +# one of these keys will be used to identify said lock. +# +# When adding a new key, pick something arbitrary and unique so +# that it is easily searchable in query logs. +ADVISORY_LOCK_KEYS = { + 'available-prefixes': 100100, + 'available-ips': 100200, +} diff --git a/requirements.txt b/requirements.txt index 9c5f624f9..b0b1b971d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ django-cors-headers==3.2.1 django-debug-toolbar==2.1 django-filter==2.2.0 django-mptt==0.9.1 +django-pglocks==1.0.4 django-prometheus==1.1.0 django-rq==2.2.0 django-tables2==2.2.1 From 908586c93a163ce3af811146de14d833fb470dee Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Tue, 11 Feb 2020 18:21:58 -0600 Subject: [PATCH 039/106] Fix PEP8 errors --- netbox/ipam/tests/test_ordering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index c1707740e..58a42fc46 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -5,6 +5,7 @@ from ipam.models import IPAddress, Prefix, VRF import netaddr + class OrderingTestBase(TestCase): vrfs = None @@ -97,7 +98,7 @@ class PrefixOrderingTestCase(OrderingTestBase): """ This function tests a compex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs This includes the testing of the Container status. - + The proper ordering, to get proper containerization should be: None:10.0.0.0/8 None:10.0.0.0/16 From 3c3cca8ec1bcbee8acdc14bca9813a98bf11bfca Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 11 Feb 2020 20:12:02 -0600 Subject: [PATCH 040/106] Enable widget usage (APISelect, APIMultipleSelect, StaticSelect2, etc) in scripts --- netbox/extras/scripts.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 6567fe707..b0e12ffcb 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -48,7 +48,7 @@ class ScriptVariable: """ form_field = forms.CharField - def __init__(self, label='', description='', default=None, required=True): + def __init__(self, label='', description='', default=None, required=True, widget=None): # Initialize field attributes if not hasattr(self, 'field_attrs'): @@ -59,6 +59,8 @@ class ScriptVariable: self.field_attrs['help_text'] = description if default: self.field_attrs['initial'] = default + if widget: + self.field_attrs['widget'] = widget self.field_attrs['required'] = required # Initialize the list of optional validators if none have already been defined @@ -71,7 +73,10 @@ class ScriptVariable: """ form_field = self.form_field(**self.field_attrs) if not isinstance(form_field.widget, forms.CheckboxInput): - form_field.widget.attrs['class'] = 'form-control' + if form_field.widget.attrs and form_field.widget.attrs['class']: + form_field.widget.attrs['class'] += ' form-control' + else: + form_field.widget.attrs['class'] = 'form-control' return form_field From 56bb053146bafb3fd610693b1d4ba40d30f79b98 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 11 Feb 2020 20:27:02 -0600 Subject: [PATCH 041/106] Fix test error --- netbox/extras/scripts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index b0e12ffcb..e5a32bde6 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -73,7 +73,7 @@ class ScriptVariable: """ form_field = self.form_field(**self.field_attrs) if not isinstance(form_field.widget, forms.CheckboxInput): - if form_field.widget.attrs and form_field.widget.attrs['class']: + if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys(): form_field.widget.attrs['class'] += ' form-control' else: form_field.widget.attrs['class'] = 'form-control' From ab378ed218f6ad1109588c0a684c7b20428aa47d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 11 Feb 2020 20:39:50 -0600 Subject: [PATCH 042/106] Fixes: #4130 Corrects service name for the RQ worker service in docs/installation/upgrading.md --- docs/installation/upgrading.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 6a2c0188f..e5cf93a28 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui ```no-highlight # sudo systemctl restart netbox -# sudo systemctl restart netbox-rqworker +# sudo systemctl restart netbox-rq ``` !!! note From 8fb4988fa1eb145ea0bb950b430367ade33f01dc Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Tue, 11 Feb 2020 21:01:43 -0600 Subject: [PATCH 043/106] Fix typo in docstring --- netbox/ipam/tests/test_ordering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 58a42fc46..3dffe4059 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -96,7 +96,7 @@ class PrefixOrderingTestCase(OrderingTestBase): self._compare(Prefix.objects.all(), prefixes) """ - This function tests a compex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs + This function tests a complex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs This includes the testing of the Container status. The proper ordering, to get proper containerization should be: From 15bc731f611f170cf4107b08e974a9785bd4058f Mon Sep 17 00:00:00 2001 From: Dan Sheppard Date: Tue, 11 Feb 2020 23:31:51 -0600 Subject: [PATCH 044/106] Convert rack units to part of SVG rendered document --- netbox/dcim/models/__init__.py | 21 ++++++++++++++----- netbox/project-static/css/rack_elevation.css | 9 ++++++++ netbox/templates/dcim/inc/rack_elevation.html | 6 ------ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index c31f4c713..8f70fa25d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -435,9 +435,20 @@ class RackElevationHelperMixin: def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height): - drawing = self._setup_drawing(unit_width, unit_height * self.u_height) + # Setup vars for the rack unit + position_width = 30 + + drawing = self._setup_drawing(unit_width + position_width, unit_height * self.u_height) unit_cursor = 0 + for ru in range(0, self.u_height): + start_y = ru * unit_height + position_coordinates = (15, start_y + unit_height / 2 + 2) + unit = ru + 1 if self.desc_units else self.u_height - ru + drawing.add( + drawing.text("U" + str(unit), position_coordinates, class_="unit") + ) + for unit in elevation: # Loop through all units in the elevation @@ -447,9 +458,9 @@ class RackElevationHelperMixin: # Setup drawing coordinates start_y = unit_cursor * unit_height end_y = unit_height * height - start_cordinates = (0, start_y) - end_cordinates = (unit_width, end_y) - text_cordinates = (unit_width / 2, start_y + end_y / 2) + start_cordinates = (position_width, start_y) + end_cordinates = (position_width + unit_width, end_y) + text_cordinates = (position_width + (unit_width / 2), start_y + end_y / 2) # Draw the device if device and device.face == face: @@ -471,7 +482,7 @@ class RackElevationHelperMixin: unit_cursor += height # Wrap the drawing with a border - drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack')) + drawing.add(drawing.rect((30, 0), (unit_width, self.u_height * unit_height), class_='rack')) return drawing diff --git a/netbox/project-static/css/rack_elevation.css b/netbox/project-static/css/rack_elevation.css index 06120c223..cbb5015a5 100644 --- a/netbox/project-static/css/rack_elevation.css +++ b/netbox/project-static/css/rack_elevation.css @@ -56,3 +56,12 @@ text { .blocked:hover+.add-device { fill: none; } + +.unit { + margin: 0; + padding: 5px 0px; + + fill: #c0c0c0; + font-size: 10px; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; +} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 1ab2e05ac..b0fcf4908 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,11 +1,5 @@ {% load helpers %} -
    - {% for u in rack.units %} -
  • {{ u }}
  • - {% endfor %} -
-
From e4b910fe87a9ed56f8aacefaceba86829432a553 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 09:44:30 -0500 Subject: [PATCH 045/106] Fixes #4148: Remove dead link to topology maps docs page --- mkdocs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 86cf9fead..4ba91dfe5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,7 +41,6 @@ pages: - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' - Tags: 'additional-features/tags.md' - - Topology Maps: 'additional-features/topology-maps.md' - Webhooks: 'additional-features/webhooks.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' From 5bf85597ed46a4ce40945c0fc0102cb1327e4776 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 11:13:32 -0500 Subject: [PATCH 046/106] Fixes #4146: Fix SecretRole permissions enforcement --- docs/release-notes/version-2.7.md | 1 + netbox/secrets/api/views.py | 8 ++-- netbox/secrets/tests/test_api.py | 51 +++++++++++++++++------ netbox/templates/secrets/secret_edit.html | 3 +- netbox/utilities/testing/utils.py | 4 +- 5 files changed, 49 insertions(+), 18 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index f937e29a8..9e6379adb 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -20,6 +20,7 @@ * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms * [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type * [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit +* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption --- diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 873679775..367dc9bd0 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -93,8 +93,8 @@ class SecretViewSet(ModelViewSet): secret = self.get_object() - # Attempt to decrypt the secret if the master key is known - if self.master_key is not None: + # Attempt to decrypt the secret if the user is permitted and the master key is known + if secret.decryptable_by(request.user) and self.master_key is not None: secret.decrypt(self.master_key) serializer = self.get_serializer(secret) @@ -111,7 +111,9 @@ class SecretViewSet(ModelViewSet): if self.master_key is not None: secrets = [] for secret in page: - secret.decrypt(self.master_key) + # Enforce role permissions + if secret.decryptable_by(request.user): + secret.decrypt(self.master_key) secrets.append(secret) serializer = self.get_serializer(secrets, many=True) else: diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index cabc340f9..df32ad7f2 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,7 +5,8 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import APITestCase +from users.models import Token +from utilities.testing import APITestCase, create_test_user from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -131,7 +132,15 @@ class SecretTest(APITestCase): def setUp(self): - super().setUp() + # Create a non-superuser test user + self.user = create_test_user('testuser', permissions=( + 'secrets.add_secret', + 'secrets.change_secret', + 'secrets.delete_secret', + 'secrets.view_secret', + )) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) userkey.save() @@ -144,11 +153,11 @@ class SecretTest(APITestCase): 'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key), } - self.plaintext = { - 'secret1': 'Secret #1 Plaintext', - 'secret2': 'Secret #2 Plaintext', - 'secret3': 'Secret #3 Plaintext', - } + self.plaintexts = ( + 'Secret #1 Plaintext', + 'Secret #2 Plaintext', + 'Secret #3 Plaintext', + ) site = Site.objects.create(name='Test Site 1', slug='test-site-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') @@ -160,17 +169,17 @@ class SecretTest(APITestCase): self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1') self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2') self.secret1 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1'] + device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0] ) self.secret1.encrypt(self.master_key) self.secret1.save() self.secret2 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2'] + device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1] ) self.secret2.encrypt(self.master_key) self.secret2.save() self.secret3 = Secret( - device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3'] + device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2] ) self.secret3.encrypt(self.master_key) self.secret3.save() @@ -178,16 +187,32 @@ class SecretTest(APITestCase): def test_get_secret(self): url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk}) - response = self.client.get(url, **self.header) - self.assertEqual(response.data['plaintext'], self.plaintext['secret1']) + # Secret plaintext not be decrypted as the user has not been assigned to the role + response = self.client.get(url, **self.header) + self.assertIsNone(response.data['plaintext']) + + # The plaintext should be present once the user has been assigned to the role + self.secretrole1.users.add(self.user) + response = self.client.get(url, **self.header) + self.assertEqual(response.data['plaintext'], self.plaintexts[0]) def test_list_secrets(self): url = reverse('secrets-api:secret-list') - response = self.client.get(url, **self.header) + # Secret plaintext not be decrypted as the user has not been assigned to the role + response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) + for secret in response.data['results']: + self.assertIsNone(secret['plaintext']) + + # The plaintext should be present once the user has been assigned to the role + self.secretrole1.users.add(self.user) + response = self.client.get(url, **self.header) + self.assertEqual(response.data['count'], 3) + for i, secret in enumerate(response.data['results']): + self.assertEqual(secret['plaintext'], self.plaintexts[i]) def test_create_secret(self): diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index be196aa57..875e53c5c 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load form_helpers %} +{% load secret_helpers %} {% block content %}
@@ -34,7 +35,7 @@
Secret Data
- {% if secret.pk %} + {% if secret.pk and secret|decryptable_by:request.user %}
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 469b21111..38ec6e196 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -21,11 +21,13 @@ def post_data(data): return ret -def create_test_user(username='testuser', permissions=list()): +def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. """ user = User.objects.create_user(username=username) + if permissions is None: + permissions = () for perm_name in permissions: app, codename = perm_name.split('.') perm = Permission.objects.get(content_type__app_label=app, codename=codename) From 0402323ef9f62ad5e28503841a6ea0e15da8c05f Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 12 Feb 2020 11:26:40 -0600 Subject: [PATCH 047/106] Fixes: #4130 Corrects service name for the RQ worker service in docs/installation/upgrading.md --- netbox/ipam/tests/test_ordering.py | 240 ++++++++++++++--------------- 1 file changed, 117 insertions(+), 123 deletions(-) diff --git a/netbox/ipam/tests/test_ordering.py b/netbox/ipam/tests/test_ordering.py index 3dffe4059..153bedddc 100644 --- a/netbox/ipam/tests/test_ordering.py +++ b/netbox/ipam/tests/test_ordering.py @@ -9,174 +9,168 @@ import netaddr class OrderingTestBase(TestCase): vrfs = None - """ - Setup the VRFs for the class as a whole - """ def setUp(self): + """ + Setup the VRFs for the class as a whole + """ self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C")) VRF.objects.bulk_create(self.vrfs) - """ - Perform the comparison of the queryset object and the object used to instantiate the queryset. - """ def _compare(self, queryset, objectset): + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ for i, obj in enumerate(queryset): - if isinstance(obj, Prefix): - self.assertEqual((obj.vrf, obj.prefix), (objectset[i]['vrf'], objectset[i]['prefix'])) - elif isinstance(obj, IPAddress): - self.assertEqual((obj.vrf, obj.address), (objectset[i]['vrf'], objectset[i]['address'])) + self.assertEqual(obj, objectset[i]) + + def _compare_ne(self, queryset, objectset): + """ + Perform the comparison of the queryset object and the object used to instantiate the queryset. + """ + for i, obj in enumerate(queryset): + self.assertNotEqual(obj, objectset[i]) class PrefixOrderingTestCase(OrderingTestBase): - """ - This is for comparing the complex ordering test case - """ - def _compare_complex(self, queryset, prefixes): - qsprefixes, regprefixes = [], [] - for i, obj in enumerate(queryset): - qsprefixes.append(obj.prefix) - for pfx in prefixes: - regprefixes.append(pfx['prefix']) - self.assertEquals(qsprefixes, regprefixes) - - """ - This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs - """ def test_prefix_vrf_ordering(self): + """ + This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs + """ # Setup VRFs vrfa, vrfb, vrfc = self.vrfs # Setup Prefixes prefixes = ( - {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.0.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.2.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.3.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.4.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.5.0/24')}, + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.5.0/24')), - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/8')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.2.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.3.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.4.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.2.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.3.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.4.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.2.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.3.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.2.4.0/24')}, + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.4.0/24')), - {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.0.0/12')}, - {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.0.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.2.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.3.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.16.4.0/24')}, - {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.0.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.2.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.3.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "prefix": netaddr.IPNetwork('172.17.4.0/24')}, + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/12')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.4.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.2.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.3.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.4.0/24')), ) - Prefix.objects.bulk_create([Prefix(status=args['status'], vrf=args['vrf'], family=args['family'], prefix=args['prefix']) for args in prefixes]) + Prefix.objects.bulk_create(prefixes) # Test self._compare(Prefix.objects.all(), prefixes) - """ - This function tests a complex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs - This includes the testing of the Container status. - - The proper ordering, to get proper containerization should be: - None:10.0.0.0/8 - None:10.0.0.0/16 - VRF A:10.0.0.0/24 - VRF A:10.0.1.0/24 - VRF A:10.0.1.0/25 - None:10.1.0.0/16 - VRF A:10.1.0.0/24 - VRF A:10.1.1.0/24 - None: 192.168.0.0/16 - """ def test_prefix_complex_ordering(self): + """ + This function tests a complex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs + This includes the testing of the Container status. + + The proper ordering, to get proper containerization should be: + None:10.0.0.0/8 + None:10.0.0.0/16 + VRF A:10.0.0.0/24 + VRF A:10.0.1.0/24 + VRF A:10.0.1.0/25 + None:10.1.0.0/16 + VRF A:10.1.0.0/24 + VRF A:10.1.1.0/24 + None: 192.168.0.0/16 + """ # Setup VRFs vrfa, vrfb, vrfc = self.vrfs # Setup Prefixes prefixes = [ - {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/8')}, - {"status": PrefixStatusChoices.STATUS_CONTAINER, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('10.1.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "prefix": netaddr.IPNetwork('192.168.0.0/16')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.0.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.1.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.0.1.0/25')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.0.0/24')}, - {"status": PrefixStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "prefix": netaddr.IPNetwork('10.1.1.0/24')}, + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')), + Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/25')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/24')), + Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')), ] - Prefix.objects.bulk_create([Prefix(status=args['status'], vrf=args['vrf'], family=args['family'], prefix=args['prefix']) for args in prefixes]) + Prefix.objects.bulk_create(prefixes) # Test - self._compare_complex(Prefix.objects.all(), prefixes) + self._compare(Prefix.objects.all(), prefixes) class IPAddressOrderingTestCase(OrderingTestBase): - """ - This function tests ordering with the inclusion of vrfs - """ + def test_address_vrf_ordering(self): + """ + This function tests ordering with the inclusion of vrfs + """ # Setup VRFs vrfa, vrfb, vrfc = self.vrfs # Setup Addresses addresses = ( - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.0.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.1.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.2.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.3.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.0.4.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.0.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.1.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.2.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.3.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.1.4.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.0.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.1.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.2.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.3.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfa, "family": 4, "address": netaddr.IPNetwork('10.2.4.1/24')}, + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.4.1/24')), - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.0.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.1.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.2.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.3.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.16.4.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.0.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.1.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.2.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.3.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": vrfb, "family": 4, "address": netaddr.IPNetwork('172.17.4.1/24')}, + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.4.1/24')), - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.0.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.1.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.2.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.3.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.4.1/24')}, - {"status": IPAddressStatusChoices.STATUS_ACTIVE, "vrf": None, "family": 4, "address": netaddr.IPNetwork('192.168.5.1/24')}, + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.0.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.1.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.2.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.3.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.4.1/24')), + IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.5.1/24')), ) - IPAddress.objects.bulk_create([IPAddress(status=args['status'], vrf=args['vrf'], family=args['family'], address=args['address']) for args in addresses]) + IPAddress.objects.bulk_create(addresses) # Test self._compare(IPAddress.objects.all(), addresses) From 3b1128f8f381b4bc24fc32ab8250ce2db37b7eca Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 12:33:27 -0500 Subject: [PATCH 048/106] Establish standard test cases for all models --- netbox/circuits/tests/test_views.py | 13 +- netbox/dcim/tests/test_views.py | 231 +++------------- netbox/extras/tests/test_views.py | 6 +- netbox/ipam/tests/test_views.py | 35 +-- netbox/secrets/tests/test_views.py | 11 +- netbox/tenancy/tests/test_views.py | 11 +- netbox/utilities/testing/testcases.py | 307 +++++++++++++--------- netbox/virtualization/tests/test_views.py | 29 +- 8 files changed, 253 insertions(+), 390 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index d2cb8e5ab..9cc7af6ae 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -2,10 +2,10 @@ import datetime from circuits.choices import * from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases -class ProviderTestCase(StandardTestCases.Views): +class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Provider @classmethod @@ -46,14 +46,9 @@ class ProviderTestCase(StandardTestCases.Views): } -class CircuitTypeTestCase(StandardTestCases.Views): +class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = CircuitType - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -77,7 +72,7 @@ class CircuitTypeTestCase(StandardTestCases.Views): ) -class CircuitTestCase(StandardTestCases.Views): +class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit @classmethod diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 75e3f9871..704dedb40 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import VLAN -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases def create_test_device(name): @@ -27,14 +27,9 @@ def create_test_device(name): return device -class RegionTestCase(StandardTestCases.Views): +class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Region - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -61,7 +56,7 @@ class RegionTestCase(StandardTestCases.Views): ) -class SiteTestCase(StandardTestCases.Views): +class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Site @classmethod @@ -118,14 +113,9 @@ class SiteTestCase(StandardTestCases.Views): } -class RackGroupTestCase(StandardTestCases.Views): +class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -152,14 +142,9 @@ class RackGroupTestCase(StandardTestCases.Views): ) -class RackRoleTestCase(StandardTestCases.Views): +class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackRole - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -184,7 +169,7 @@ class RackRoleTestCase(StandardTestCases.Views): ) -class RackReservationTestCase(StandardTestCases.Views): +class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = RackReservation # Disable inapplicable tests @@ -226,7 +211,7 @@ class RackReservationTestCase(StandardTestCases.Views): } -class RackTestCase(StandardTestCases.Views): +class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Rack @classmethod @@ -302,14 +287,9 @@ class RackTestCase(StandardTestCases.Views): } -class ManufacturerTestCase(StandardTestCases.Views): +class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Manufacturer - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -332,7 +312,7 @@ class ManufacturerTestCase(StandardTestCases.Views): ) -class DeviceTypeTestCase(StandardTestCases.Views): +class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = DeviceType @classmethod @@ -528,18 +508,9 @@ device-bays: # DeviceType components # -class ConsolePortTemplateTestCase(StandardTestCases.Views): +class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsolePortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -572,18 +543,9 @@ class ConsolePortTemplateTestCase(StandardTestCases.Views): } -class ConsoleServerPortTemplateTestCase(StandardTestCases.Views): +class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsoleServerPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -616,18 +578,9 @@ class ConsoleServerPortTemplateTestCase(StandardTestCases.Views): } -class PowerPortTemplateTestCase(StandardTestCases.Views): +class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -666,18 +619,9 @@ class PowerPortTemplateTestCase(StandardTestCases.Views): } -class PowerOutletTemplateTestCase(StandardTestCases.Views): +class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerOutletTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -716,18 +660,9 @@ class PowerOutletTemplateTestCase(StandardTestCases.Views): } -class InterfaceTemplateTestCase(StandardTestCases.Views): +class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = InterfaceTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -763,18 +698,9 @@ class InterfaceTemplateTestCase(StandardTestCases.Views): } -class FrontPortTemplateTestCase(StandardTestCases.Views): +class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = FrontPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -818,18 +744,9 @@ class FrontPortTemplateTestCase(StandardTestCases.Views): } -class RearPortTemplateTestCase(StandardTestCases.Views): +class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = RearPortTemplate - # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -864,19 +781,12 @@ class RearPortTemplateTestCase(StandardTestCases.Views): } -class DeviceBayTemplateTestCase(StandardTestCases.Views): +class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate # Disable inapplicable views - test_get_object = None - test_list_objects = None - test_create_object = None - test_import_objects = None test_bulk_edit_objects = None - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -903,14 +813,9 @@ class DeviceBayTemplateTestCase(StandardTestCases.Views): } -class DeviceRoleTestCase(StandardTestCases.Views): +class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = DeviceRole - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -936,14 +841,9 @@ class DeviceRoleTestCase(StandardTestCases.Views): ) -class PlatformTestCase(StandardTestCases.Views): +class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Platform - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -971,7 +871,7 @@ class PlatformTestCase(StandardTestCases.Views): ) -class DeviceTestCase(StandardTestCases.Views): +class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Device @classmethod @@ -1056,16 +956,9 @@ class DeviceTestCase(StandardTestCases.Views): } -class ConsolePortTestCase(StandardTestCases.Views): +class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1105,16 +998,9 @@ class ConsolePortTestCase(StandardTestCases.Views): ) -class ConsoleServerPortTestCase(StandardTestCases.Views): +class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1155,16 +1041,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views): ) -class PowerPortTestCase(StandardTestCases.Views): +class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1210,16 +1089,9 @@ class PowerPortTestCase(StandardTestCases.Views): ) -class PowerOutletTestCase(StandardTestCases.Views): +class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1272,15 +1144,12 @@ class PowerOutletTestCase(StandardTestCases.Views): ) -class InterfaceTestCase(StandardTestCases.Views): +class InterfaceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeviceComponentViewTestCase, +): model = Interface - # Disable inapplicable views - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1356,16 +1225,9 @@ class InterfaceTestCase(StandardTestCases.Views): ) -class FrontPortTestCase(StandardTestCases.Views): +class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1420,16 +1282,9 @@ class FrontPortTestCase(StandardTestCases.Views): ) -class RearPortTestCase(StandardTestCases.Views): +class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1471,19 +1326,12 @@ class RearPortTestCase(StandardTestCases.Views): ) -class DeviceBayTestCase(StandardTestCases.Views): +class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay # Disable inapplicable views - test_get_object = None - test_create_object = None - - # TODO test_bulk_edit_objects = None - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device1 = create_test_device('Device 1') @@ -1520,16 +1368,9 @@ class DeviceBayTestCase(StandardTestCases.Views): ) -class InventoryItemTestCase(StandardTestCases.Views): +class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem - # Disable inapplicable views - test_get_object = None - test_create_object = None - - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1581,7 +1422,7 @@ class InventoryItemTestCase(StandardTestCases.Views): ) -class CableTestCase(StandardTestCases.Views): +class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cable # TODO: Creation URL needs termination context @@ -1655,7 +1496,7 @@ class CableTestCase(StandardTestCases.Views): } -class VirtualChassisTestCase(StandardTestCases.Views): +class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis # Disable inapplicable tests @@ -1709,7 +1550,7 @@ class VirtualChassisTestCase(StandardTestCases.Views): Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) -class PowerPanelTestCase(StandardTestCases.Views): +class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerPanel # Disable inapplicable tests @@ -1750,7 +1591,7 @@ class PowerPanelTestCase(StandardTestCases.Views): ) -class PowerFeedTestCase(StandardTestCases.Views): +class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = PowerFeed @classmethod diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ecb25a78c..370055b26 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -7,10 +7,10 @@ from django.urls import reverse from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import ConfigContext, ObjectChange, Tag -from utilities.testing import StandardTestCases, TestCase +from utilities.testing import ViewTestCases, TestCase -class TagTestCase(StandardTestCases.Views): +class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tag # Disable inapplicable tests @@ -38,7 +38,7 @@ class TagTestCase(StandardTestCases.Views): } -class ConfigContextTestCase(StandardTestCases.Views): +class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ConfigContext # Disable inapplicable tests diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index cfa06788c..66e649005 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -5,10 +5,10 @@ from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases -class VRFTestCase(StandardTestCases.Views): +class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VRF @classmethod @@ -43,14 +43,9 @@ class VRFTestCase(StandardTestCases.Views): } -class RIRTestCase(StandardTestCases.Views): +class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RIR - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -74,7 +69,7 @@ class RIRTestCase(StandardTestCases.Views): ) -class AggregateTestCase(StandardTestCases.Views): +class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Aggregate @classmethod @@ -115,14 +110,9 @@ class AggregateTestCase(StandardTestCases.Views): } -class RoleTestCase(StandardTestCases.Views): +class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Role - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -147,7 +137,7 @@ class RoleTestCase(StandardTestCases.Views): ) -class PrefixTestCase(StandardTestCases.Views): +class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Prefix @classmethod @@ -207,7 +197,7 @@ class PrefixTestCase(StandardTestCases.Views): } -class IPAddressTestCase(StandardTestCases.Views): +class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPAddress @classmethod @@ -254,14 +244,9 @@ class IPAddressTestCase(StandardTestCases.Views): } -class VLANGroupTestCase(StandardTestCases.Views): +class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = VLANGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -287,7 +272,7 @@ class VLANGroupTestCase(StandardTestCases.Views): ) -class VLANTestCase(StandardTestCases.Views): +class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VLAN @classmethod @@ -346,7 +331,7 @@ class VLANTestCase(StandardTestCases.Views): } -class ServiceTestCase(StandardTestCases.Views): +class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Service # Disable inapplicable tests diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 94f4cbd6a..96439a10d 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -4,18 +4,13 @@ from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY -class SecretRoleTestCase(StandardTestCases.Views): +class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = SecretRole - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -41,7 +36,7 @@ class SecretRoleTestCase(StandardTestCases.Views): ) -class SecretTestCase(StandardTestCases.Views): +class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Secret # Disable inapplicable tests diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index a44ca2932..27e2c1591 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,15 +1,10 @@ from tenancy.models import Tenant, TenantGroup -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases -class TenantGroupTestCase(StandardTestCases.Views): +class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = TenantGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -32,7 +27,7 @@ class TenantGroupTestCase(StandardTestCases.Views): ) -class TenantTestCase(StandardTestCases.Views): +class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tenant @classmethod diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index b5e2e1bab..8d1b1a1be 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -57,6 +57,53 @@ class TestCase(_TestCase): expected_status, response.status_code, getattr(response, 'data', 'No data') )) + +class ModelViewTestCase(TestCase): + """ + Base TestCase for model views. Subclass to test individual views. + """ + model = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.model is None: + raise Exception("Test case requires model to be defined") + + def _get_base_url(self): + """ + Return the base format for a URL for the test's model. Override this to test for a model which belongs + to a different app (e.g. testing Interfaces within the virtualization app). + """ + return '{}:{}_{{}}'.format( + self.model._meta.app_label, + self.model._meta.model_name + ) + + def _get_url(self, action, instance=None): + """ + Return the URL name for a specific action. An instance must be specified for + get/edit/delete views. + """ + url_format = self._get_base_url() + + if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): + return reverse(url_format.format(action)) + + elif action in ('get', 'edit', 'delete'): + if instance is None: + raise Exception("Resolving {} URL requires specifying an instance".format(action)) + # Attempt to resolve using slug first + if hasattr(self.model, 'slug'): + try: + return reverse(url_format.format(action), kwargs={'slug': instance.slug}) + except NoReverseMatch: + pass + return reverse(url_format.format(action), kwargs={'pk': instance.pk}) + + else: + raise Exception("Invalid action for URL resolution: {}".format(action)) + def assertInstanceEqual(self, instance, data): """ Compare a model instance to a dictionary, checking that its attribute values match those specified @@ -94,108 +141,14 @@ class APITestCase(TestCase): self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} -class StandardTestCases: +class ViewTestCases: """ We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them. """ - - class Views(TestCase): + class GetObjectViewTestCase(ModelViewTestCase): """ - Stock TestCase suitable for testing all standard View functions: - - List objects - - View single object - - Create new object - - Modify existing object - - Delete existing object - - Import multiple new objects + Retrieve a single instance. """ - model = None - - # Data to be sent when creating/editing individual objects - form_data = {} - - # CSV lines used for bulk import of new objects - csv_data = () - - # Form data used when creating multiple objects - bulk_create_data = {} - - # Form data to be used when editing multiple objects at once - bulk_edit_data = {} - - maxDiff = None - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - if self.model is None: - raise Exception("Test case requires model to be defined") - - # - # URL functions - # - - def _get_base_url(self): - """ - Return the base format for a URL for the test's model. Override this to test for a model which belongs - to a different app (e.g. testing Interfaces within the virtualization app). - """ - return '{}:{}_{{}}'.format( - self.model._meta.app_label, - self.model._meta.model_name - ) - - def _get_url(self, action, instance=None): - """ - Return the URL name for a specific action. An instance must be specified for - get/edit/delete views. - """ - url_format = self._get_base_url() - - if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): - return reverse(url_format.format(action)) - - elif action in ('get', 'edit', 'delete'): - if instance is None: - raise Exception("Resolving {} URL requires specifying an instance".format(action)) - # Attempt to resolve using slug first - if hasattr(self.model, 'slug'): - try: - return reverse(url_format.format(action), kwargs={'slug': instance.slug}) - except NoReverseMatch: - pass - return reverse(url_format.format(action), kwargs={'pk': instance.pk}) - - else: - raise Exception("Invalid action for URL resolution: {}".format(action)) - - # - # Standard view tests - # These methods will run by default. To disable a test, nullify its method on the subclasses TestCase: - # - # test_list_objects = None - # - - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def test_list_objects(self): - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.get(self._get_url('list')), 403) - - # Assign the required permission and submit again - self.add_permissions( - '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.get(self._get_url('list')) - self.assertHttpStatus(response, 200) - - # Built-in CSV export - if hasattr(self.model, 'csv_headers'): - response = self.client.get('{}?export'.format(self._get_url('list'))) - self.assertHttpStatus(response, 200) - self.assertEqual(response.get('Content-Type'), 'text/csv') - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): instance = self.model.objects.first() @@ -211,6 +164,12 @@ class StandardTestCases: response = self.client.get(instance.get_absolute_url()) self.assertHttpStatus(response, 200) + class CreateObjectViewTestCase(ModelViewTestCase): + """ + Create a single new instance. + """ + form_data = {} + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object(self): initial_count = self.model.objects.count() @@ -235,6 +194,12 @@ class StandardTestCases: instance = self.model.objects.order_by('-pk').first() self.assertInstanceEqual(instance, self.form_data) + class EditObjectViewTestCase(ModelViewTestCase): + """ + Edit a single existing instance. + """ + form_data = {} + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object(self): instance = self.model.objects.first() @@ -259,6 +224,10 @@ class StandardTestCases: instance = self.model.objects.get(pk=instance.pk) self.assertInstanceEqual(instance, self.form_data) + class DeleteObjectViewTestCase(ModelViewTestCase): + """ + Delete a single instance. + """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object(self): instance = self.model.objects.first() @@ -283,6 +252,66 @@ class StandardTestCases: with self.assertRaises(ObjectDoesNotExist): self.model.objects.get(pk=instance.pk) + class ListObjectsViewTestCase(ModelViewTestCase): + """ + Retrieve multiple instances. + """ + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_list_objects(self): + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(self._get_url('list')), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.get(self._get_url('list')) + self.assertHttpStatus(response, 200) + + # Built-in CSV export + if hasattr(self.model, 'csv_headers'): + response = self.client.get('{}?export'.format(self._get_url('list'))) + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv') + + class BulkCreateObjectsViewTestCase(ModelViewTestCase): + """ + Create multiple instances using a single form. Expects the creation of three new instances by default. + """ + bulk_create_count = 3 + bulk_create_data = {} + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_create_objects(self): + initial_count = self.model.objects.count() + request = { + 'path': self._get_url('add'), + 'data': post_data(self.bulk_create_data), + 'follow': False, # Do not follow 302 redirects + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count()) + for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]: + self.assertInstanceEqual(instance, self.bulk_create_data) + + class ImportObjectsViewTestCase(ModelViewTestCase): + """ + Create multiple instances from imported data. + """ + csv_data = () + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_import_objects(self): initial_count = self.model.objects.count() @@ -307,6 +336,12 @@ class StandardTestCases: self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + class BulkEditObjectsViewTestCase(ModelViewTestCase): + """ + Edit multiple instances. + """ + bulk_edit_data = {} + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_edit_objects(self): # Bulk edit the first three objects only @@ -338,6 +373,10 @@ class StandardTestCases: for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): self.assertInstanceEqual(instance, self.bulk_edit_data) + class BulkDeleteObjectsViewTestCase(ModelViewTestCase): + """ + Delete multiple instances. + """ @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects(self): pk_list = self.model.objects.values_list('pk', flat=True) @@ -366,31 +405,55 @@ class StandardTestCases: # Check that all objects were deleted self.assertEqual(self.model.objects.count(), 0) - # - # Optional view tests - # These methods will run only if the required data - # + class PrimaryObjectViewTestCase( + GetObjectViewTestCase, + CreateObjectViewTestCase, + EditObjectViewTestCase, + DeleteObjectViewTestCase, + ListObjectsViewTestCase, + ImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing all standard View functions for primary objects + """ + maxDiff = None - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) - def _test_bulk_create_objects(self, expected_count): - initial_count = self.model.objects.count() - request = { - 'path': self._get_url('add'), - 'data': post_data(self.bulk_create_data), - 'follow': False, # Do not follow 302 redirects - } + class OrganizationalObjectViewTestCase( + CreateObjectViewTestCase, + EditObjectViewTestCase, + ListObjectsViewTestCase, + ImportObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for all organizational objects + """ + maxDiff = None - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) + class DeviceComponentTemplateViewTestCase( + EditObjectViewTestCase, + DeleteObjectViewTestCase, + BulkCreateObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing device component template models (ConsolePortTemplates, InterfaceTemplates, etc.) + """ + maxDiff = None - # Assign the required permission and submit again - self.add_permissions( - '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) - ) - response = self.client.post(**request) - self.assertHttpStatus(response, 302) - - self.assertEqual(initial_count + expected_count, self.model.objects.count()) - for instance in self.model.objects.order_by('-pk')[:expected_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + class DeviceComponentViewTestCase( + EditObjectViewTestCase, + DeleteObjectViewTestCase, + ListObjectsViewTestCase, + BulkCreateObjectsViewTestCase, + ImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, + BulkDeleteObjectsViewTestCase, + ): + """ + TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.) + """ + maxDiff = None diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 6cedf9803..639908977 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -3,19 +3,14 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Interface, Platform, Site from ipam.models import VLAN -from utilities.testing import StandardTestCases +from utilities.testing import ViewTestCases from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine -class ClusterGroupTestCase(StandardTestCases.Views): +class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterGroup - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -38,14 +33,9 @@ class ClusterGroupTestCase(StandardTestCases.Views): ) -class ClusterTypeTestCase(StandardTestCases.Views): +class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterType - # Disable inapplicable tests - test_get_object = None - test_delete_object = None - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): @@ -68,7 +58,7 @@ class ClusterTypeTestCase(StandardTestCases.Views): ) -class ClusterTestCase(StandardTestCases.Views): +class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cluster @classmethod @@ -124,7 +114,7 @@ class ClusterTestCase(StandardTestCases.Views): } -class VirtualMachineTestCase(StandardTestCases.Views): +class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualMachine @classmethod @@ -193,17 +183,16 @@ class VirtualMachineTestCase(StandardTestCases.Views): } -class InterfaceTestCase(StandardTestCases.Views): +class InterfaceTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.DeviceComponentViewTestCase, +): model = Interface # Disable inapplicable tests test_list_objects = None - test_create_object = None test_import_objects = None - def test_bulk_create_objects(self): - return self._test_bulk_create_objects(expected_count=3) - def _get_base_url(self): # Interface belongs to the DCIM app, so we have to override the base URL return 'virtualization:interface_{}' From fe452735bee0ece44a604830cb7788c022ff9122 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 12 Feb 2020 12:48:13 -0600 Subject: [PATCH 049/106] Add Device Bay count to rack elevation names --- netbox/dcim/models/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index c31f4c713..4adfebadf 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -389,6 +389,10 @@ class RackElevationHelperMixin: @staticmethod def _draw_device_front(drawing, device, start, end, text): + name = str(device) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + color = device.device_role.color link = drawing.add( drawing.a( @@ -403,7 +407,7 @@ class RackElevationHelperMixin: )) link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(device), insert=text, fill=hex_color)) + link.add(drawing.text(str(name), insert=text, fill=hex_color)) @staticmethod def _draw_device_rear(drawing, device, start, end, text): From 62d6e02d6bdb6d1a35d94e96d8de1e8e6b6625dd Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Wed, 12 Feb 2020 13:15:29 -0600 Subject: [PATCH 050/106] Modify _draw_elevations * Add legend_width argument, variable & constant * Applied legend_width variable where required * Removed U prefix --- netbox/dcim/constants.py | 2 ++ netbox/dcim/models/__init__.py | 24 +++++++++++------------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0e05867e4..13a5052e4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -9,6 +9,8 @@ from .choices import InterfaceTypeChoices RACK_U_HEIGHT_DEFAULT = 42 +RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 + RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 8f70fa25d..4a817ce11 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -433,20 +433,17 @@ class RackElevationHelperMixin: link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) - def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height): + def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width): - # Setup vars for the rack unit - position_width = 30 - - drawing = self._setup_drawing(unit_width + position_width, unit_height * self.u_height) + drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height) unit_cursor = 0 for ru in range(0, self.u_height): start_y = ru * unit_height - position_coordinates = (15, start_y + unit_height / 2 + 2) + position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2) unit = ru + 1 if self.desc_units else self.u_height - ru drawing.add( - drawing.text("U" + str(unit), position_coordinates, class_="unit") + drawing.text(str(unit), position_coordinates, class_="unit") ) for unit in elevation: @@ -458,9 +455,9 @@ class RackElevationHelperMixin: # Setup drawing coordinates start_y = unit_cursor * unit_height end_y = unit_height * height - start_cordinates = (position_width, start_y) - end_cordinates = (position_width + unit_width, end_y) - text_cordinates = (position_width + (unit_width / 2), start_y + end_y / 2) + start_cordinates = (legend_width, start_y) + end_cordinates = (legend_width + unit_width, end_y) + text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2) # Draw the device if device and device.face == face: @@ -482,7 +479,7 @@ class RackElevationHelperMixin: unit_cursor += height # Wrap the drawing with a border - drawing.add(drawing.rect((30, 0), (unit_width, self.u_height * unit_height), class_='rack')) + drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack')) return drawing @@ -505,7 +502,8 @@ class RackElevationHelperMixin: self, face=DeviceFaceChoices.FACE_FRONT, unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, - unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT, + legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT ): """ Return an SVG of the rack elevation @@ -518,7 +516,7 @@ class RackElevationHelperMixin: elevation = self.merge_elevations(face) reserved_units = self.get_reserved_units() - return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) + return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width) class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): From 8eea0331bfcff04a9fdd210ffd1a3bba1074a851 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Wed, 12 Feb 2020 19:59:04 +0000 Subject: [PATCH 051/106] Fixes #4150: Replace OrderedDict with Dict when rendering YAML --- netbox/utilities/templatetags/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 4278b3b95..ae679c91a 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -82,7 +82,7 @@ def render_yaml(value): """ Render a dictionary as formatted YAML. """ - return yaml.dump(dict(value)) + return yaml.dump(json.loads(json.dumps(value))) @register.filter() From 139f18b2e59f86347ba290cb8b63c1068f27c8b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 15:59:37 -0500 Subject: [PATCH 052/106] Closes #4153: Add a management command to manually renaturalize applicable objects (from #3799) --- .../management/commands/renaturalize.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 netbox/extras/management/commands/renaturalize.py diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py new file mode 100644 index 000000000..9ba4c44c2 --- /dev/null +++ b/netbox/extras/management/commands/renaturalize.py @@ -0,0 +1,119 @@ +from django.apps import apps +from django.core.management.base import BaseCommand, CommandError + +from utilities.fields import NaturalOrderingField + + +class Command(BaseCommand): + help = "Recalculate natural ordering values for the specified models" + + def add_arguments(self, parser): + parser.add_argument( + 'args', metavar='app_label.ModelName', nargs='*', + help='One or more specific models (each prefixed with its app_label) to renaturalize', + ) + + def _get_models(self, names): + """ + Compile a list of models to be renaturalized. If no names are specified, all models which have one or more + NaturalOrderingFields will be included. + """ + models = [] + + if names: + # Collect all NaturalOrderingFields present on the specified models + for name in names: + try: + app_label, model_name = name.split('.') + except ValueError: + raise CommandError( + "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name) + ) + try: + app_config = apps.get_app_config(app_label) + except LookupError as e: + raise CommandError(str(e)) + try: + model = app_config.get_model(model_name) + except LookupError: + raise CommandError("Unknown model: {}.{}".format(app_label, model_name)) + fields = [ + field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField + ] + if not fields: + raise CommandError( + "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name) + ) + models.append( + (model, fields) + ) + + else: + # Find *all* models with NaturalOrderingFields + for app_config in apps.get_app_configs(): + for model in app_config.models.values(): + fields = [ + field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField + ] + if fields: + models.append( + (model, fields) + ) + + return models + + def handle(self, *args, **options): + + models = self._get_models(args) + + if options['verbosity']: + self.stdout.write("Renaturalizing {} models.".format(len(models))) + + for model, fields in models: + for field in fields: + target_field = field.target_field + naturalize = field.naturalize_function + count = 0 + + # Print the model and field name + if options['verbosity']: + self.stdout.write( + "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name), + ending='\n' if options['verbosity'] >= 2 else '' + ) + self.stdout.flush() + + # Find all unique values for the field + queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct() + for value in queryset: + naturalized_value = naturalize(value) + + # Skip any naturalized values that don't differ from their original form + if value == naturalized_value: + if options['verbosity'] >= 3: + self.stdout.write(self.style.WARNING( + " {} == {} (skipped)".format(value, naturalized_value) + )) + continue + + if options['verbosity'] >= 2: + self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='') + self.stdout.flush() + + # Update each unique field value in bulk + changed = model.objects.filter(name=value).update(**{field.name: naturalized_value}) + + if options['verbosity'] >= 2: + self.stdout.write(" ({})".format(changed)) + count += changed + + # Print the total count of alterations for the field + if options['verbosity'] >= 2: + self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format( + count, model._meta.verbose_name_plural, queryset.count() + ))) + elif options['verbosity']: + self.stdout.write(self.style.SUCCESS(str(count))) + + if options['verbosity']: + self.stdout.write(self.style.SUCCESS("Done.")) From 6d7c5d51fe43b8ca447e8cee4b8bbdf021a58e4f Mon Sep 17 00:00:00 2001 From: Joshua Corrick Date: Wed, 12 Feb 2020 16:15:05 -0500 Subject: [PATCH 053/106] Add email testing example (#4152) * Add email testing example Includes an example provided by Jeremy * Updated with suggestions Co-authored-by: Jeremy Stretch --- docs/configuration/optional-settings.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 8cadddeb5..3ea4cec5a 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -109,6 +109,14 @@ In order to send email, NetBox needs an email server configured. The following i * TIMEOUT - Amount of time to wait for a connection (seconds) * FROM_EMAIL - Sender address for emails sent by NetBox +Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()]( https://docs.djangoproject.com/en/3.0/topics/email/#send-mail): + +``` +# python ./manage.py nbshell +>>> from django.core.mail import send_mail +>>> send_mail('Test Email Subject', 'Test Email Body', 'noreply-netbox@example.com', ['users@example.com'], fail_silently=False) +``` + --- ## EXEMPT_VIEW_PERMISSIONS From 4064c32a7f06f655b1ac22edfe8cbf24963d8261 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 16:16:23 -0500 Subject: [PATCH 054/106] Fix nav menu link for configcontext_add --- netbox/templates/inc/nav_menu.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 5e6621813..55ac3e5c2 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -478,12 +478,12 @@
  • - Config Contexts {% if perms.extras.add_configcontext %}
    {% endif %} + Config Contexts Scripts From 5cc24c055b39340d46971f5cbe96e37bc1b9bf69 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 16:18:08 -0500 Subject: [PATCH 055/106] Tweak docs link for send_mail() to reference stable; formatting --- docs/configuration/optional-settings.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 3ea4cec5a..cbe01728c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -109,12 +109,18 @@ In order to send email, NetBox needs an email server configured. The following i * TIMEOUT - Amount of time to wait for a connection (seconds) * FROM_EMAIL - Sender address for emails sent by NetBox -Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()]( https://docs.djangoproject.com/en/3.0/topics/email/#send-mail): +Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail): ``` # python ./manage.py nbshell >>> from django.core.mail import send_mail ->>> send_mail('Test Email Subject', 'Test Email Body', 'noreply-netbox@example.com', ['users@example.com'], fail_silently=False) +>>> send_mail( + 'Test Email Subject', + 'Test Email Body', + 'noreply-netbox@example.com', + ['users@example.com'], + fail_silently=False +) ``` --- From 1e221cd9bb91e33eae0ebe7140e5210044f153ce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 16:28:19 -0500 Subject: [PATCH 056/106] Update changelog --- docs/release-notes/version-2.7.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 9e6379adb..286406364 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components +* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevation * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views @@ -20,7 +21,9 @@ * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms * [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type * [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit +* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Include device bay counts in rack elevation diagrams * [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption +* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts --- From 0d57cb0033bbc7e7882d39ebb32b0c4505d562dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 16:54:40 -0500 Subject: [PATCH 057/106] Changelog & docs for #3766 --- docs/additional-features/custom-scripts.md | 5 +++-- docs/release-notes/version-2.7.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 6fac5b63d..cf98a6290 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -177,10 +177,11 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a All variables support the following default options: -* `label` - The name of the form field -* `description` - A brief description of the field * `default` - The field's default value +* `description` - A brief description of the field +* `label` - The name of the form field * `required` - Indicates whether the field is mandatory (default: true) +* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/)) ## Example diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 286406364..375174757 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -2,6 +2,7 @@ ## Enhancements +* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable * [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components * [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevation * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views From a08ee680330d9ca752e9a29891005a9f3eaeba8b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Feb 2020 21:56:23 -0500 Subject: [PATCH 058/106] Exempt pull requests from stalebot --- .github/stale.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/stale.yml b/.github/stale.yml index 61201cc4e..43401de8a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,5 +1,8 @@ # Configuration for Stale (https://github.com/apps/stale) +# Pull requests are exempt from being marked as stale +only: issues + # Number of days of inactivity before an issue becomes stale daysUntilStale: 14 From ca56fc709a7a7ada213e9e20947825411174c625 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Thu, 6 Feb 2020 03:20:59 -0500 Subject: [PATCH 059/106] Fixes #4093: Add decommissioning for vms --- docs/release-notes/version-2.7.md | 1 + netbox/virtualization/choices.py | 3 +++ netbox/virtualization/models.py | 7 ++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 375174757..56b519f6f 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -37,6 +37,7 @@ * [#3313](https://github.com/netbox-community/netbox/issues/3313) - Toggle config context display between JSON and YAML * [#3886](https://github.com/netbox-community/netbox/issues/3886) - Enable assigning config contexts by cluster and cluster group * [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command +* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add decommissioning status for VMs ## Bug Fixes diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 3c4a17c7b..ad8d12554 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -11,17 +11,20 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_ACTIVE = 'active' STATUS_OFFLINE = 'offline' STATUS_STAGED = 'staged' + STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( (STATUS_ACTIVE, 'Active'), (STATUS_OFFLINE, 'Offline'), (STATUS_STAGED, 'Staged'), + (STATUS_DECOMMISSIONING, 'Decommissioning'), ) LEGACY_MAP = { STATUS_OFFLINE: 0, STATUS_ACTIVE: 1, STATUS_STAGED: 3, + STATUS_DECOMMISSIONING: 4, } diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3ec5ccf8e..99866359a 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -267,9 +267,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ] STATUS_CLASS_MAP = { - 'active': 'success', - 'offline': 'warning', - 'staged': 'primary', + VirtualMachineStatusChoices.STATUS_ACTIVE: 'success', + VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning', + VirtualMachineStatusChoices.STATUS_STAGED: 'primary', + VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning', } class Meta: From bc7f5fb33a8c22dcca7ecc00abd724bd81b4cdc8 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Tue, 11 Feb 2020 00:26:09 -0500 Subject: [PATCH 060/106] Modify offline status color for vm --- docs/release-notes/version-2.7.md | 2 +- netbox/virtualization/choices.py | 1 - netbox/virtualization/models.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 56b519f6f..89cb15a4f 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views * [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components +* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add decommissioning status for VMs ## Bug Fixes @@ -37,7 +38,6 @@ * [#3313](https://github.com/netbox-community/netbox/issues/3313) - Toggle config context display between JSON and YAML * [#3886](https://github.com/netbox-community/netbox/issues/3886) - Enable assigning config contexts by cluster and cluster group * [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command -* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add decommissioning status for VMs ## Bug Fixes diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index ad8d12554..85eeda369 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -24,7 +24,6 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_OFFLINE: 0, STATUS_ACTIVE: 1, STATUS_STAGED: 3, - STATUS_DECOMMISSIONING: 4, } diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 99866359a..9508cd1f8 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -268,7 +268,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): STATUS_CLASS_MAP = { VirtualMachineStatusChoices.STATUS_ACTIVE: 'success', - VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning', + VirtualMachineStatusChoices.STATUS_OFFLINE: 'danger', VirtualMachineStatusChoices.STATUS_STAGED: 'primary', VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning', } From 335343642b0e24b561a74412e6ac4bccffcd9228 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Wed, 12 Feb 2020 22:43:40 -0500 Subject: [PATCH 061/106] additional status choices for vms --- docs/release-notes/version-2.7.md | 2 +- netbox/virtualization/choices.py | 8 ++++++-- netbox/virtualization/models.py | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 89cb15a4f..3e2bf8f1b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -5,11 +5,11 @@ * [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable * [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components * [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevation +* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add multiple status choices for VMs * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views * [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components -* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add decommissioning status for VMs ## Bug Fixes diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 85eeda369..1dae88e1d 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -8,15 +8,19 @@ from utilities.choices import ChoiceSet class VirtualMachineStatusChoices(ChoiceSet): - STATUS_ACTIVE = 'active' STATUS_OFFLINE = 'offline' + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' STATUS_STAGED = 'staged' + STATUS_FAILED = 'failed' STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_ACTIVE, 'Active'), (STATUS_OFFLINE, 'Offline'), + (STATUS_ACTIVE, 'Active'), + (STATUS_PLANNED, 'Planned'), (STATUS_STAGED, 'Staged'), + (STATUS_FAILED, 'Failed'), (STATUS_DECOMMISSIONING, 'Decommissioning'), ) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 9508cd1f8..13b181137 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -267,9 +267,11 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ] STATUS_CLASS_MAP = { + VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning', VirtualMachineStatusChoices.STATUS_ACTIVE: 'success', - VirtualMachineStatusChoices.STATUS_OFFLINE: 'danger', + VirtualMachineStatusChoices.STATUS_PLANNED: 'info', VirtualMachineStatusChoices.STATUS_STAGED: 'primary', + VirtualMachineStatusChoices.STATUS_FAILED: 'danger', VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning', } From 617fc7659fd29053b307587b4345c2204b3ed5b4 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Thu, 13 Feb 2020 08:26:47 -0600 Subject: [PATCH 062/106] Fixes: #4159 - Corrects settings.py to use CACHING_REDIS instead of WEBHOOKS_REDIS --- netbox/netbox/settings.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index aa90bdcbb..305f7efa8 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -176,12 +176,12 @@ WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) CACHING_REDIS = REDIS.get('caching', {}) -CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') -CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) -CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') -CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) -CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) -CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) +CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost') +CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379) +CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') +CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) +CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300) +CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) # From 344fa72357e159712c0269d8d3b050d67fbb4222 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 09:33:01 -0500 Subject: [PATCH 063/106] renaturalize command should not skip any values --- netbox/extras/management/commands/renaturalize.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py index 9ba4c44c2..70f57c1ba 100644 --- a/netbox/extras/management/commands/renaturalize.py +++ b/netbox/extras/management/commands/renaturalize.py @@ -88,14 +88,6 @@ class Command(BaseCommand): for value in queryset: naturalized_value = naturalize(value) - # Skip any naturalized values that don't differ from their original form - if value == naturalized_value: - if options['verbosity'] >= 3: - self.stdout.write(self.style.WARNING( - " {} == {} (skipped)".format(value, naturalized_value) - )) - continue - if options['verbosity'] >= 2: self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='') self.stdout.flush() From 47b15aacef683760af48e9cd5e22fa277243bb7b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 09:48:12 -0500 Subject: [PATCH 064/106] Changelog for #4159 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 3e2bf8f1b..63745bbab 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -26,6 +26,7 @@ * [#4138](https://github.com/netbox-community/netbox/issues/4138) - Include device bay counts in rack elevation diagrams * [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption * [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts +* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration --- From 0c89534bfba2f3a32a97e617de4f9a1cbe95b91a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 10:08:10 -0500 Subject: [PATCH 065/106] Closes #4160: Link to full database configuration parameters in configuration docs --- docs/configuration/required-settings.md | 5 ++++- netbox/netbox/configuration.example.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index dd7492cb4..70c6de276 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv * `PASSWORD` - PostgreSQL password * `HOST` - Name or IP address of the database server (use `localhost` if running locally) * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432) -* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)). +* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended) Example: @@ -36,6 +36,9 @@ DATABASE = { } ``` +!!! note + NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). + --- ## REDIS diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index c1258d83b..885c9e0ba 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -10,7 +10,8 @@ # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] ALLOWED_HOSTS = [] -# PostgreSQL database configuration. +# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: +# https://docs.djangoproject.com/en/stable/ref/settings/#databases DATABASE = { 'NAME': 'netbox', # Database name 'USER': '', # PostgreSQL username From 3538eeda14a5ba1921299f2742593f0b9e434807 Mon Sep 17 00:00:00 2001 From: Dan Starner Date: Thu, 13 Feb 2020 08:53:46 -0500 Subject: [PATCH 066/106] allow for redis sentinel connection --- netbox/netbox/configuration.example.py | 6 +++ netbox/netbox/settings.py | 55 ++++++++++++++++++++------ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 885c9e0ba..7002def9b 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -28,6 +28,9 @@ REDIS = { 'webhooks': { 'HOST': 'localhost', 'PORT': 6379, + # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel + # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, @@ -36,6 +39,9 @@ REDIS = { 'caching': { 'HOST': 'localhost', 'PORT': 6379, + # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel + # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, 'DEFAULT_TIMEOUT': 300, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 305f7efa8..4b6177318 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -170,14 +170,27 @@ if 'caching' not in REDIS: WEBHOOKS_REDIS = REDIS.get('webhooks', {}) WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost') WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379) +WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', []) +WEBHOOKS_REDIS_USING_SENTINEL = all([ + isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)), + len(WEBHOOKS_REDIS_SENTINELS) > 0 +]) +WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default') WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '') WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0) WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300) WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False) + CACHING_REDIS = REDIS.get('caching', {}) CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost') CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379) +CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', []) +CACHING_REDIS_USING_SENTINEL = all([ + isinstance(CACHING_REDIS_SENTINELS, (list, tuple)), + len(CACHING_REDIS_SENTINELS) > 0 +]) +CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300) @@ -394,28 +407,35 @@ if LDAP_CONFIG is not None: # # Caching # - -if CACHING_REDIS_SSL: - REDIS_CACHE_CON_STRING = 'rediss://' +if CACHING_REDIS_USING_SENTINEL: + CACHEOPS_SENTINEL = { + 'locations': CACHING_REDIS_SENTINELS, + 'service_name': CACHING_REDIS_SENTINEL_SERVICE, + 'db': CACHING_REDIS_DATABASE, + } else: - REDIS_CACHE_CON_STRING = 'redis://' + if CACHING_REDIS_SSL: + REDIS_CACHE_CON_STRING = 'rediss://' + else: + REDIS_CACHE_CON_STRING = 'redis://' -if CACHING_REDIS_PASSWORD: - REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) + if CACHING_REDIS_PASSWORD: + REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD) -REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( - REDIS_CACHE_CON_STRING, - CACHING_REDIS_HOST, - CACHING_REDIS_PORT, - CACHING_REDIS_DATABASE -) + REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format( + REDIS_CACHE_CON_STRING, + CACHING_REDIS_HOST, + CACHING_REDIS_PORT, + CACHING_REDIS_DATABASE + ) + CACHEOPS_REDIS = REDIS_CACHE_CON_STRING if not CACHE_TIMEOUT: CACHEOPS_ENABLED = False else: CACHEOPS_ENABLED = True -CACHEOPS_REDIS = REDIS_CACHE_CON_STRING + CACHEOPS_DEFAULTS = { 'timeout': CACHE_TIMEOUT } @@ -534,6 +554,15 @@ RQ_QUEUES = { 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, 'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT, 'SSL': WEBHOOKS_REDIS_SSL, + } if not WEBHOOKS_REDIS_USING_SENTINEL else { + 'SENTINELS': WEBHOOKS_REDIS_SENTINELS, + 'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE, + 'DB': WEBHOOKS_REDIS_DATABASE, + 'PASSWORD': WEBHOOKS_REDIS_PASSWORD, + 'SOCKET_TIMEOUT': None, + 'CONNECTION_KWARGS': { + 'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT + }, } } From ba9a2956a8ac6076b4f9e74e857e6b4634e2a836 Mon Sep 17 00:00:00 2001 From: Dan Starner Date: Thu, 13 Feb 2020 09:10:53 -0500 Subject: [PATCH 067/106] documentation on redis sentinel --- docs/configuration/required-settings.md | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index 70c6de276..81790eae0 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -88,6 +88,48 @@ REDIS = { It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the same Redis instance for both may result in webhook processing data being lost during cache flushing events. +### Using Redis Sentinel + +If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal +configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from +above and the addition of two new keys. + +* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address +of the Redis server and port for each sentinel instance to connect to +* `SENTINEL_SERVICE`: Name of the master / service to connect to + +Example: + +```python +REDIS = { + 'webhooks': { + 'SENTINELS': [('mysentinel.redis.example.com', 6379)], + 'SENTINEL_SERVICE': 'netbox', + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + }, + 'caching': { + 'SENTINELS': [ + ('mysentinel.redis.example.com', 6379), + ('othersentinel.redis.example.com', 6379) + ], + 'SENTINEL_SERVICE': 'netbox', + 'PASSWORD': '', + 'DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + } +} +``` + +!!! note: + It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible + for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via + `SENTINELS`/`SENTINEL_SERVICE`. + + --- ## SECRET_KEY From 72f0e31b84820a7eaf508e567956c0d365f2afba Mon Sep 17 00:00:00 2001 From: Dan Starner Date: Thu, 13 Feb 2020 10:27:56 -0500 Subject: [PATCH 068/106] fixed extraneous linting error --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4b6177318..1a36cf424 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -409,7 +409,7 @@ if LDAP_CONFIG is not None: # if CACHING_REDIS_USING_SENTINEL: CACHEOPS_SENTINEL = { - 'locations': CACHING_REDIS_SENTINELS, + 'locations': CACHING_REDIS_SENTINELS, 'service_name': CACHING_REDIS_SENTINEL_SERVICE, 'db': CACHING_REDIS_DATABASE, } From 874e59b01a90522a94477c8a473112484bbc3799 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 10:30:12 -0500 Subject: [PATCH 069/106] Closes #4060: Move secrets panel on home page to righthand column --- netbox/templates/home.html | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/netbox/templates/home.html b/netbox/templates/home.html index be63b19c5..c4821d6c3 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -127,23 +127,6 @@
    -
    -
    - Secrets -
    -
    -
    - {% if perms.secrets.view_secret %} - {{ stats.secret_count }} -

    Secrets

    - {% else %} - -

    Secrets

    - {% endif %} -

    Cryptographically secured secret data

    -
    -
    -
    @@ -259,6 +242,23 @@
    +
    +
    + Secrets +
    +
    +
    + {% if perms.secrets.view_secret %} + {{ stats.secret_count }} +

    Secrets

    + {% else %} + +

    Secrets

    + {% endif %} +

    Cryptographically secured secret data

    +
    +
    +
    Reports From 35498c17d78e05ae4dd43069d94f768a8a53e068 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 11:04:07 -0500 Subject: [PATCH 070/106] Updated the style guide --- docs/development/style-guide.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 4c490eebf..53b2215b3 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -32,7 +32,7 @@ pycodestyle --ignore=W504,E501 netbox/ The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks. -If there's a strong case for introducing a new depdency, it must meet the following criteria: +If there's a strong case for introducing a new dependency, it must meet the following criteria: * Its complete source code must be published and freely accessible without registration. * Its license must be conducive to inclusion in an open source project. @@ -45,10 +45,18 @@ When adding a new dependency, a short description of the package and the URL of * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point. +* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it. + * No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely. * Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable. -* Every model should have a docstring. Every custom method should include an expalantion of its function. +* Every model should have a docstring. Every custom method should include an explanation of its function. * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. + +## Branding + +* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation. + +* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size. From c5f74cce805e7fb4a73745a41ddf1b8a5388e8f2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 13:13:27 -0500 Subject: [PATCH 071/106] Introduce a common template for object list views --- netbox/templates/utilities/obj_list.html | 28 ++++++++++++++++++++++++ netbox/utilities/templatetags/helpers.py | 19 ++++++++++++++-- netbox/utilities/views.py | 10 +++++---- 3 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 netbox/templates/utilities/obj_list.html diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html new file mode 100644 index 000000000..c06afaed8 --- /dev/null +++ b/netbox/templates/utilities/obj_list.html @@ -0,0 +1,28 @@ +{% extends '_base.html' %} +{% load buttons %} +{% load helpers %} + +{% block content %} +
    + {% if permissions.add %} + {% add_button content_type.model_class|url_name:"add" %} + {% import_button content_type.model_class|url_name:"import" %} + {% endif %} + {% export_button content_type %} +
    +

    {% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}

    +
    + {% if filter_form %} +
    + {% include 'utilities/obj_table.html' with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} +
    +
    + {% include 'inc/search_panel.html' %} +
    + {% else %} +
    + {% include 'utilities/obj_table.html' with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} +
    + {% endif %} +
    +{% endblock %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index ae679c91a..dfeddd31a 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,9 +1,10 @@ import datetime import json import re -import yaml +import yaml from django import template +from django.urls import NoReverseMatch, reverse from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown @@ -11,7 +12,6 @@ from markdown import markdown from utilities.choices import unpack_grouped_choices from utilities.utils import foreground_color - register = template.Library() @@ -101,6 +101,21 @@ def model_name_plural(obj): return obj._meta.verbose_name_plural +@register.filter() +def url_name(model, action): + """ + Return the URL name for the given model and action, or None if invalid. + """ + url_name = '{}:{}_{}'.format(model._meta.app_label, model._meta.model_name, action) + try: + # Validate and return the URL name. We don't return the actual URL yet because many of the templates + # are written to pass a name to {% url %}. + reverse(url_name) + return url_name + except NoReverseMatch: + return None + + @register.filter() def contains(value, arg): """ diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7c38aceee..7618031ed 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -71,7 +71,7 @@ class ObjectListView(View): filterset = None filterset_form = None table = None - template_name = None + template_name = 'utilities/obj_list.html' def queryset_to_yaml(self): """ @@ -156,9 +156,11 @@ class ObjectListView(View): # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list self.queryset = self.alter_queryset(request) - # Compile user model permissions for access from within the template - perm_base_name = '{}.{{}}_{}'.format(model._meta.app_label, model._meta.model_name) - permissions = {p: request.user.has_perm(perm_base_name.format(p)) for p in ['add', 'change', 'delete']} + # Compile a dictionary indicating which permissions are available to the current user for this model + permissions = {} + for action in ('add', 'change', 'delete', 'view'): + perm_name = '{}.{}_{}'.format(model._meta.app_label, action, model._meta.model_name) + permissions[action] = request.user.has_perm(perm_name) # Construct the table based on the user's permissions table = self.table(self.queryset) From 4ef15e4dc89b90ef6c5ac6d5a401c6683177b29a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 13:31:04 -0500 Subject: [PATCH 072/106] Migrate circuits views to use common object list template --- netbox/circuits/views.py | 3 --- netbox/templates/circuits/circuit_list.html | 21 ------------------- .../templates/circuits/circuittype_list.html | 18 ---------------- netbox/templates/circuits/provider_list.html | 21 ------------------- 4 files changed, 63 deletions(-) delete mode 100644 netbox/templates/circuits/circuit_list.html delete mode 100644 netbox/templates/circuits/circuittype_list.html delete mode 100644 netbox/templates/circuits/provider_list.html diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 15cf901c1..ba873f23f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm table = tables.ProviderDetailTable - template_name = 'circuits/provider_list.html' class ProviderView(PermissionRequiredMixin, View): @@ -107,7 +106,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): permission_required = 'circuits.view_circuittype' queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable - template_name = 'circuits/circuittype_list.html' class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): @@ -151,7 +149,6 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView): filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable - template_name = 'circuits/circuit_list.html' class CircuitView(PermissionRequiredMixin, View): diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html deleted file mode 100644 index 169aab072..000000000 --- a/netbox/templates/circuits/circuit_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.circuits.add_circuit %} - {% add_button 'circuits:circuit_add' %} - {% import_button 'circuits:circuit_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Circuits{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html deleted file mode 100644 index 654d4ab09..000000000 --- a/netbox/templates/circuits/circuittype_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.circuits.add_circuittype %} - {% add_button 'circuits:circuittype_add' %} - {% import_button 'circuits:circuittype_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Circuit Types{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='circuits:circuittype_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html deleted file mode 100644 index 4126f75ec..000000000 --- a/netbox/templates/circuits/provider_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.circuits.add_provider %} - {% add_button 'circuits:provider_add' %} - {% import_button 'circuits:provider_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Providers{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} From fff657cd5a2649bbfa7d84a7e119ce444009cdfc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 14:07:15 -0500 Subject: [PATCH 073/106] Migrate DCIM views to use common object list template --- netbox/dcim/views.py | 36 +++++++------------ netbox/templates/dcim/cable_list.html | 20 ----------- netbox/templates/dcim/consoleport_list.html | 17 --------- .../dcim/consoleserverport_list.html | 17 --------- netbox/templates/dcim/devicebay_list.html | 17 --------- netbox/templates/dcim/devicerole_list.html | 18 ---------- netbox/templates/dcim/devicetype_list.html | 21 ----------- netbox/templates/dcim/frontport_list.html | 17 --------- netbox/templates/dcim/interface_list.html | 17 --------- netbox/templates/dcim/inventoryitem_list.html | 21 ----------- netbox/templates/dcim/manufacturer_list.html | 18 ---------- netbox/templates/dcim/platform_list.html | 18 ---------- netbox/templates/dcim/powerfeed_list.html | 21 ----------- netbox/templates/dcim/poweroutlet_list.html | 17 --------- netbox/templates/dcim/powerpanel_list.html | 21 ----------- netbox/templates/dcim/powerport_list.html | 17 --------- netbox/templates/dcim/rack_list.html | 21 ----------- netbox/templates/dcim/rackgroup_list.html | 21 ----------- .../templates/dcim/rackreservation_list.html | 14 -------- netbox/templates/dcim/rackrole_list.html | 18 ---------- netbox/templates/dcim/rearport_list.html | 17 --------- netbox/templates/dcim/region_list.html | 21 ----------- netbox/templates/dcim/site_list.html | 21 ----------- .../templates/dcim/virtualchassis_list.html | 18 ---------- netbox/templates/utilities/obj_list.html | 8 +++-- netbox/utilities/views.py | 2 ++ 26 files changed, 21 insertions(+), 453 deletions(-) delete mode 100644 netbox/templates/dcim/cable_list.html delete mode 100644 netbox/templates/dcim/consoleport_list.html delete mode 100644 netbox/templates/dcim/consoleserverport_list.html delete mode 100644 netbox/templates/dcim/devicebay_list.html delete mode 100644 netbox/templates/dcim/devicerole_list.html delete mode 100644 netbox/templates/dcim/devicetype_list.html delete mode 100644 netbox/templates/dcim/frontport_list.html delete mode 100644 netbox/templates/dcim/interface_list.html delete mode 100644 netbox/templates/dcim/inventoryitem_list.html delete mode 100644 netbox/templates/dcim/manufacturer_list.html delete mode 100644 netbox/templates/dcim/platform_list.html delete mode 100644 netbox/templates/dcim/powerfeed_list.html delete mode 100644 netbox/templates/dcim/poweroutlet_list.html delete mode 100644 netbox/templates/dcim/powerpanel_list.html delete mode 100644 netbox/templates/dcim/powerport_list.html delete mode 100644 netbox/templates/dcim/rack_list.html delete mode 100644 netbox/templates/dcim/rackgroup_list.html delete mode 100644 netbox/templates/dcim/rackreservation_list.html delete mode 100644 netbox/templates/dcim/rackrole_list.html delete mode 100644 netbox/templates/dcim/rearport_list.html delete mode 100644 netbox/templates/dcim/region_list.html delete mode 100644 netbox/templates/dcim/site_list.html delete mode 100644 netbox/templates/dcim/virtualchassis_list.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ae59890a3..89f051b2a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -152,7 +152,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RegionFilterSet filterset_form = forms.RegionFilterForm table = tables.RegionTable - template_name = 'dcim/region_list.html' class RegionCreateView(PermissionRequiredMixin, ObjectEditView): @@ -191,7 +190,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView): filterset = filters.SiteFilterSet filterset_form = forms.SiteFilterForm table = tables.SiteTable - template_name = 'dcim/site_list.html' class SiteView(PermissionRequiredMixin, View): @@ -271,7 +269,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackGroupFilterSet filterset_form = forms.RackGroupFilterForm table = tables.RackGroupTable - template_name = 'dcim/rackgroup_list.html' class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -308,7 +305,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_rackrole' queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable - template_name = 'dcim/rackrole_list.html' class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -350,7 +346,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackFilterSet filterset_form = forms.RackFilterForm table = tables.RackDetailTable - template_name = 'dcim/rack_list.html' class RackElevationListView(PermissionRequiredMixin, View): @@ -474,7 +469,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable - template_name = 'dcim/rackreservation_list.html' + action_buttons = () class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): @@ -533,7 +528,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView): platform_count=Count('platforms', distinct=True), ) table = tables.ManufacturerTable - template_name = 'dcim/manufacturer_list.html' class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): @@ -571,7 +565,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm table = tables.DeviceTypeTable - template_name = 'dcim/devicetype_list.html' class DeviceTypeView(PermissionRequiredMixin, View): @@ -995,7 +988,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_devicerole' queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable - template_name = 'dcim/devicerole_list.html' class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -1031,7 +1023,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView): permission_required = 'dcim.view_platform' queryset = Platform.objects.all() table = tables.PlatformTable - template_name = 'dcim/platform_list.html' class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): @@ -1071,6 +1062,7 @@ class DeviceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceFilterSet filterset_form = forms.DeviceFilterForm table = tables.DeviceDetailTable + # TODO: Remove custom template template_name = 'dcim/device_list.html' @@ -1292,7 +1284,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortDetailTable - template_name = 'dcim/consoleport_list.html' + action_buttons = ('import', 'export') class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1345,7 +1337,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortDetailTable - template_name = 'dcim/consoleserverport_list.html' + action_buttons = ('import', 'export') class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1410,7 +1402,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortDetailTable - template_name = 'dcim/powerport_list.html' + action_buttons = ('import', 'export') class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1463,7 +1455,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletDetailTable - template_name = 'dcim/poweroutlet_list.html' + action_buttons = ('import', 'export') class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1528,7 +1520,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceDetailTable - template_name = 'dcim/interface_list.html' + action_buttons = ('import', 'export') class InterfaceView(PermissionRequiredMixin, View): @@ -1630,7 +1622,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortDetailTable - template_name = 'dcim/frontport_list.html' + action_buttons = ('import', 'export') class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1695,7 +1687,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortDetailTable - template_name = 'dcim/rearport_list.html' + action_buttons = ('import', 'export') class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1762,7 +1754,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayDetailTable - template_name = 'dcim/devicebay_list.html' + action_buttons = ('import', 'export') class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1961,7 +1953,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView): filterset = filters.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable - template_name = 'dcim/cable_list.html' + action_buttons = ('import', 'export') class CableView(PermissionRequiredMixin, View): @@ -2233,7 +2225,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - template_name = 'dcim/inventoryitem_list.html' + action_buttons = ('import', 'export') class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): @@ -2289,7 +2281,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet filterset_form = forms.VirtualChassisFilterForm - template_name = 'dcim/virtualchassis_list.html' + action_buttons = ('export') class VirtualChassisCreateView(PermissionRequiredMixin, View): @@ -2533,7 +2525,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPanelFilterSet filterset_form = forms.PowerPanelFilterForm table = tables.PowerPanelTable - template_name = 'dcim/powerpanel_list.html' class PowerPanelView(PermissionRequiredMixin, View): @@ -2602,7 +2593,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerFeedFilterSet filterset_form = forms.PowerFeedFilterForm table = tables.PowerFeedTable - template_name = 'dcim/powerfeed_list.html' class PowerFeedView(PermissionRequiredMixin, View): diff --git a/netbox/templates/dcim/cable_list.html b/netbox/templates/dcim/cable_list.html deleted file mode 100644 index 0dd8095a5..000000000 --- a/netbox/templates/dcim/cable_list.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_cable %} - {% import_button 'dcim:cable_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Cables{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/consoleport_list.html b/netbox/templates/dcim/consoleport_list.html deleted file mode 100644 index 0ed840820..000000000 --- a/netbox/templates/dcim/consoleport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Console Ports{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_list.html b/netbox/templates/dcim/consoleserverport_list.html deleted file mode 100644 index 47a8676e3..000000000 --- a/netbox/templates/dcim/consoleserverport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Console Server Ports{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/devicebay_list.html b/netbox/templates/dcim/devicebay_list.html deleted file mode 100644 index 74f64858a..000000000 --- a/netbox/templates/dcim/devicebay_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Device Bays{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicebay_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/devicerole_list.html b/netbox/templates/dcim/devicerole_list.html deleted file mode 100644 index 9f560dab4..000000000 --- a/netbox/templates/dcim/devicerole_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_devicerole %} - {% add_button 'dcim:devicerole_add' %} - {% import_button 'dcim:devicerole_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Device Roles{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicerole_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html deleted file mode 100644 index 75f587f5d..000000000 --- a/netbox/templates/dcim/devicetype_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_devicetype %} - {% add_button 'dcim:devicetype_add' %} - {% import_button 'dcim:devicetype_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Device Types{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/frontport_list.html b/netbox/templates/dcim/frontport_list.html deleted file mode 100644 index a3334b876..000000000 --- a/netbox/templates/dcim/frontport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Front Ports{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:frontport_bulk_edit' bulk_delete_url='dcim:frontport_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/interface_list.html b/netbox/templates/dcim/interface_list.html deleted file mode 100644 index 9dd8f7858..000000000 --- a/netbox/templates/dcim/interface_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Interfaces{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:interface_bulk_edit' bulk_delete_url='dcim:interface_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/inventoryitem_list.html b/netbox/templates/dcim/inventoryitem_list.html deleted file mode 100644 index 57e7d2d03..000000000 --- a/netbox/templates/dcim/inventoryitem_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} -{% load helpers %} - -{% block content %} -
    - {% if perms.dcim.add_devicetype %} - {% import_button 'dcim:inventoryitem_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Inventory Items{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html deleted file mode 100644 index 9b612dfa5..000000000 --- a/netbox/templates/dcim/manufacturer_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_manufacturer %} - {% add_button 'dcim:manufacturer_add' %} - {% import_button 'dcim:manufacturer_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Manufacturers{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:manufacturer_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/platform_list.html b/netbox/templates/dcim/platform_list.html deleted file mode 100644 index d82ef9a44..000000000 --- a/netbox/templates/dcim/platform_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_platform %} - {% add_button 'dcim:platform_add' %} - {% import_button 'dcim:platform_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Platforms{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:platform_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_list.html b/netbox/templates/dcim/powerfeed_list.html deleted file mode 100644 index e384cb2c2..000000000 --- a/netbox/templates/dcim/powerfeed_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_powerfeed %} - {% add_button 'dcim:powerfeed_add' %} - {% import_button 'dcim:powerfeed_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Power Feeds{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_list.html b/netbox/templates/dcim/poweroutlet_list.html deleted file mode 100644 index 2e842d699..000000000 --- a/netbox/templates/dcim/poweroutlet_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Power Outlets{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:poweroutlet_bulk_edit' bulk_delete_url='dcim:poweroutlet_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/powerpanel_list.html b/netbox/templates/dcim/powerpanel_list.html deleted file mode 100644 index a0d49b30b..000000000 --- a/netbox/templates/dcim/powerpanel_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_powerpanel %} - {% add_button 'dcim:powerpanel_add' %} - {% import_button 'dcim:powerpanel_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Power Panels{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/powerport_list.html b/netbox/templates/dcim/powerport_list.html deleted file mode 100644 index b5830edca..000000000 --- a/netbox/templates/dcim/powerport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Power Ports{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerport_bulk_edit' bulk_delete_url='dcim:powerport_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html deleted file mode 100644 index 2724e4427..000000000 --- a/netbox/templates/dcim/rack_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_rack %} - {% add_button 'dcim:rack_add' %} - {% import_button 'dcim:rack_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Racks{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rack_bulk_edit' bulk_delete_url='dcim:rack_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html deleted file mode 100644 index 52723ef92..000000000 --- a/netbox/templates/dcim/rackgroup_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_rackgroup %} - {% add_button 'dcim:rackgroup_add' %} - {% import_button 'dcim:rackgroup_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Rack Groups{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/rackreservation_list.html b/netbox/templates/dcim/rackreservation_list.html deleted file mode 100644 index b5424bbe6..000000000 --- a/netbox/templates/dcim/rackreservation_list.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends '_base.html' %} -{% load helpers %} - -{% block content %} -

    {% block title %}Rack Reservations{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/rackrole_list.html b/netbox/templates/dcim/rackrole_list.html deleted file mode 100644 index 267ef3c7f..000000000 --- a/netbox/templates/dcim/rackrole_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_rackrole %} - {% add_button 'dcim:rackrole_add' %} - {% import_button 'dcim:rackrole_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Rack Roles{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/rearport_list.html b/netbox/templates/dcim/rearport_list.html deleted file mode 100644 index cc603d620..000000000 --- a/netbox/templates/dcim/rearport_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Rear Ports{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rearport_bulk_edit' bulk_delete_url='dcim:rearport_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html deleted file mode 100644 index ec1adfc06..000000000 --- a/netbox/templates/dcim/region_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_region %} - {% add_button 'dcim:region_add' %} - {% import_button 'dcim:region_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Regions{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html deleted file mode 100644 index ef9e0e411..000000000 --- a/netbox/templates/dcim/site_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.dcim.add_site %} - {% add_button 'dcim:site_add' %} - {% import_button 'dcim:site_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Sites{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' bulk_delete_url='dcim:site_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_list.html b/netbox/templates/dcim/virtualchassis_list.html deleted file mode 100644 index 55cfc1691..000000000 --- a/netbox/templates/dcim/virtualchassis_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} -{% load helpers %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Virtual Chassis{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index c06afaed8..dba9e2f36 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -4,11 +4,15 @@ {% block content %}
    - {% if permissions.add %} + {% if permissions.add and 'add' in action_buttons %} {% add_button content_type.model_class|url_name:"add" %} + {% endif %} + {% if permissions.add and 'import' in action_buttons %} {% import_button content_type.model_class|url_name:"import" %} {% endif %} - {% export_button content_type %} + {% if 'export' in action_buttons %} + {% export_button content_type %} + {% endif %}

    {% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}

    diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7618031ed..c93842d4b 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -72,6 +72,7 @@ class ObjectListView(View): filterset_form = None table = None template_name = 'utilities/obj_list.html' + action_buttons = ('add', 'import', 'export') def queryset_to_yaml(self): """ @@ -178,6 +179,7 @@ class ObjectListView(View): 'content_type': content_type, 'table': table, 'permissions': permissions, + 'action_buttons': self.action_buttons, 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, } context.update(self.extra_context()) From 8fd809ac5e9cfb36263293a624fb8f0a7656dd33 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 14:17:13 -0500 Subject: [PATCH 074/106] Migrate IPAM views to use common object list template --- netbox/ipam/views.py | 11 ++++------- netbox/templates/ipam/ipaddress_list.html | 21 --------------------- netbox/templates/ipam/role_list.html | 18 ------------------ netbox/templates/ipam/service_list.html | 17 ----------------- netbox/templates/ipam/vlan_list.html | 21 --------------------- netbox/templates/ipam/vlangroup_list.html | 21 --------------------- netbox/templates/ipam/vrf_list.html | 21 --------------------- 7 files changed, 4 insertions(+), 126 deletions(-) delete mode 100644 netbox/templates/ipam/ipaddress_list.html delete mode 100644 netbox/templates/ipam/role_list.html delete mode 100644 netbox/templates/ipam/service_list.html delete mode 100644 netbox/templates/ipam/vlan_list.html delete mode 100644 netbox/templates/ipam/vlangroup_list.html delete mode 100644 netbox/templates/ipam/vrf_list.html diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c8c7d40ca..8484d7a32 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VRFFilterSet filterset_form = forms.VRFFilterForm table = tables.VRFTable - template_name = 'ipam/vrf_list.html' class VRFView(PermissionRequiredMixin, View): @@ -187,6 +186,7 @@ class RIRListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RIRFilterSet filterset_form = forms.RIRFilterForm table = tables.RIRDetailTable + # TODO: Remove custom template template_name = 'ipam/rir_list.html' def alter_queryset(self, request): @@ -293,10 +293,10 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView): queryset = Aggregate.objects.prefetch_related('rir').annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) - filterset = filters.AggregateFilterSet filterset_form = forms.AggregateFilterForm table = tables.AggregateDetailTable + # TODO: Remove custom template template_name = 'ipam/aggregate_list.html' def extra_context(self): @@ -411,7 +411,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'ipam.view_role' queryset = Role.objects.all() table = tables.RoleTable - template_name = 'ipam/role_list.html' class RoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -449,6 +448,7 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PrefixFilterSet filterset_form = forms.PrefixFilterForm table = tables.PrefixDetailTable + # TODO: Remove custom template template_name = 'ipam/prefix_list.html' def alter_queryset(self, request): @@ -644,7 +644,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView): filterset = filters.IPAddressFilterSet filterset_form = forms.IPAddressFilterForm table = tables.IPAddressDetailTable - template_name = 'ipam/ipaddress_list.html' class IPAddressView(PermissionRequiredMixin, View): @@ -817,7 +816,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable - template_name = 'ipam/vlangroup_list.html' class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -893,7 +891,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VLANFilterSet filterset_form = forms.VLANFilterForm table = tables.VLANDetailTable - template_name = 'ipam/vlan_list.html' class VLANView(PermissionRequiredMixin, View): @@ -989,7 +986,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable - template_name = 'ipam/service_list.html' + action_buttons = ('export') class ServiceView(PermissionRequiredMixin, View): diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html deleted file mode 100644 index b7920a434..000000000 --- a/netbox/templates/ipam/ipaddress_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.ipam.add_ipaddress %} - {% add_button 'ipam:ipaddress_add' %} - {% import_button 'ipam:ipaddress_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}IP Addresses{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/role_list.html b/netbox/templates/ipam/role_list.html deleted file mode 100644 index 958fa8e06..000000000 --- a/netbox/templates/ipam/role_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.ipam.add_role %} - {% add_button 'ipam:role_add' %} - {% import_button 'ipam:role_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Prefix/VLAN Roles{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:role_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html deleted file mode 100644 index 4aac520d9..000000000 --- a/netbox/templates/ipam/service_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Services{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html deleted file mode 100644 index 24d538f88..000000000 --- a/netbox/templates/ipam/vlan_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.ipam.add_vlan %} - {% add_button 'ipam:vlan_add' %} - {% import_button 'ipam:vlan_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}VLANs{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/vlangroup_list.html b/netbox/templates/ipam/vlangroup_list.html deleted file mode 100644 index 16ddd9669..000000000 --- a/netbox/templates/ipam/vlangroup_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.ipam.add_vlangroup %} - {% add_button 'ipam:vlangroup_add' %} - {% import_button 'ipam:vlangroup_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}VLAN Groups{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html deleted file mode 100644 index 975c73a37..000000000 --- a/netbox/templates/ipam/vrf_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.ipam.add_vrf %} - {% add_button 'ipam:vrf_add' %} - {% import_button 'ipam:vrf_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}VRFs{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vrf_bulk_edit' bulk_delete_url='ipam:vrf_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} From a054aff3c431e44a4d2f395fd7cb06a4adb97b28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 14:19:14 -0500 Subject: [PATCH 075/106] Migrate secrets views to use common object list template --- netbox/secrets/views.py | 3 +-- netbox/templates/secrets/secret_list.html | 20 ------------------- netbox/templates/secrets/secretrole_list.html | 18 ----------------- 3 files changed, 1 insertion(+), 40 deletions(-) delete mode 100644 netbox/templates/secrets/secret_list.html delete mode 100644 netbox/templates/secrets/secretrole_list.html diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 288edaa6f..d92e4b64d 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView): permission_required = 'secrets.view_secretrole' queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable - template_name = 'secrets/secretrole_list.html' class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): @@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView): filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm table = tables.SecretTable - template_name = 'secrets/secret_list.html' + action_buttons = ('import', 'export') class SecretView(PermissionRequiredMixin, View): diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html deleted file mode 100644 index ee631b439..000000000 --- a/netbox/templates/secrets/secret_list.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.secrets.add_secret %} - {% import_button 'secrets:secret_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Secrets{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='secrets:secret_bulk_edit' bulk_delete_url='secrets:secret_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html deleted file mode 100644 index 0e4caadae..000000000 --- a/netbox/templates/secrets/secretrole_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.secrets.add_secretrole %} - {% add_button 'secrets:secretrole_add' %} - {% import_button 'secrets:secretrole_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Secret Roles{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='secrets:secretrole_bulk_delete' %} -
    -
    -{% endblock %} From 88c917231de849646544d1ea76c801f893f681c3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 14:21:14 -0500 Subject: [PATCH 076/106] Migrate tenancy views to use common object list template --- netbox/templates/tenancy/tenant_list.html | 21 ------------------- .../templates/tenancy/tenantgroup_list.html | 18 ---------------- netbox/tenancy/views.py | 2 -- 3 files changed, 41 deletions(-) delete mode 100644 netbox/templates/tenancy/tenant_list.html delete mode 100644 netbox/templates/tenancy/tenantgroup_list.html diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html deleted file mode 100644 index a77636a5b..000000000 --- a/netbox/templates/tenancy/tenant_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.tenancy.add_tenant %} - {% add_button 'tenancy:tenant_add' %} - {% import_button 'tenancy:tenant_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Tenants{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='tenancy:tenant_bulk_edit' bulk_delete_url='tenancy:tenant_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html deleted file mode 100644 index af0dc1aad..000000000 --- a/netbox/templates/tenancy/tenantgroup_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.tenancy.add_tenantgroup %} - {% add_button 'tenancy:tenantgroup_add' %} - {% import_button 'tenancy:tenantgroup_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Tenant Groups{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='tenancy:tenantgroup_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index a53458694..0319a20b0 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -22,7 +22,6 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView): permission_required = 'tenancy.view_tenantgroup' queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable - template_name = 'tenancy/tenantgroup_list.html' class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -60,7 +59,6 @@ class TenantListView(PermissionRequiredMixin, ObjectListView): filterset = filters.TenantFilterSet filterset_form = forms.TenantFilterForm table = tables.TenantTable - template_name = 'tenancy/tenant_list.html' class TenantView(PermissionRequiredMixin, View): From 6884404957913501dd16062e9181be9d54edb84c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 14:24:22 -0500 Subject: [PATCH 077/106] Migrate virtualization views to use common object list template --- .../virtualization/cluster_list.html | 21 ------------------- .../virtualization/clustergroup_list.html | 18 ---------------- .../virtualization/clustertype_list.html | 18 ---------------- netbox/virtualization/views.py | 4 +--- 4 files changed, 1 insertion(+), 60 deletions(-) delete mode 100644 netbox/templates/virtualization/cluster_list.html delete mode 100644 netbox/templates/virtualization/clustergroup_list.html delete mode 100644 netbox/templates/virtualization/clustertype_list.html diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html deleted file mode 100644 index 6f5f058ad..000000000 --- a/netbox/templates/virtualization/cluster_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.virtualization.add_cluster %} - {% add_button 'virtualization:cluster_add' %} - {% import_button 'virtualization:cluster_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Clusters{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='virtualization:cluster_bulk_edit' bulk_delete_url='virtualization:cluster_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html deleted file mode 100644 index f922b5efa..000000000 --- a/netbox/templates/virtualization/clustergroup_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.virtualization.add_clustergroup %} - {% add_button 'virtualization:clustergroup_add' %} - {% import_button 'virtualization:clustergroup_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Cluster Groups{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustergroup_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html deleted file mode 100644 index 48ff077c7..000000000 --- a/netbox/templates/virtualization/clustertype_list.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.virtualization.add_clustertype %} - {% add_button 'virtualization:clustertype_add' %} - {% import_button 'virtualization:clustertype_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Cluster Types{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustertype_bulk_delete' %} -
    -
    -{% endblock %} diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index b961d65e5..e6292ba7d 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -26,7 +26,6 @@ class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): permission_required = 'virtualization.view_clustertype' queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable - template_name = 'virtualization/clustertype_list.html' class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView): @@ -62,7 +61,6 @@ class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): permission_required = 'virtualization.view_clustergroup' queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable - template_name = 'virtualization/clustergroup_list.html' class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView): @@ -100,7 +98,6 @@ class ClusterListView(PermissionRequiredMixin, ObjectListView): table = tables.ClusterTable filterset = filters.ClusterFilterSet filterset_form = forms.ClusterFilterForm - template_name = 'virtualization/cluster_list.html' class ClusterView(PermissionRequiredMixin, View): @@ -257,6 +254,7 @@ class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineDetailTable + # TODO: Remove custom template template_name = 'virtualization/virtualmachine_list.html' From 92fab048d116c02484f249c4e9a4f51425cdbee0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 15:13:43 -0500 Subject: [PATCH 078/106] Add tests for naturalization functions --- netbox/utilities/ordering.py | 2 +- netbox/utilities/tests/test_ordering.py | 43 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 netbox/utilities/tests/test_ordering.py diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index d459e6f6c..a560e776e 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -68,7 +68,7 @@ def naturalize_interface(value, max_length=None): if match.group('type') is not None: output.append(match.group('type')) - # Finally, append any remaining fields, left-padding to eight digits each. + # Finally, append any remaining fields, left-padding to six digits each. for part_name in ('id', 'channel', 'vc'): part = match.group(part_name) if part is not None: diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py new file mode 100644 index 000000000..a875c688c --- /dev/null +++ b/netbox/utilities/tests/test_ordering.py @@ -0,0 +1,43 @@ +from django.test import TestCase + +from utilities.ordering import naturalize, naturalize_interface + + +class NaturalizationTestCase(TestCase): + """ + Validate the operation of the functions which generate values suitable for natural ordering. + """ + def test_naturalize(self): + + data = ( + # Original, naturalized + ('abc', 'abc'), + ('123', '00000123'), + ('abc123', 'abc00000123'), + ('123abc', '00000123abc'), + ('123abc456', '00000123abc00000456'), + ('abc123def', 'abc00000123def'), + ('abc123def456', 'abc00000123def00000456'), + ) + + for origin, naturalized in data: + self.assertEqual(naturalize(origin), naturalized) + + def test_naturalize_interface(self): + + data = ( + # Original, naturalized + ('Gi', '9999999999999999Gi000000000000000000'), + ('Gi1', '9999999999999999Gi000001000000000000'), + ('Gi1/2', '0001999999999999Gi000002000000000000'), + ('Gi1/2/3', '0001000299999999Gi000003000000000000'), + ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'), + ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'), + ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'), + ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'), + ('Gi1:2', '9999999999999999Gi000001000002000000'), + ('Gi1:2.3', '9999999999999999Gi000001000002000003'), + ) + + for origin, naturalized in data: + self.assertEqual(naturalize_interface(origin), naturalized) From 807c2f048d435a60dfe86fb5ef4ed3aa1ddfdaa5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 15:16:07 -0500 Subject: [PATCH 079/106] Changelog for #3984 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 63745bbab..ea200c462 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -4,6 +4,7 @@ * [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable * [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components +* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel * [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevation * [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add multiple status choices for VMs * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views From 08ce024473bd4b20ab75e9a3ca416a5150f2f6bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 15:32:09 -0500 Subject: [PATCH 080/106] Release v2.7.5 --- docs/release-notes/version-2.7.md | 14 ++++++++------ netbox/netbox/settings.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ea200c462..04b3972ca 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,12 +1,14 @@ -# v2.7.5 (FUTURE) +# v2.7.5 (2020-02-13) + +**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox. ## Enhancements * [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable * [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components * [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel -* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevation -* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add multiple status choices for VMs +* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations +* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views @@ -14,8 +16,8 @@ ## Bug Fixes -* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices -* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices +* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens * [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view @@ -24,7 +26,7 @@ * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms * [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type * [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit -* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Include device bay counts in rack elevation diagrams +* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams * [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption * [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts * [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 1a36cf424..5f60d16f7 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.5-dev' +VERSION = '2.7.5' # Hostname HOSTNAME = platform.node() From 4d50cad6ed6e0a7d048b30ef893c396678ef33de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 15:39:08 -0500 Subject: [PATCH 081/106] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5f60d16f7..f66828f69 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.5' +VERSION = '2.7.6-dev' # Hostname HOSTNAME = platform.node() From ff952fb2210f92f8ff643c60ccca2266d830903b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 16:39:38 -0500 Subject: [PATCH 082/106] Migrate extras views to use common object list template --- netbox/dcim/views.py | 2 +- netbox/extras/views.py | 5 +++-- netbox/ipam/views.py | 2 +- .../templates/extras/configcontext_list.html | 19 ------------------- netbox/templates/extras/tag_list.html | 14 -------------- 5 files changed, 5 insertions(+), 37 deletions(-) delete mode 100644 netbox/templates/extras/configcontext_list.html delete mode 100644 netbox/templates/extras/tag_list.html diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 89f051b2a..4a7e9eca5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2281,7 +2281,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet filterset_form = forms.VirtualChassisFilterForm - action_buttons = ('export') + action_buttons = ('export',) class VirtualChassisCreateView(PermissionRequiredMixin, View): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 73d29393f..0998646cf 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -34,7 +34,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView): filterset = filters.TagFilterSet filterset_form = forms.TagFilterForm table = TagTable - template_name = 'extras/tag_list.html' + action_buttons = () class TagView(PermissionRequiredMixin, View): @@ -111,7 +111,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConfigContextFilterSet filterset_form = forms.ConfigContextFilterForm table = ConfigContextTable - template_name = 'extras/configcontext_list.html' + action_buttons = ('add',) class ConfigContextView(PermissionRequiredMixin, View): @@ -190,6 +190,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm table = ObjectChangeTable + # TODO: Remove custom template template_name = 'extras/objectchange_list.html' diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8484d7a32..36d757b6a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -986,7 +986,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable - action_buttons = ('export') + action_buttons = ('export',) class ServiceView(PermissionRequiredMixin, View): diff --git a/netbox/templates/extras/configcontext_list.html b/netbox/templates/extras/configcontext_list.html deleted file mode 100644 index f21be2836..000000000 --- a/netbox/templates/extras/configcontext_list.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -
    - {% if perms.extras.add_configcontext %} - {% add_button 'extras:configcontext_add' %} - {% endif %} -
    -

    {% block title %}Config Contexts{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} diff --git a/netbox/templates/extras/tag_list.html b/netbox/templates/extras/tag_list.html deleted file mode 100644 index c87b6c2e5..000000000 --- a/netbox/templates/extras/tag_list.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends '_base.html' %} -{% load buttons %} - -{% block content %} -

    {% block title %}Tags{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='extras:tag_bulk_edit' bulk_delete_url='extras:tag_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    -{% endblock %} From 8df9bb6fb47863a4f6376d0e5a29cd4b9ac84023 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 17:11:39 -0500 Subject: [PATCH 083/106] Convert change log view to extend standard template --- netbox/extras/views.py | 2 +- .../templates/extras/objectchange_list.html | 23 +++++-------------- netbox/templates/inc/nav_menu.html | 2 +- netbox/templates/utilities/obj_list.html | 19 +++++++-------- 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0998646cf..3912c602f 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -190,8 +190,8 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ObjectChangeFilterSet filterset_form = forms.ObjectChangeFilterForm table = ObjectChangeTable - # TODO: Remove custom template template_name = 'extras/objectchange_list.html' + action_buttons = ('export',) class ObjectChangeView(PermissionRequiredMixin, View): diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/extras/objectchange_list.html index e9be6ba69..3672f4f04 100644 --- a/netbox/templates/extras/objectchange_list.html +++ b/netbox/templates/extras/objectchange_list.html @@ -1,20 +1,9 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
    - {% export_button content_type %} -
    -

    {% block title %}Changelog{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' %} -
    - Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %} -
    +{% block title %}Change Log{% endblock %} + +{% block sidebar %} +
    + Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
    -
    - {% include 'inc/search_panel.html' %} -
    -
    {% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 55ac3e5c2..900d783f6 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -473,7 +473,7 @@

    {% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}

    - {% if filter_form %} -
    - {% include 'utilities/obj_table.html' with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} -
    -
    +
    + {% include 'utilities/obj_table.html' with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} +
    +
    + {% if filter_form %} {% include 'inc/search_panel.html' %} -
    - {% else %} -
    - {% include 'utilities/obj_table.html' with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} -
    - {% endif %} + {% endif %} + {% block sidebar %}{% endblock %} +
    {% endblock %} From 8212c8f6fc596e5133e960cd8317cde85f8695d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 17:22:17 -0500 Subject: [PATCH 084/106] Convert IPAM list views to extend standard template --- netbox/templates/ipam/aggregate_list.html | 37 ++++++----------------- netbox/templates/ipam/prefix_list.html | 21 ++----------- netbox/templates/ipam/rir_list.html | 33 ++++++-------------- netbox/templates/utilities/obj_list.html | 1 + 4 files changed, 23 insertions(+), 69 deletions(-) diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index 27363a56d..85a2bd36d 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -1,31 +1,14 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} {% load humanize %} -{% block content %} -
    - {% if perms.ipam.add_aggregate %} - {% add_button 'ipam:aggregate_add' %} - {% import_button 'ipam:aggregate_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Aggregates{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    - Statistics -
    -
      -
    • Total IPv4 IPs {{ ipv4_total|intcomma }}
    • -
    • Total IPv6 /64s {{ ipv6_total|intcomma }}
    • -
    +{% block sidebar %} +
    +
    + Statistics
    -
    -
    +
      +
    • Total IPv4 IPs {{ ipv4_total|intcomma }}
    • +
    • Total IPv6 /64s {{ ipv6_total|intcomma }}
    • +
    +
    {% endblock %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index f0754d37b..00f0b7fe9 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -1,26 +1,9 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} {% load helpers %} -{% block content %} -
    +{% block buttons %} - {% if perms.ipam.add_prefix %} - {% add_button 'ipam:prefix_add' %} - {% import_button 'ipam:prefix_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Prefixes{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    {% endblock %} diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index 846eb3cb2..02f01fc7c 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,9 +1,6 @@ -{% extends '_base.html' %} -{% load buttons %} -{% load humanize %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
    +{% block buttons %} {% if request.GET.family == '6' %} @@ -15,22 +12,12 @@ IPv6 Stats {% endif %} - {% if perms.ipam.add_rir %} - {% add_button 'ipam:rir_add' %} - {% import_button 'ipam:rir_import' %} - {% endif %} - {% export_button content_type %} -
    -

    {% block title %}RIRs{% endblock %}

    -
    -
    - {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %} - {% if request.GET.family == '6' %} -
    Note: Numbers shown indicate /64 prefixes.
    - {% endif %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    +{% endblock %} + +{% block sidebar %} + {% if request.GET.family == '6' %} +
    + Numbers shown indicate /64 prefixes. +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 05dd6a6ac..5ffed3c2b 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -4,6 +4,7 @@ {% block content %}
    + {% block buttons %}{% endblock %} {% if permissions.add and 'add' in action_buttons %} {% add_button content_type.model_class|url_name:"add" %} {% endif %} From 909323663eca94493a2eb64e8e08bcae58290be8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 21:41:00 -0500 Subject: [PATCH 085/106] Fixes #4166: Fix schema migrations to enforce maximum character length for naturalized fields --- docs/release-notes/version-2.7.md | 8 ++++++++ .../dcim/migrations/0093_device_component_ordering.py | 2 +- .../0094_device_component_template_ordering.py | 2 +- netbox/dcim/migrations/0095_primary_model_ordering.py | 2 +- netbox/dcim/migrations/0096_interface_ordering.py | 2 +- netbox/extras/management/commands/renaturalize.py | 2 +- netbox/utilities/ordering.py | 8 ++++---- netbox/utilities/tests/test_ordering.py | 10 ++++++++-- 8 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 04b3972ca..75269b6ad 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.6 (FUTURE) + +## Bug Fixes + +* [#4166](https://github.com/netbox-community/netbox/issues/4166) - Fix schema migrations to enforce maximum character length for naturalized fields + +--- + # v2.7.5 (2020-02-13) **Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox. diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py index 017241c8b..4e3c941a1 100644 --- a/netbox/dcim/migrations/0093_device_component_ordering.py +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -6,7 +6,7 @@ import utilities.ordering def _update_model_names(model): # Update each unique field value in bulk for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): - model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) def naturalize_consoleports(apps, schema_editor): diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py index fc39f76b2..24fe98e94 100644 --- a/netbox/dcim/migrations/0094_device_component_template_ordering.py +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -6,7 +6,7 @@ import utilities.ordering def _update_model_names(model): # Update each unique field value in bulk for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): - model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) def naturalize_consoleporttemplates(apps, schema_editor): diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 9cef0a581..3bc780161 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -6,7 +6,7 @@ import utilities.ordering def _update_model_names(model): # Update each unique field value in bulk for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): - model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100)) def naturalize_sites(apps, schema_editor): diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py index 284066462..f1622f504 100644 --- a/netbox/dcim/migrations/0096_interface_ordering.py +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -6,7 +6,7 @@ import utilities.ordering def _update_model_names(model): # Update each unique field value in bulk for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): - model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name)) + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name, max_length=100)) def naturalize_interfacetemplates(apps, schema_editor): diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py index 70f57c1ba..cfd037910 100644 --- a/netbox/extras/management/commands/renaturalize.py +++ b/netbox/extras/management/commands/renaturalize.py @@ -86,7 +86,7 @@ class Command(BaseCommand): # Find all unique values for the field queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct() for value in queryset: - naturalized_value = naturalize(value) + naturalized_value = naturalize(value, max_length=field.max_length) if options['verbosity'] >= 2: self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='') diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index a560e776e..39e21733a 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -10,7 +10,7 @@ INTERFACE_NAME_REGEX = r'(^(?P[^\d\.:]+)?)' \ r'(.(?P\d+)$)?' -def naturalize(value, max_length=None, integer_places=8): +def naturalize(value, max_length, integer_places=8): """ Take an alphanumeric string and prepend all integers to `integer_places` places to ensure the strings are ordered naturally. For example: @@ -39,10 +39,10 @@ def naturalize(value, max_length=None, integer_places=8): output.append(segment) ret = ''.join(output) - return ret[:max_length] if max_length else ret + return ret[:max_length] -def naturalize_interface(value, max_length=None): +def naturalize_interface(value, max_length): """ Similar in nature to naturalize(), but takes into account a particular naming format adapted from the old InterfaceManager. @@ -77,4 +77,4 @@ def naturalize_interface(value, max_length=None): output.append('000000') ret = ''.join(output) - return ret[:max_length] if max_length else ret + return ret[:max_length] diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py index a875c688c..958224c94 100644 --- a/netbox/utilities/tests/test_ordering.py +++ b/netbox/utilities/tests/test_ordering.py @@ -21,7 +21,10 @@ class NaturalizationTestCase(TestCase): ) for origin, naturalized in data: - self.assertEqual(naturalize(origin), naturalized) + self.assertEqual(naturalize(origin, max_length=50), naturalized) + + def test_naturalize_max_length(self): + self.assertEqual(naturalize('abc123def456', max_length=10), 'abc0000012') def test_naturalize_interface(self): @@ -40,4 +43,7 @@ class NaturalizationTestCase(TestCase): ) for origin, naturalized in data: - self.assertEqual(naturalize_interface(origin), naturalized) + self.assertEqual(naturalize_interface(origin, max_length=50), naturalized) + + def test_naturalize_interface_max_length(self): + self.assertEqual(naturalize_interface('Gi1/2/3', max_length=20), '0001000299999999Gi00') From 0863145c7fdc2d619a28019fa73d848be4d44681 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 21:46:03 -0500 Subject: [PATCH 086/106] Release v2.7.6 --- docs/release-notes/version-2.7.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 75269b6ad..70981658f 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,4 +1,4 @@ -# v2.7.6 (FUTURE) +# v2.7.6 (2020-02-13) ## Bug Fixes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f66828f69..81422b714 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.6-dev' +VERSION = '2.7.6' # Hostname HOSTNAME = platform.node() From 598d23fc03b7e50530bc8cfae499050bba18039c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Feb 2020 21:51:03 -0500 Subject: [PATCH 087/106] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 81422b714..249ee9e53 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.6' +VERSION = '2.7.7-dev' # Hostname HOSTNAME = platform.node() From 1d72436bfc6d38809a52f7677cdf28de1d936cb9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 09:13:05 -0500 Subject: [PATCH 088/106] Fixes #4168: Role is not required when creating a virtual machine --- docs/release-notes/version-2.7.md | 8 ++++++++ netbox/virtualization/forms.py | 1 + 2 files changed, 9 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 70981658f..c235b8115 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.7 (FUTURE) + +## Bug Fixes + +* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine + +--- + # v2.7.6 (2020-02-13) ## Bug Fixes diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 12393d400..f35a94c99 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -351,6 +351,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), + required=False, widget=APISelect( api_url="/api/dcim/device-roles/", additional_query_params={ From a4705fa73a464a88ac8931a44f55e2378f434c97 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 09:35:43 -0500 Subject: [PATCH 089/106] Changelog for #2519 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index c235b8115..ac2f149e1 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -2,6 +2,7 @@ ## Bug Fixes +* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API * [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine --- From 03a7f6bbda63e1fa638804376686f87b39f15d4a Mon Sep 17 00:00:00 2001 From: Dan Starner Date: Fri, 14 Feb 2020 09:39:01 -0500 Subject: [PATCH 090/106] ammend redis conn check to acccount for sentinel --- netbox/extras/apps.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index f8c5a98e6..257e7801a 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -13,13 +13,23 @@ class ExtrasConfig(AppConfig): # Check that we can connect to the configured Redis database. try: - rs = redis.Redis( - host=settings.WEBHOOKS_REDIS_HOST, - port=settings.WEBHOOKS_REDIS_PORT, - db=settings.WEBHOOKS_REDIS_DATABASE, - password=settings.WEBHOOKS_REDIS_PASSWORD or None, - ssl=settings.WEBHOOKS_REDIS_SSL, - ) + if settings.WEBHOOKS_REDIS_USING_SENTINEL: + sentinel = redis.sentinel.Sentinel( + settings.WEBHOOKS_REDIS_SENTINELS, + socket_timeout=settings.WEBHOOKS_REDIS_DEFAULT_TIMEOUT + ) + rs = sentinel.master_for( + settings.WEBHOOKS_REDIS_SENTINEL_SERVICE, + socket_timeout=settings.WEBHOOKS_REDIS_DEFAULT_TIMEOUT + ) + else: + rs = redis.Redis( + host=settings.WEBHOOKS_REDIS_HOST, + port=settings.WEBHOOKS_REDIS_PORT, + db=settings.WEBHOOKS_REDIS_DATABASE, + password=settings.WEBHOOKS_REDIS_PASSWORD or None, + ssl=settings.WEBHOOKS_REDIS_SSL, + ) rs.ping() except redis.exceptions.ConnectionError: raise ImproperlyConfigured( From e431ef09e5b49f15fab28ec5332b88051a3ca035 Mon Sep 17 00:00:00 2001 From: Dan Starner Date: Fri, 14 Feb 2020 10:29:09 -0500 Subject: [PATCH 091/106] fix extraneous formatting of notice boxes in required settings doc --- docs/configuration/required-settings.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index 81790eae0..e86b2810a 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -80,11 +80,11 @@ REDIS = { } ``` -!!! note: +!!! note If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary -!!! warning: +!!! note It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the same Redis instance for both may result in webhook processing data being lost during cache flushing events. @@ -124,7 +124,7 @@ REDIS = { } ``` -!!! note: +!!! note It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via `SENTINELS`/`SENTINEL_SERVICE`. From ec0f45e20dffde593f157b789a922aca1c9fe64a Mon Sep 17 00:00:00 2001 From: Dan Starner Date: Fri, 14 Feb 2020 11:16:59 -0500 Subject: [PATCH 092/106] remove redis conn check from extras AppConfig --- netbox/extras/apps.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 257e7801a..5dad07b48 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -8,31 +8,4 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): - import extras.signals - - # Check that we can connect to the configured Redis database. - try: - if settings.WEBHOOKS_REDIS_USING_SENTINEL: - sentinel = redis.sentinel.Sentinel( - settings.WEBHOOKS_REDIS_SENTINELS, - socket_timeout=settings.WEBHOOKS_REDIS_DEFAULT_TIMEOUT - ) - rs = sentinel.master_for( - settings.WEBHOOKS_REDIS_SENTINEL_SERVICE, - socket_timeout=settings.WEBHOOKS_REDIS_DEFAULT_TIMEOUT - ) - else: - rs = redis.Redis( - host=settings.WEBHOOKS_REDIS_HOST, - port=settings.WEBHOOKS_REDIS_PORT, - db=settings.WEBHOOKS_REDIS_DATABASE, - password=settings.WEBHOOKS_REDIS_PASSWORD or None, - ssl=settings.WEBHOOKS_REDIS_SSL, - ) - rs.ping() - except redis.exceptions.ConnectionError: - raise ImproperlyConfigured( - "Unable to connect to the Redis database. Check that the Redis configuration has been defined in " - "configuration.py." - ) From 7aba8e3ec48a03b867ae6a500a77588c6edc6a16 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 14 Feb 2020 16:43:42 +0000 Subject: [PATCH 093/106] Added back clean --- netbox/virtualization/forms.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 6771ee76b..2aa57608a 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -704,6 +704,22 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk) + def clean(self): + super().clean() + + # Validate VLAN assignments + tagged_vlans = self.cleaned_data['tagged_vlans'] + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + class InterfaceCreateForm(BootstrapMixin, forms.Form): virtual_machine = forms.ModelChoiceField( From e4df02887b2f65b4a36b00396799e3afa342b224 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 12:04:35 -0500 Subject: [PATCH 094/106] Changelog for #3840 --- docs/release-notes/version-2.7.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ac2f149e1..5e58e0c58 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,9 @@ # v2.7.7 (FUTURE) +## Enhancements + +* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment + ## Bug Fixes * [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API From 7ce1289bb2ad5e1f2d723a6e7eeb5ac315d85df7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 12:04:56 -0500 Subject: [PATCH 095/106] Clean up unused imports --- netbox/extras/apps.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 5dad07b48..3201c3bb2 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -1,7 +1,4 @@ from django.apps import AppConfig -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -import redis class ExtrasConfig(AppConfig): From ce89fa74b9797be6d7d5ed235065ed85f8bf90d6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 13:09:01 -0500 Subject: [PATCH 096/106] Closes #4170: Improve color contrast in rack elevation drawings --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 5e58e0c58..d3b889513 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment +* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings ## Bug Fixes diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index f291fc825..29afef1f1 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -382,8 +382,8 @@ class RackElevationHelperMixin: # add gradients RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff') - RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#f0f0f0') - RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7') + RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc0c0') return drawing From 815a46bfbec47d45994bc33bb80e54ef24b74025 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 13:21:32 -0500 Subject: [PATCH 097/106] Convert device and VM list views to use obj_list.html --- netbox/templates/dcim/device_list.html | 39 +++++++------- netbox/templates/dcim/inc/device_table.html | 24 --------- netbox/templates/utilities/obj_list.html | 51 ++++++++++++++++++- .../inc/virtualmachine_table.html | 14 ----- .../virtualization/virtualmachine_list.html | 29 ++++------- 5 files changed, 82 insertions(+), 75 deletions(-) delete mode 100644 netbox/templates/dcim/inc/device_table.html delete mode 100644 netbox/templates/virtualization/inc/virtualmachine_table.html diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 8b991689f..b12e4b5a8 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,21 +1,24 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
    - {% if perms.dcim.add_device %} - {% add_button 'dcim:device_add' %} - {% import_button 'dcim:device_import' %} +{% block bulk_buttons %} + {% if perms.dcim.change_device %} +
    + + +
    + {% endif %} + {% if perms.dcim.add_virtualchassis %} + {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Devices{% endblock %}

    -
    -
    - {% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    {% endblock %} diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html deleted file mode 100644 index 68570fdf3..000000000 --- a/netbox/templates/dcim/inc/device_table.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends 'utilities/obj_table.html' %} - -{% block extra_actions %} - {% if perms.dcim.change_device %} -
    - - -
    - {% endif %} - {% if perms.dcim.add_virtualchassis %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 5ffed3c2b..020a37660 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -18,7 +18,56 @@

    {% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}

    - {% include 'utilities/obj_table.html' with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} + {% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} + {% if permissions.change or permissions.delete %} + + {% csrf_token %} + + {% if table.paginator.num_pages > 1 %} + + {% endif %} + {% include table_template|default:'responsive_table.html' %} +
    + {% block bulk_buttons %}{% endblock %} + {% if bulk_edit_url and permissions.change %} + + {% endif %} + {% if bulk_delete_url and permissions.delete %} + + {% endif %} +
    + + {% else %} + {% include table_template|default:'responsive_table.html' %} + {% endif %} + {% endwith %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
    {% if filter_form %} diff --git a/netbox/templates/virtualization/inc/virtualmachine_table.html b/netbox/templates/virtualization/inc/virtualmachine_table.html deleted file mode 100644 index ce249593e..000000000 --- a/netbox/templates/virtualization/inc/virtualmachine_table.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'utilities/obj_table.html' %} - -{% block extra_actions %} - {% if perms.virtualization.change_virtualmachine %} -
    - - -
    - {% endif %} -{% endblock %} \ No newline at end of file diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 821f956a2..74839b250 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -1,21 +1,14 @@ -{% extends '_base.html' %} -{% load buttons %} +{% extends 'utilities/obj_list.html' %} -{% block content %} -
    - {% if perms.virtualization.add_virtualmachine %} - {% add_button 'virtualization:virtualmachine_add' %} - {% import_button 'virtualization:virtualmachine_import' %} +{% block bulk_buttons %} + {% if perms.virtualization.change_virtualmachine %} +
    + + +
    {% endif %} - {% export_button content_type %} -
    -

    {% block title %}Virtual Machines{% endblock %}

    -
    -
    - {% include 'virtualization/inc/virtualmachine_table.html' with bulk_edit_url='virtualization:virtualmachine_bulk_edit' bulk_delete_url='virtualization:virtualmachine_bulk_delete' %} -
    -
    - {% include 'inc/search_panel.html' %} -
    -
    {% endblock %} From 440f754fec01d10d8dfbb3c707a85245ea7053ea Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 13:30:53 -0500 Subject: [PATCH 098/106] Clean up TODO notes --- netbox/dcim/views.py | 1 - netbox/ipam/views.py | 3 --- netbox/virtualization/views.py | 1 - 3 files changed, 5 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4a7e9eca5..0bb6658a2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1062,7 +1062,6 @@ class DeviceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceFilterSet filterset_form = forms.DeviceFilterForm table = tables.DeviceDetailTable - # TODO: Remove custom template template_name = 'dcim/device_list.html' diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 36d757b6a..053098f0b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -186,7 +186,6 @@ class RIRListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RIRFilterSet filterset_form = forms.RIRFilterForm table = tables.RIRDetailTable - # TODO: Remove custom template template_name = 'ipam/rir_list.html' def alter_queryset(self, request): @@ -296,7 +295,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView): filterset = filters.AggregateFilterSet filterset_form = forms.AggregateFilterForm table = tables.AggregateDetailTable - # TODO: Remove custom template template_name = 'ipam/aggregate_list.html' def extra_context(self): @@ -448,7 +446,6 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PrefixFilterSet filterset_form = forms.PrefixFilterForm table = tables.PrefixDetailTable - # TODO: Remove custom template template_name = 'ipam/prefix_list.html' def alter_queryset(self, request): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index e6292ba7d..291392eb4 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -254,7 +254,6 @@ class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): filterset = filters.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineDetailTable - # TODO: Remove custom template template_name = 'virtualization/virtualmachine_list.html' From 1a8eea5aa943f4f63b76ecadcf9ee7c4ab60e6e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Feb 2020 14:27:47 -0500 Subject: [PATCH 099/106] Fixes #4175: Fix potential exception when bulk editing objects from a filtered list --- docs/release-notes/version-2.7.md | 1 + netbox/templates/utilities/obj_list.html | 4 ++-- netbox/utilities/views.py | 6 +----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index d3b889513..e224196ad 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ * [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API * [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine +* [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list --- diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 020a37660..fe70edd3b 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -51,12 +51,12 @@
    {% block bulk_buttons %}{% endblock %} {% if bulk_edit_url and permissions.change %} - {% endif %} {% if bulk_delete_url and permissions.delete %} - {% endif %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index c93842d4b..d0257324e 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -634,7 +634,7 @@ class BulkEditView(GetReturnURLMixin, View): post_data['pk'] = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs] if '_apply' in request.POST: - form = self.form(model, request.POST, initial=request.GET) + form = self.form(model, request.POST) if form.is_valid(): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] @@ -718,10 +718,6 @@ class BulkEditView(GetReturnURLMixin, View): else: # Pass the PK list as initial data to avoid binding the form initial_data = querydict_to_dict(post_data) - - # Append any normal initial data (passed as GET parameters) - initial_data.update(request.GET) - form = self.form(model, initial=initial_data) # Retrieve objects being edited From 5b505b21c8bf6a71669b3dca409c36f0b3724441 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 10:50:14 -0500 Subject: [PATCH 100/106] Fixes #4183: Fix representation of NaturalOrderingField values in change log --- docs/release-notes/version-2.7.md | 1 + netbox/utilities/fields.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e224196ad..f59aa72e4 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -10,6 +10,7 @@ * [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API * [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine * [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list +* [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log --- diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 6181a7ca1..4eb19f539 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -56,8 +56,11 @@ class NaturalOrderingField(models.CharField): """ Generate a naturalized value from the target field """ - value = getattr(model_instance, self.target_field) - return self.naturalize_function(value, max_length=self.max_length) + original_value = getattr(model_instance, self.target_field) + naturalized_value = self.naturalize_function(original_value, max_length=self.max_length) + setattr(model_instance, self.attname, naturalized_value) + + return naturalized_value def deconstruct(self): kwargs = super().deconstruct()[3] # Pass kwargs from CharField From a456cbb26ce93507f810514ef7c60ae3971e8933 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 11:08:16 -0500 Subject: [PATCH 101/106] Fixes #4179: Site is required when creating a rack group or power panel --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index f59aa72e4..3d4306ac7 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -10,6 +10,7 @@ * [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API * [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine * [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list +* [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel * [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4c8a0821f..c4b5d4503 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, widget=APISelect( api_url="/api/dcim/sites/" ) @@ -4522,7 +4521,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): class PowerPanelForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), - required=False, widget=APISelect( api_url="/api/dcim/sites/", filter_for={ From 4ea8967c2d43e1c49985b2c32e28ad8d2cd34809 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 11:14:37 -0500 Subject: [PATCH 102/106] Fixes #4194: Role field should not be required when searching/filtering secrets --- docs/release-notes/version-2.7.md | 1 + netbox/secrets/forms.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 3d4306ac7..c6985f9f2 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -12,6 +12,7 @@ * [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list * [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel * [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log +* [#4194](https://github.com/netbox-community/netbox/issues/4194) - Role field should not be required when searching/filtering secrets --- diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 79064e0dd..88e5325ec 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', - required=True, + required=False, widget=APISelectMultiple( api_url="/api/secrets/secret-roles/", value_field="slug", From 2a1de0202ffaea722cad5e9d65b1572b466dd9f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 11:43:47 -0500 Subject: [PATCH 103/106] Add helpful links to "new issue" page --- .github/ISSUE_TEMPLATE/config.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..ab7d7cdc4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +# Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: false +contact_links: + - name: 📖 Contributing Policy + url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md + about: Please read through our contributing policy before opening an issue or pull request + - name: 💬 Discussion Group + url: https://groups.google.com/forum/#!forum/netbox-discuss + about: Join our discussion group for assistance with installation issues and other problems From 84d078a5390befbe7f628c92b860bf43fd907911 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 16:21:50 -0500 Subject: [PATCH 104/106] Fixes #4196: Fix exception when viewing LLDP neighbors page --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/views.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index c6985f9f2..458328369 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -13,6 +13,7 @@ * [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel * [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log * [#4194](https://github.com/netbox-community/netbox/issues/4194) - Role field should not be required when searching/filtering secrets +* [#4196](https://github.com/netbox-community/netbox/issues/4196) - Fix exception when viewing LLDP neighbors page --- diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0bb6658a2..91b32bc70 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -31,6 +31,7 @@ from utilities.views import ( from virtualization.models import VirtualMachine from . import filters, forms, tables from .choices import DeviceFaceChoices +from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -1181,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): def get(self, request, pk): device = get_object_or_404(Device, pk=pk) - interfaces = device.vc_interfaces.connectable().prefetch_related( + interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related( '_connected_interface__device' ) From ae1767b5d03002ae56316bfaa166055c0d645c28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 16:22:17 -0500 Subject: [PATCH 105/106] Remove obsolete InterfaceManager --- netbox/dcim/managers.py | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 netbox/dcim/managers.py diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py deleted file mode 100644 index 502719646..000000000 --- a/netbox/dcim/managers.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db.models import Manager, QuerySet - -from .constants import NONCONNECTABLE_IFACE_TYPES - - -class InterfaceQuerySet(QuerySet): - - def connectable(self): - """ - Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or - wireless). - """ - return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES) - - -class InterfaceManager(Manager): - - def get_queryset(self): - return InterfaceQuerySet(self.model, using=self._db) From 8cfb5ac5c627504cf4199d77633cea66cb17457e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Feb 2020 16:56:50 -0500 Subject: [PATCH 106/106] Fixes #3967: Fix missing migration for interface templates of type "other" --- docs/release-notes/version-2.7.md | 1 + .../0097_interfacetemplate_type_other.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 netbox/dcim/migrations/0097_interfacetemplate_type_other.py diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 458328369..02b3aa9bf 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -8,6 +8,7 @@ ## Bug Fixes * [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API +* [#3967](https://github.com/netbox-community/netbox/issues/3967) - Fix missing migration for interface templates of type "other" * [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine * [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list * [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel diff --git a/netbox/dcim/migrations/0097_interfacetemplate_type_other.py b/netbox/dcim/migrations/0097_interfacetemplate_type_other.py new file mode 100644 index 000000000..d71b5c655 --- /dev/null +++ b/netbox/dcim/migrations/0097_interfacetemplate_type_other.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def interfacetemplate_type_to_slug(apps, schema_editor): + InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate') + InterfaceTemplate.objects.filter(type=32767).update(type='other') + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0096_interface_ordering'), + ] + + operations = [ + # Missed type "other" in the initial migration (see #3967) + migrations.RunPython( + code=interfacetemplate_type_to_slug + ), + ]