From d29d265b0aadda0913c46904d829d1b7ddc8a82e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Sep 2022 15:44:41 -0400 Subject: [PATCH 01/42] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 3482c9061..e886309e1 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.3 (FUTURE) + +--- + ## v3.3.2 (2022-09-02) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8f32de207..f96b6085b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.2' +VERSION = '3.3.3-dev' # Hostname HOSTNAME = platform.node() From 536bd37d05a8eeba6f17b64a5d0ad2a207063be8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Sep 2022 16:37:52 -0700 Subject: [PATCH 02/42] #9231 make empty search work --- netbox/netbox/filtersets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3a0434592..2dec13d88 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -80,6 +80,10 @@ class BaseFilterSet(django_filters.FilterSet): }, }) + def __init__(self, *args, **kwargs): + self.base_filters = self.get_filters() + super().__init__(*args, **kwargs) + @staticmethod def _get_filter_lookup_dict(existing_filter): # Choose the lookup expression map based on the filter type From 2fe620df7019bf6eb6e5397b85f2e595e2b86768 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Sep 2022 18:04:29 -0700 Subject: [PATCH 03/42] #9231 call class method --- netbox/netbox/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 2dec13d88..2e535266d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -81,7 +81,7 @@ class BaseFilterSet(django_filters.FilterSet): }) def __init__(self, *args, **kwargs): - self.base_filters = self.get_filters() + self.base_filters = self.__class__.get_filters() super().__init__(*args, **kwargs) @staticmethod From 48a907ae4580f77eea74180c826952c8c3d3d5e7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 08:09:28 -0700 Subject: [PATCH 04/42] #9231 add comment --- netbox/netbox/filtersets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 2e535266d..b6776e3c1 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -81,6 +81,9 @@ class BaseFilterSet(django_filters.FilterSet): }) def __init__(self, *args, **kwargs): + # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready + # however FilterSet Factory is setup before this which creates the + # initial filters. This recreates the filters so Empty is picked up correctly. self.base_filters = self.__class__.get_filters() super().__init__(*args, **kwargs) From ac8f0a7ef2e094c06ac67ddd1aa33e46689148b7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 11:23:13 -0400 Subject: [PATCH 05/42] Add installation video to docs --- docs/installation/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/installation/index.md b/docs/installation/index.md index 905add7ab..8b588fccd 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -2,6 +2,8 @@ The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. + + The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) From 7826cfb01faef3574cd071a982c3d1bebb9381fa Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 08:45:56 -0700 Subject: [PATCH 06/42] #10270 - fix custom field validation for ipam services --- netbox/ipam/forms/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 34bf739f4..724812585 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -854,6 +854,7 @@ class ServiceCreateForm(ServiceForm): del self.fields[field].widget.attrs['required'] def clean(self): + super().clean() if self.cleaned_data['service_template']: # Create a new Service from the specified template service_template = self.cleaned_data['service_template'] From def853e8c444f646b550be8f6e7e8a22d18a27b2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 10:40:24 -0700 Subject: [PATCH 07/42] #10278 add get_extra_addanother_params --- netbox/extras/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 5b589c181..30f48f817 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -441,6 +441,12 @@ class ImageAttachmentEditView(generic.ObjectEditView): def get_return_url(self, request, obj=None): return obj.parent.get_absolute_url() if obj else super().get_return_url(request) + def get_extra_addanother_params(self, request): + return { + 'content_type': request.GET.get('content_type'), + 'object_id': request.GET.get('object_id'), + } + class ImageAttachmentDeleteView(generic.ObjectDeleteView): queryset = ImageAttachment.objects.all() From 1cbb2320c1fba4998939b63cea210b129ed3e6be Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 13:59:00 -0400 Subject: [PATCH 08/42] Changelog for #9231, #10270, #10278 --- docs/release-notes/version-3.3.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e886309e1..fe13eaf9f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,12 @@ ## v3.3.3 (FUTURE) +### Bug Fixes + +* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services +* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments + --- ## v3.3.2 (2022-09-02) From b7028228578066b81e5f26d83cbba6c303401efe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 14:09:17 -0400 Subject: [PATCH 09/42] Closes #10268: Omit trailing ".0" in device positions within UI --- docs/release-notes/version-3.3.md | 4 ++++ netbox/dcim/tables/devices.py | 11 +++++++---- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/virtualchassis_edit.html | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fe13eaf9f..049e8acc5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,10 @@ ## v3.3.3 (FUTURE) +### Enhancements + +* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI + ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 036f83306..c42731b90 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -152,6 +152,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): rack = tables.Column( linkify=True ) + position = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) device_role = columns.ColoredLabelColumn( verbose_name='Role' ) @@ -199,10 +202,10 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Device fields = ( - 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', + 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 8a70db621..6cc859749 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -70,7 +70,7 @@ {% endif %} {% endwith %} {% elif object.rack and object.position %} - U{{ object.position }} / {{ object.get_face_display }} + U{{ object.position|floatformat }} / {{ object.get_face_display }} {% elif object.rack and object.device_type.u_height %} Not racked {% else %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 275391c61..87917f2a2 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -55,7 +55,7 @@ {{ device.pk }} {% if device.rack %} - {{ device.rack }} / {{ device.position }} + {{ device.rack }} / {{ device.position|floatformat }} {% else %} {{ ''|placeholder }} {% endif %} From 51d066a1bc05ef10aada58635c4142b0e42149f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 14:30:51 -0400 Subject: [PATCH 10/42] Fixes #10259: Fix NoReverseMatch exception when listing available prefixes with "flat" column displayed --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/tables/ip.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 049e8acc5..beceae802 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index f3c45ec1d..a820385ed 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -21,6 +21,14 @@ __all__ = ( AVAILABLE_LABEL = mark_safe('Available') PREFIX_LINK = """ +{% if record.pk %} + {{ record.prefix }} +{% else %} + {{ record.prefix }} +{% endif %} +""" + +PREFIX_LINK_WITH_DEPTH = """ {% load helpers %} {% if record.depth %}
@@ -29,8 +37,7 @@ PREFIX_LINK = """ {% endfor %}
{% endif %} -{{ record.prefix }} -""" +""" + PREFIX_LINK IPADDRESS_LINK = """ {% if record.pk %} @@ -216,14 +223,15 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(TenancyColumnsMixin, NetBoxTable): prefix = columns.TemplateColumn( - template_code=PREFIX_LINK, + template_code=PREFIX_LINK_WITH_DEPTH, export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) - prefix_flat = tables.Column( + prefix_flat = columns.TemplateColumn( accessor=Accessor('prefix'), - linkify=True, - verbose_name='Prefix (Flat)', + template_code=PREFIX_LINK, + export_raw=True, + verbose_name='Prefix (Flat)' ) depth = tables.Column( accessor=Accessor('_depth'), From 211c7641c182b53990be7a4186d04a342e53de57 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 14:41:48 -0400 Subject: [PATCH 11/42] Fixes #10250: Fix exception when CableTermination validation fails during bulk import of cables --- netbox/dcim/models/cables.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index ab1fe88e4..e05eb6d51 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -281,15 +281,11 @@ class CableTermination(models.Model): # Validate interface type (if applicable) if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces' - }) + raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces") # A CircuitTermination attached to a ProviderNetwork cannot have a Cable if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: - raise ValidationError({ - 'termination': "Circuit terminations attached to a provider network may not be cabled." - }) + raise ValidationError("Circuit terminations attached to a provider network may not be cabled.") def save(self, *args, **kwargs): From f489ffa043c8859cce39df69a8397b11825a720b Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 7 Sep 2022 22:33:24 +0200 Subject: [PATCH 12/42] Allow running scripts nested in modules/packages --- netbox/extras/api/views.py | 2 +- netbox/extras/scripts.py | 8 +++++++- netbox/extras/urls.py | 4 ++-- netbox/templates/extras/script_list.html | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 82c68c86d..c7c6cc2aa 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -257,7 +257,7 @@ class ScriptViewSet(ViewSet): lookup_value_regex = '[^/]+' # Allow dots def _get_script(self, pk): - module_name, script_name = pk.split('.') + module_name, script_name = pk.split('.', maxsplit=1) script = get_script(module_name, script_name) if script is None: raise Http404 diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 6e4478304..23a778789 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -299,6 +299,10 @@ class BaseScript: def module(cls): return cls.__module__ + @classmethod + def root_module(cls): + return cls.__module__.split(".")[0] + @classproperty def job_timeout(self): return getattr(self.Meta, 'job_timeout', None) @@ -514,7 +518,9 @@ def get_scripts(use_names=False): ordered_scripts = [cls for cls in script_order if is_script(cls)] unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order] for cls in [*ordered_scripts, *unordered_scripts]: - module_scripts[cls.__name__] = cls + # For scripts in submodules use the full import path w/o the root module as the name + script_name = cls.full_name.split(".", maxsplit=1)[1] + module_scripts[script_name] = cls if module_scripts: scripts[module_name] = module_scripts diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 4c23adb0f..6c6156f4a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from extras import models, views from netbox.views.generic import ObjectChangeLogView @@ -105,7 +105,7 @@ urlpatterns = [ # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), - path('scripts/./', views.ScriptView.as_view(), name='script'), path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), + re_path(r'^scripts/(?P.([^.]+)).(?P.(.+))/', views.ScriptView.as_view(), name='script'), ] diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 8884ff77c..1f34f4d5e 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -34,7 +34,7 @@ {% for class_name, script in module_scripts.items %} - {{ script.name }} + {{ script.name }} {% include 'extras/inc/job_label.html' with result=script.result %} From b4877e7fac49282a766ebcdd2f886f71e8d61fa5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 15:45:01 -0700 Subject: [PATCH 13/42] #8580 add interface filters for connected --- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/filtersets.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5d92af878..1a9887c31 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1304,6 +1304,9 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) + is_occupied = django_filters.BooleanFilter( + method='filter_is_occupied' + ) class Meta: model = Interface @@ -1359,6 +1362,12 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) + def filter_is_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) + class FrontPortFilterSet( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 173ea5d1e..85fe909c5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1009,6 +1009,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'is_occupied')) ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1087,6 +1088,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='VRF' ) tag = TagFilterField(model) + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + is_occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) class FrontPortFilterForm(DeviceComponentFilterForm): From 3dbc7bdd2c78583902733e6a9a7d59d7ce07d649 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 8 Sep 2022 11:38:39 +0200 Subject: [PATCH 14/42] Add wwn CharField to InterfaceForm --- netbox/dcim/forms/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index d1d5b1683..a21265db4 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1331,6 +1331,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): label='VRF' ) + wwn = forms.CharField( + empty_value=None, + required=False, + label='WWN' + ) + fieldsets = ( ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), From 6b70436e2baeac91243acb81162a935e6e7319de Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Sep 2022 09:18:45 -0400 Subject: [PATCH 15/42] Add Repography stats --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93e125079..654b290ee 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ NetBox logo -![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) - NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, @@ -11,6 +9,16 @@ NetBox provides the ideal "source of truth" to power network automation. Available as open source software under the Apache 2.0 license, NetBox is employed by thousands of organizations around the world. +![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) + +[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits) +[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues) +[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls) +[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors) +
Stats via [Repography](https://repography.com) + +## About NetBox + ![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI") Myriad infrastructure components can be modeled in NetBox, including: @@ -57,7 +65,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
-

Thank you to our sponsors!

+

Thank you to our sponsors!

[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)            From c6644ec1ae688d980f4d6fdb954cadf09fd07369 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Sep 2022 10:00:53 -0400 Subject: [PATCH 16/42] Annotate upgrade paths in upgrade documentation --- docs/installation/3-netbox.md | 2 +- docs/installation/upgrading.md | 21 +++++++++++++++------ docs/media/installation/upgrade_paths.png | Bin 0 -> 9038 bytes 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 docs/media/installation/upgrade_paths.png diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index eeb5e6f20..f42e28deb 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. !!! warning "Python 3.8 or later required" - NetBox v3.2 requires Python 3.8, 3.9, or 3.10. + NetBox requires Python 3.8, 3.9, or 3.10. === "Ubuntu" diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index deeec883a..802c13e49 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -1,10 +1,19 @@ # Upgrading to a New NetBox Release -## Review the Release Notes +Upgrading NetBox to a new version is pretty simple, however users are cautioned to always review the release notes and save a backup of their current deployment prior to beginning an upgrade. + +NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change). + +[![Upgrade paths](../media/installation/upgrade_paths.png)](../media/installation/upgrade_paths.png) + +!!! warning "Perform a Backup" + Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process. + +## 1. Review the Release Notes Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. -## Update Dependencies to Required Versions +## 2. Update Dependencies to Required Versions NetBox v3.0 and later require the following: @@ -14,7 +23,7 @@ NetBox v3.0 and later require the following: | PostgreSQL | 10 | | Redis | 4.0 | -## Install the Latest Release +## 3. Install the Latest Release As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. @@ -87,7 +96,7 @@ sudo git pull origin master sudo git checkout v2.11.11 -## Run the Upgrade Script +## 4. Run the Upgrade Script Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: @@ -118,7 +127,7 @@ This script performs the following actions: been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are intentionally modifying the database schema. -## Restart the NetBox Services +## 5. Restart the NetBox Services !!! warning If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference. @@ -129,7 +138,7 @@ Finally, restart the gunicorn and RQ services: sudo systemctl restart netbox netbox-rq ``` -## Verify Housekeeping Scheduling +## 6. Verify Housekeeping Scheduling If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.) diff --git a/docs/media/installation/upgrade_paths.png b/docs/media/installation/upgrade_paths.png new file mode 100644 index 0000000000000000000000000000000000000000..494744b5826c9a13aab8ee14bc0620d26a84de7d GIT binary patch literal 9038 zcmdU#XIvB8_VRHAgqNfim=5f4x2)~mJyxJ7Q`!?-; zOiz4^NwMi*&Q)@simvG~Q6Wpt$u}ZsR7kG>pB(%<)7A3gmY69ePi(cEWNu!I1G`+-#mN*_(`M7= zX}|0dSRUib>oh!!F03-}U9@8LcxDi?7%$ngl0351<@tJ0YVquvv)4T>CplS`DMv5= zaedSiwN#b^3RVr4OZ~X>K1dDhGNf=4HXf2ZNXl38ICutgnc9z)>{@}3ESc6yG@3Oi zM4m}qJlTKl0iza|0oF*zCB>=Ts#Cnsy#zsl?q1?%Prc$j*t-bS#MJ;;S^u1*j}ndj zD{grSFI&YOx3}-H)DPM>cv!0)pT0E69~3^zl!N74_=WZ|sL2~@92N@#o2(XUft1R500+rEcq-smS!0Weuq^%tIMwXyU18Hh7%( zUsv-LSf1Lt(B+W5wJZ?Siy^rYA=V6X71sZ@EejsT99h0p0DrSRzA$qX zdq_=k?O4GY{th#hvKPqd%9*XmHx?hu64d6tn->><BES<}tM_cT=3HneI3gvYcl<^$H&&&VIw}y;qBUXYUm^sFz%XABlWy zy+}M}nL-|}NeyJ>H?jQbQ1b4Qm6=i`Kq7zClJ6vhj`Pg2fEZ2lg_Ek5m*IFydYm}B zg7R|HX}=i*x3~;8Di@rqQ>(jn4sOuzfFx=e!R7-mDh5N0%u?PKSjKH#GSM*mk%!r~I zuPu2QW%%-qJkJ(rdNet(C8fv^n?rXZGj3dbk3kk3e`Cio)9RAh9v$%)u*CwOXhTJl(#ylLPX@vFN2^Qf z1o2@g@kUYS=ujk2W@hqUprR{lLBFwp?-ly6i=bK$CQ0`kd=6rNLf0=K9uA5`SlY_d z{hXu8W&K61irn<8rs1?!UxRXnWK%ypbBlWA`=*X^omJGb;;h>~MT=7O58s8awXisM zdVTXn);{88S^2hCYa_2(Ai!80rBXNtNC%0e-A$FRDT>Y+ zVgRT}1RWqNl4z?l2)>I=uF8jKH}KhTf)8gTPi!Lgbqq6XZA4-K>&jcZS#2)+?~d5+ z!P;^xo+G5srIp&dM$pqXsADC;}RcJZJeJzz;ZV;O(9;c44 z&0A(Cd}^q@cXO@U(E4N9| zXC7Mh5r1Ff(hZhxtN1+5UJ@t(T2%xSnH;T1qScr--<5g2?o^hS!h-#gGQHfrLXq1w zKf=T*Zeh>UXss+nkPHUn=@z+@6-gad{m-2y`NH)hm9AE%4BpPOvQeeOC7;?acJ(f3 zJqJHpoTpwRhJZ$@vH`*Eo-@HDXn&uGhTsN&lzWb_nXQ-FI1NF# zrW^^)3d(&qPPWUfBCF4JBmBk24;xjyf>8Y!sx4_{IyVmJ|QtembygTzpO z=9>Hdk%uL@YA}7C@eWAxUd}vBU3&OJxbEydckgD$SIWEHWG+VcU$vjGg|t@0dDP{- z=T@&-?WJJ?xyb?)K$1%GbhK`27{XyAmfYc_KW>XYZ7( zW5`|}+)M~ST~YBY=-y@Gt9$ZkTX8_|Ip*|3I6BN=wFg|@rf?co4YQTJ0V?Iu}MtD;dT` zYz?*_n6-8Lw%iN1kV4)Co)P6vVCu=T!XE=e#H+%!rGJ1N;XU!(|GdL`PI(sJaU&=S(=@8srrW?v zO(FyfA?!ZD#t_DOb#VHGy06I1MJQ5HsLgc-!Mbw$Q|fe0Ty|OAAzJy`<(pI~TLSd? zTLj6cLp=|Q%1LN0X;k!e@4aICU=efHj$5>!g-k{P(2Yf2W!JsNA&QI538&L@60TnR z>Fp=Q1&on5DT}v}DR!T(_UrJ@oW^YXjA#;oolb8_Qf|1au5?};oA1Uy_bVrnC{>gK zHSmcaM{Uf29G}%y=S5e$My|h->~2OK)%EuKvFz%H?mTgP=FMqhwlrpvl zy_+490=WGG%Gp}n z-j|8#i}+=vJeK|GkdL?b#@49NobpB~$@`LlwWQW&?Y|pq?t?rU@s68z%MkJeNnzVJ zruc*WLlggUo>v-{TJ79q+(4~u0DcX(L+h*$b!ge6I^8X*z-as%n(V#` zN^N$Ui5LoC@;j<}zs!sCxcmSN`4AdD>6jq4v`Q&40m~=7In`nHUg=kJnjg@Y=DD1w z<)V4#h<@73QS&21sJa$sTr@UE5b*jEHX1t9^cOwSr*pLz{3;g`wtr*oR%18 zxOq5?8`0za>U~VaPG~6WThTh>RFk#HynY+b@l0@5i!P* zwdbGL_gzbqsR0x~Y?<|$C&FdaXRiif_qnQb`UXn#myk8z1+Q}>x_VFZ=f!bPJjm0YKdcELv)v`XhPZxXBqVi7y;z~;g55>9^(TE~=0r%R z_fX$vaBkb%hqkEaS_7P}Iwgl^<&C-+tVe)|G9=Amn$C(78tAQ`!hwyGe&c(lVf%Ds z9Cg>prX;Hc{@q1(XUO~H0QrEDqeZNF!bR2GLS%K4U3SP%s& zt{5#EdLeUu@yYRg{WA+HBz|`;qoL+7?Kn_=$2iY=AVp8gwS8gR@nH3c;mN1~(+L*S zv+|}yk`I@ii1M*dM*o?edPtZutYgEB_3lN$)AWna>~=noo@%|xoZ#b{d>@hEwIu$g znZ2{9{!Jxz%9B(@abfIiH; z80G!AjNMGh_dygmmG!iT>4&w`wBB5~OZSE*IewLq6TanNGZaY%HvYEq(1oBgUEZiu z{{t3yYgYcc1;pB!RixBRFUzP%uc7HnmeaR6v-70^M>HdG633E-z}WZISWdyQo72Vx zfcUb(Do}gq*r|2%H)07;-t}Uxg<031L5fEWI~O#j9G3&wz^P$|T3WBo;cwQ2ipL(Y zyOhIj!jxV%q*sK`*uc#`(*E_NH`)8aMaJrp^6DIkAL3;>|9pFKY$4T6Lbe3R7cTCHNrv#WY@s^rxPq(mgvaNQioz5eC&qx&MO=gPJz z_$k-iDt%pw-Ick*sbL~Wf5tNS@KNjZaX;M&p`jcq-ZP!ThT$$i!g2ZtxyV>g-W z16vIo01jY)kl!tNJY^F(tJ2i6k5%Eq{GufEvkLVOn6_=Yl8+s(S;IwUf{vRnMl^3# zcc!jj4^ZH7$kkvg5(eI9O3MH`aB_RKe<>LQX)f8YxzzNaWbzsl04!cDC<=Rd`5hY$ z8TJL$l){H3(q=umDb%*@bR04%Z7wRtQkuw&kEP*$xsQerQ;gmj+y1jPHmKK8JRj%3 z0dT9h=FbcykFGm%LDAJ@twDPT5JUlMnZ`-c-f6I-08yGD5=>dZN<;RR6~qNvhj6tP z*O7C;4!W5N~}EwQe~VVeNudQeZ=XbHR6=S{Gzpuw;Z7PcEyP1TWR-BGIkk#S#?CSp((p~R<^R! z0EEVDMcxat(dzP^54f3hP2$JF%ZyGdZoU7U^>D!WJsL8xw6o3vaI`ph`nDfwMhVt9 zgQoRk?;r_>@4%HHyQ@y&8LdOTVZ(ZAO=K#^83Ly5FKRfxHn2Wvi~YX(ac{Db29yy5 zzJ$%w6VnlrxT%znOOSza#UxRzN6Iv-cKTk1kgByKqyO4M7z>B(xfV*&h=93+R6&3v z&oJDZ*S)vkE#p@I4@f6;O=5a=p{av|JK;A!@d=4#i!G1$HtkOI)JQ1C86{^d_oL0Z z!=8na5i&Y_f4iI=2Zf1*iU<?sbl$oBVB&s`q`ZmEnDf0C@lh`bh<8@R zW&q>*e8>R$Ahp}c-4k&;LHf+N0ZO0IO%K_>H5^j^aU_c~He!%b2-eN63ioIy2j6px z8f?xNQnG0wJL?EwK8_-)g@?_I`w4!31DOCgfp^T$8~I{Rd1E6T1K%aG%0Qf{(Q&dJ&DCTf5WQ@6HDtNty?|u?Yh@72Lkho+z*$P z)7P;_kcBF95C|ifIYS;oi#SwoHYl_B#?44PEKZ3&RQE&fp4&noy;FR+89TyJrS7Sb z01XMhlpfrkoowp~@>$$RI#3zJk-xEus3Y(sz!*9)r9^`dfZZ9xL@I?7wVI+rQE@ww z&%z}l7~aX6k%#F=9!Q&Nhtot2gdT_vR_vGykVKPk!6oFe?kO#q(qx|X?98L=&-BAS z3M=wVj~8|rzrZV)bz{;$z6MvDO|>?6J*#SrtCigtEHAu;_v@UWxUF&=<8r+K?o4`V zEe)03G)BYL!?P#@WALy4N)%ZB;$bCm{0D$b_1;@+m*&SIOgMmPt=X)dyi6&Ny2bS$ zM$0-}-#}h)H+sK$Uzsb9dWb-~9;{FJWcq|iZm^TR{58M&V8fcRhR+9TXbxlalX=UN z_hKZ0QTW`yKAb+~-lq37|CD>LtrOa7MedUqpX}159b_tW9)LRcn-;b(cGTxMQTE<3CVlzR1X_G${#ZBL=K;+|7p%$H6 z4sGMkQ`aX;K7IO~c7sKJ%tkLGw9INxkx zroB5|C6@Wyz5^uB-?m$cXgW&uwf1#I!9J^^{*rPJE4O`VK#9z{m9C<@IKedF`Iqo} zvvJB!2TEl8oA%7>X*_)I9%CNQ{i+pz95uy25|iB$gOg%@Rf~@?+XsY6JP}D78K5AX z5X$fO*EOI|IdViB27Kob>k%p2Hte-ekM=hjtT5)2Dw>&EMnJ z5o(ANmDD)mH@IK&i2UE(_lsDGx8Gy3{i9lUs;C^G;7TxPHi97k4{=JI#OBa3lyFd4 zxh;w~-8mn!G9qc5sfzPzlcru9LZq;Kc_?mtdHR`q=rC#E+?vA<(`iRgtKpQFSonza zUyoSO@QpvN*}^WqHtt+H8Y~iEl9Y=QT5WTfa3WJCSU&u&t^IU8uuW>j`1T3PUnA9| zY|w<_O!H3+CuKn3mJfi|AM%d^=%3^JbP|!8iqb|?UC-HP@oa|qZN!`mU1a3fL^Zk$ zp!RL&FA$evC)6ilC<*8Bc!=9JljX*Illr-uBKAOWUp(BW8dbsBF_1z~rz6eC&psB^ zs@g9R5!a-~kzBcn2t}s{Q`aRJT=#dGYoLU}b4I?8jvp+rxhd8A8IJqAdL79&dH$b&t_=pFya5dgU4 zabnl>)NZERdTTkg*^OJBq?0~16-J#_dz;DfTP7r2HnqBca={G%3_j5LOUO8(fxOfE zgYPxg3Aje&bQ9e>&bY<2n)L8!74cS7J{YUtQh65G^1-X1^<-}o%1S)<;;nbU(2(9i zM~%@z|BL57))Dj+rlCtzpme19yH0B5k8%RiRk8aa)39wEsOr4KvXIUih^ z&h~Gj6q)tkkpaod%kJJjWjmdh6V`1Nv1==ARrpG=&$R0A1bj@vGBCwW{axJqfdp5J z^kzWdh5)g1W-VvLQIxWxqxvgkz`NF}ZQt#DS62CeSylh;VlJXg> zwvo^s8)!+%yBFAcIeVcZr7qvUguW9H@Ih(6S zN9?lKXUVhb&{skF4m(Hea@&0Jb`L{Nj|*~}nnK423FWez0-9m2YDd-cs-}`Z4-ykL zYzc3G0UhOcZC2GKj%UPUtRrd$?=;4NHDtk)G_ z(mdF}#b-y_i`i6iO?Gyg4O%zM|UlKga zGH!9}gd_r9kOS+e&^Muj_SG&|hJ|MDh(K(=5`0&ik!G}gblssk@`E&z(04JXF;ok= zZd9ft8&&38fN@Sp5Eo9PMM~hxCd6}slJeB%oERYmcZukIrk|-?uk;Cd!;DQ##tJTy zHdB*UyDGmWOo`rU$n)l!WuXI=P@HWICfllAD99c1G`Uv8h_dbGhV=HoFm!HuMo`^O zJ1G3FFOCc|mAeS&iwgKjeQu~iyLID5Q&V(*37K><-mis0*kLIO=YiPp7Z7R{8LuQf zJ2*=XhcWIMiJjf^&;ZRY+(4q>m_ML}kZzJmf8YtaTm+n*)EOf9E-E z`#-_M?x16Ve7~?Na00VKg1&{&f8hM@id8WH8z7if|6!>REREuO@&2b3R7PU^5yJGJ z?f=^O#sN{{0U+rS6))HCShr3J?B>u?I@AFw{IbEG7~GaY0wm!Tl+-T*UyY)D^*dux zAbI$534ez&Ss)J9lArwpzVH!qAmEyt@zopuB%FZZ75@JLmuO`Cohb|3p;W)qg3Df3fWU v??#lE^3ZF)eQo*QUv2*VcZ!C>Zd+l6iiVj}fGF-i89dGVy7x*|AYuOxTgc5- literal 0 HcmV?d00001 From d51e833bf378912c67f60d6eab6f6ba9565a6f09 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 8 Sep 2022 13:11:17 -0700 Subject: [PATCH 17/42] #8580 changes from code review --- netbox/dcim/filtersets.py | 6 ++--- netbox/dcim/forms/filtersets.py | 41 ++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 1a9887c31..4ccf0dda5 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1144,6 +1144,9 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) + is_occupied = django_filters.BooleanFilter( + method='filter_is_occupied' + ) class PathEndpointFilterSet(django_filters.FilterSet): @@ -1304,9 +1307,6 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) - is_occupied = django_filters.BooleanFilter( - method='filter_is_occupied' - ) class Meta: model = Interface diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 85fe909c5..fe92350f9 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1000,7 +1000,28 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceFilterForm(DeviceComponentFilterForm): +class CabledFilterForm(forms.Form): + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + is_occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -1088,24 +1109,6 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='VRF' ) tag = TagFilterField(model) - cabled = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - connected = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - is_occupied = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) class FrontPortFilterForm(DeviceComponentFilterForm): From 385a0f979ec460023d3d7f8e9003ddc373fb5ac3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Sep 2022 16:50:16 -0400 Subject: [PATCH 18/42] Changelog for #10250, #10294 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index beceae802..fcd5bc0f5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,9 +9,11 @@ ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables * [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments +* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field --- From 59a2a43473b4e0ebc0670b5901328ecc64bd8be2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 Sep 2022 07:40:01 -0700 Subject: [PATCH 19/42] #10307 fix choices for poe type --- netbox/dcim/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 019ae09a4..7d35a40f9 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1096,7 +1096,7 @@ class InterfacePoETypeChoices(ChoiceSet): (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), - (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + (PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'), ) ), ) From cd1ad452da5fdffda3d2dd50d44c44b2d5d79b2d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Sep 2022 16:44:58 -0400 Subject: [PATCH 20/42] Move clone() to CloningMixin --- netbox/netbox/models/__init__.py | 24 ++---------------------- netbox/netbox/models/features.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 4c65094ca..aefb733b4 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -2,7 +2,6 @@ from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey -from extras.utils import is_taggable from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet from netbox.models.features import * @@ -32,7 +31,7 @@ class NetBoxFeatureSet( def get_prerequisite_models(cls): """ Return a list of model types that are required to create this model or empty list if none. This is used for - showing prequisite warnings in the UI on the list and detail views. + showing prerequisite warnings in the UI on the list and detail views. """ return [] @@ -52,7 +51,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class NetBoxModel(NetBoxFeatureSet, models.Model): +class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -61,25 +60,6 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): class Meta: abstract = True - def clone(self): - """ - Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- - populating an object creation form in the UI. - """ - attrs = {} - - for field_name in getattr(self, 'clone_fields', []): - field = self._meta.get_field(field_name) - field_value = field.value_from_object(self) - if field_value not in (None, ''): - attrs[field_name] = field_value - - # Include tags (if applicable) - if is_taggable(self): - attrs['tags'] = [tag.pk for tag in self.tags.all()] - - return attrs - class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 6b2ee1f94..7f30248b4 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -10,12 +10,13 @@ from django.db import models from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices -from extras.utils import register_features +from extras.utils import is_taggable, register_features from netbox.signals import post_clean from utilities.utils import serialize_object __all__ = ( 'ChangeLoggingMixin', + 'CloningMixin', 'CustomFieldsMixin', 'CustomLinksMixin', 'CustomValidationMixin', @@ -82,6 +83,33 @@ class ChangeLoggingMixin(models.Model): return objectchange +class CloningMixin(models.Model): + """ + Provides the clone() method used to prepare a copy of existing objects. + """ + class Meta: + abstract = True + + def clone(self): + """ + Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. + """ + attrs = {} + + for field_name in getattr(self, 'clone_fields', []): + field = self._meta.get_field(field_name) + field_value = field.value_from_object(self) + if field_value not in (None, ''): + attrs[field_name] = field_value + + # Include tags (if applicable) + if is_taggable(self): + attrs['tags'] = [tag.pk for tag in self.tags.all()] + + return attrs + + class CustomFieldsMixin(models.Model): """ Enables support for custom fields. From 2b2a41edd29042e7b94484bd3476eccb73dcb918 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Sep 2022 16:51:18 -0400 Subject: [PATCH 21/42] Enable cloning for custom fields & custom links --- netbox/extras/models/customfields.py | 10 ++++++++-- netbox/extras/models/models.py | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 426565231..43c4f9671 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -14,7 +14,7 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin, WebhooksMixin +from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -41,7 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', @@ -143,8 +143,14 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): verbose_name='UI visibility', help_text='Specifies the visibility of custom field in the UI' ) + objects = CustomFieldManager() + clone_fields = ( + 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default', + 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility', + ) + class Meta: ordering = ['group_name', 'weight', 'name'] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 4873a1f9e..0df34c146 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -21,7 +21,7 @@ from extras.conditions import ConditionSet from extras.utils import FeatureQuery, image_upload from netbox.models import ChangeLoggedModel from netbox.models.features import ( - CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, + CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, ) from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -187,7 +187,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return render_jinja2(self.payload_url, context) -class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -230,6 +230,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text="Force link to open in a new window" ) + clone_fields = ( + 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + ) + class Meta: ordering = ['group_name', 'weight', 'name'] From 3aac62caa7ead476410129fb7072de30cfcecf6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Sep 2022 17:08:12 -0400 Subject: [PATCH 22/42] Changelog for #10304, #10307 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fcd5bc0f5..480595d56 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -14,6 +14,8 @@ * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field +* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links +* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection --- From 721cd578bb02c9a1e6d14042fdb464892530ae80 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 12 Sep 2022 06:16:17 -0700 Subject: [PATCH 23/42] 10310 pre commit yarn (#10315) * #10310 run yarn pre-commit only if static files changed --- scripts/git-hooks/pre-commit | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 7a3d680a4..2ccf8df89 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -40,10 +40,13 @@ if [ $? != 0 ]; then EXIT=1 fi -echo "Checking UI ESLint, TypeScript, and Prettier compliance..." -yarn --cwd "$PWD/netbox/project-static" validate -if [ $? != 0 ]; then - EXIT=1 +git diff --cached --name-only | if grep --quiet 'netbox/project-static/' +then + echo "Checking UI ESLint, TypeScript, and Prettier compliance..." + yarn --cwd "$PWD/netbox/project-static" validate + if [ $? != 0 ]; then + EXIT=1 + fi fi if [ $EXIT != 0 ]; then From f67cb71dbce226a36974f152bb8f48c158a6f5f3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 10:36:19 -0400 Subject: [PATCH 24/42] Fixes #10333: Show available values for ui_visibility field of CustomField for CSV import --- netbox/extras/forms/bulk_import.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index d9148a5c3..e83cac3b9 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe -from extras.choices import CustomFieldTypeChoices +from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices from extras.models import * from extras.utils import FeatureQuery from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField @@ -38,6 +38,10 @@ class CustomFieldCSVForm(CSVModelForm): required=False, help_text='Comma-separated list of field choices' ) + ui_visibility = CSVChoiceField( + choices=CustomFieldVisibilityChoices, + help_text='How the custom field is displayed in the user interface' + ) class Meta: model = CustomField From d078befd33f7c0aabe5cc9717db13886cda40985 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 12 Sep 2022 11:48:26 -0400 Subject: [PATCH 25/42] Introduce pyproject.toml - Tweaked to not break the existing codebase... too much --- pyproject.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..6d579b737 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +# See PEP 518 for the spec of this file +# https://www.python.org/dev/peps/pep-0518/ + +[tool.black] +line-length = 120 +target_version = ['py38', 'py39', 'py310'] +skip-string-normalization = true + +[tool.isort] +profile = "black" + +[tool.pylint] +max-line-length = 120 From 1daa2ff98d07020c864c90b5f0e306956da35479 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 10:22:05 -0700 Subject: [PATCH 26/42] #8580 add tests --- netbox/dcim/tests/test_filtersets.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1aaf861ef..15e109030 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2741,12 +2741,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'label': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_enabled(self): params = {'enabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) @@ -2885,6 +2879,18 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def is_occupied(self): + params = {'is_occupied': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'is_occupied': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_kind(self): params = {'kind': 'physical'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) From d24f10ce6e2878a8963412ec271f0dd449862bf7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 10:52:40 -0700 Subject: [PATCH 27/42] #8580 add tests --- netbox/circuits/tests/test_filtersets.py | 18 ++++++++++++++++-- netbox/dcim/filtersets.py | 12 ++++++------ netbox/dcim/tests/test_filtersets.py | 6 +++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index abcfa8a00..ada3d9bf1 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), ) Circuit.objects.bulk_create(circuits) @@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -397,12 +399,24 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_provider_network(self): provider_networks = ProviderNetwork.objects.all()[:2] params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + + def test_is_occupied(self): + params = {'is_occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'is_occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ProviderNetwork.objects.all() diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4ccf0dda5..3af5883ba 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1148,6 +1148,12 @@ class CabledObjectFilterSet(django_filters.FilterSet): method='filter_is_occupied' ) + def filter_is_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) + class PathEndpointFilterSet(django_filters.FilterSet): connected = django_filters.BooleanFilter( @@ -1362,12 +1368,6 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) - def filter_is_occupied(self, queryset, name, value): - if value: - return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) - else: - return queryset.filter(cable__isnull=True, mark_connected=False) - class FrontPortFilterSet( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 15e109030..6cbd91122 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2885,10 +2885,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def is_occupied(self): - params = {'is_occupied': 'true'} + def test_is_occupied(self): + params = {'is_occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'is_occupied': 'false'} + params = {'is_occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): From 57365ef7b9d012b4c5d27361bfe7c0ca3012db15 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 16:42:04 -0400 Subject: [PATCH 28/42] Rename is_occupied to occupied --- netbox/circuits/tests/test_filtersets.py | 6 +++--- netbox/dcim/filtersets.py | 6 +++--- netbox/dcim/forms/filtersets.py | 4 ++-- netbox/dcim/tests/test_filtersets.py | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index ada3d9bf1..0bc0711c1 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -411,10 +411,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) - def test_is_occupied(self): - params = {'is_occupied': True} + def test_occupied(self): + params = {'occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'is_occupied': False} + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3af5883ba..afecf551c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1144,11 +1144,11 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) - is_occupied = django_filters.BooleanFilter( - method='filter_is_occupied' + occupied = django_filters.BooleanFilter( + method='filter_occupied' ) - def filter_is_occupied(self, queryset, name, value): + def filter_occupied(self, queryset, name, value): if value: return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) else: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index fe92350f9..93e221b19 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1013,7 +1013,7 @@ class CabledFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - is_occupied = forms.NullBooleanField( + occupied = forms.NullBooleanField( required=False, widget=StaticSelect( choices=BOOLEAN_WITH_BLANK_CHOICES @@ -1030,7 +1030,7 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ('Connection', ('cabled', 'connected', 'is_occupied')) + ('Connection', ('cabled', 'connected', 'occupied')) ) kind = MultipleChoiceField( choices=InterfaceKindChoices, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6cbd91122..49e68b9a2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2885,10 +2885,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_is_occupied(self): - params = {'is_occupied': True} + def test_occupied(self): + params = {'occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'is_occupied': False} + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): From f10460d774db773c759faa4b9b96c59470c12a5a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 17:03:33 -0400 Subject: [PATCH 29/42] Add relevant tests for all device components --- netbox/circuits/tests/test_filtersets.py | 8 +- netbox/dcim/forms/filtersets.py | 157 ++++++++++++----------- netbox/dcim/tests/test_filtersets.py | 128 +++++++++++------- 3 files changed, 167 insertions(+), 126 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 0bc0711c1..2646de3c2 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -404,12 +404,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_occupied(self): params = {'occupied': True} diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 93e221b19..98be0983e 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -936,70 +936,6 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): # Device components # -class ConsolePortFilterForm(DeviceComponentFilterForm): - model = ConsolePort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False - ) - speed = MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False - ) - tag = TagFilterField(model) - - -class ConsoleServerPortFilterForm(DeviceComponentFilterForm): - model = ConsoleServerPort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False - ) - speed = MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False - ) - tag = TagFilterField(model) - - -class PowerPortFilterForm(DeviceComponentFilterForm): - model = PowerPort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=PowerPortTypeChoices, - required=False - ) - tag = TagFilterField(model) - - -class PowerOutletFilterForm(DeviceComponentFilterForm): - model = PowerOutlet - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=PowerOutletTypeChoices, - required=False - ) - tag = TagFilterField(model) - - class CabledFilterForm(forms.Form): cabled = forms.NullBooleanField( required=False, @@ -1007,12 +943,6 @@ class CabledFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - connected = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) occupied = forms.NullBooleanField( required=False, widget=StaticSelect( @@ -1021,7 +951,84 @@ class CabledFilterForm(forms.Form): ) -class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): +class PathEndpointFilterForm(CabledFilterForm): + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = ConsolePort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False + ) + tag = TagFilterField(model) + + +class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = ConsoleServerPort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False + ) + tag = TagFilterField(model) + + +class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = PowerPort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=PowerPortTypeChoices, + required=False + ) + tag = TagFilterField(model) + + +class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = PowerOutlet + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=PowerOutletTypeChoices, + required=False + ) + tag = TagFilterField(model) + + +class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -1030,7 +1037,7 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ('Connection', ('cabled', 'connected', 'occupied')) + ('Connection', ('cabled', 'connected', 'occupied')), ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1111,11 +1118,12 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortFilterForm(DeviceComponentFilterForm): +class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) model = FrontPort type = MultipleChoiceField( @@ -1128,12 +1136,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortFilterForm(DeviceComponentFilterForm): +class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) type = MultipleChoiceField( choices=PortTypeChoices, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 49e68b9a2..eb4627ac0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1983,12 +1983,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2037,9 +2031,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2144,12 +2150,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2198,9 +2198,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2313,12 +2325,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'allocated_draw': [50, 100]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2367,9 +2373,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2478,12 +2496,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2532,9 +2544,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2874,15 +2898,9 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'connected': False} + params = {'cabled': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_occupied(self): @@ -2891,6 +2909,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_kind(self): params = {'kind': 'physical'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) @@ -3097,9 +3121,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -3261,9 +3291,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -4165,9 +4201,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_connected(self): From 211a1394d3671a9ff8a99bc8071296d13e0cbdfb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 17:17:53 -0400 Subject: [PATCH 30/42] Changelog for #8580, #10333 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 480595d56..f9fe3d494 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,6 +4,7 @@ ### Enhancements +* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI ### Bug Fixes @@ -16,6 +17,7 @@ * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field * [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection +* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import --- From ea9d2e3f88c8560141cba95554595c1b9631b196 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Sep 2022 14:14:18 -0400 Subject: [PATCH 31/42] Closes #9577: Add has_front_image and has_rear_image filters for device types --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/filtersets.py | 20 ++++++++++++++++++++ netbox/dcim/forms/filtersets.py | 15 +++++++++++++++ netbox/dcim/tests/test_filtersets.py | 18 +++++++++++++++--- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f9fe3d494..c5ca3d5be 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -5,6 +5,7 @@ ### Enhancements * [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` +* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI ### Bug Fixes diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index afecf551c..0a4439173 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + has_front_image = django_filters.BooleanFilter( + label='Has a front image', + method='_has_front_image' + ) + has_rear_image = django_filters.BooleanFilter( + label='Has a rear image', + method='_has_rear_image' + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label='Has console ports', @@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): Q(comments__icontains=value) ) + def _has_front_image(self, queryset, name, value): + if value: + return queryset.exclude(front_image='') + else: + return queryset.filter(front_image='') + + def _has_rear_image(self, queryset, name, value): + if value: + return queryset.exclude(rear_image='') + else: + return queryset.filter(rear_image='') + def _console_ports(self, queryset, name, value): return queryset.exclude(consoleporttemplates__isnull=value) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 98be0983e..96b0d1319 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -365,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', @@ -386,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=add_blank_choice(DeviceAirflowChoices), required=False ) + has_front_image = forms.NullBooleanField( + required=False, + label='Has a front image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + has_rear_image = forms.NullBooleanField( + required=False, + label='Has a rear image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index eb4627ac0..feef4e90c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -688,7 +688,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True), + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), ) @@ -753,9 +753,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_is_full_depth(self): - params = {'is_full_depth': 'true'} + params = {'is_full_depth': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'is_full_depth': 'false'} + params = {'is_full_depth': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_subdevice_role(self): @@ -773,6 +773,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_has_front_image(self): + params = {'has_front_image': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_front_image': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_has_rear_image(self): + params = {'has_rear_image': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_rear_image': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_console_ports(self): params = {'console_ports': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) From ace66eab61f53294a78a8ede48cf73d17518e8f0 Mon Sep 17 00:00:00 2001 From: Zachary Clark Date: Mon, 12 Sep 2022 00:21:20 -0400 Subject: [PATCH 32/42] Fixes #10305: Allows null master in VirtualChassis APIs --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/tests/test_api.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 79f5339ad..897ee4ca3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1076,7 +1076,7 @@ class CablePathSerializer(serializers.ModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer(required=False) + master = NestedDeviceSerializer(required=False, allow_null=True, default=None) member_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index acd52178d..2697c29b2 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2057,6 +2057,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): cls.bulk_update_data = { 'domain': 'newdomain', + 'master': None } From 356ff457be08d5527920c617eb598f24a6edbc3d Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 14 Sep 2022 19:57:37 +0200 Subject: [PATCH 33/42] Allow reports to be nested in submodules --- netbox/extras/api/views.py | 6 ++--- .../extras/management/commands/runreport.py | 4 +-- netbox/extras/reports.py | 27 ++++++++++++------- netbox/extras/urls.py | 2 +- netbox/extras/views.py | 7 ++--- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index c7c6cc2aa..63003bdf2 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -159,7 +159,7 @@ class ReportViewSet(ViewSet): # Read the PK as "." if '.' not in pk: raise Http404 - module_name, report_name = pk.split('.', 1) + module_name, report_name = pk.split('.', maxsplit=1) # Raise a 404 on an invalid Report module/name report = get_report(module_name, report_name) @@ -183,8 +183,8 @@ class ReportViewSet(ViewSet): } # Iterate through all available Reports. - for module_name, reports in get_reports(): - for report in reports: + for module_name, reports in get_reports().items(): + for report in reports.values(): # Attach the relevant JobResult (if any) to each Report. report.result = results.get(report.full_name, None) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index ee166ae6a..38d435613 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -21,8 +21,8 @@ class Command(BaseCommand): reports = get_reports() # Run reports - for module_name, report_list in reports: - for report in report_list: + for module_name, report_list in reports.items(): + for report in report_list.values(): if module_name in options['reports'] or report.full_name in options['reports']: # Run the report and create a new JobResult diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 43d916aff..702ea0338 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -26,20 +26,18 @@ def get_report(module_name, report_name): """ Return a specific report from within a module. """ - file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name) + reports = get_reports() + module = reports.get(module_name) - spec = importlib.util.spec_from_file_location(module_name, file_path) - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except FileNotFoundError: + if module is None: return None - report = getattr(module, report_name, None) + report = module.get(report_name) + if report is None: return None - return report() + return report def get_reports(): @@ -52,7 +50,7 @@ def get_reports(): ... ] """ - module_list = [] + module_list = {} # Iterate through all modules within the reports path. These are the user-created files in which reports are # defined. @@ -61,7 +59,16 @@ def get_reports(): report_order = getattr(module, "report_order", ()) ordered_reports = [cls() for cls in report_order if is_report(cls)] unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order] - module_list.append((module_name, [*ordered_reports, *unordered_reports])) + + module_reports = {} + + for cls in [*ordered_reports, *unordered_reports]: + # For reports in submodules use the full import path w/o the root module as the name + report_name = cls.full_name.split(".", maxsplit=1)[1] + module_reports[report_name] = cls + + if module_reports: + module_list[module_name] = module_reports return module_list diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 6c6156f4a..ced3bd4b9 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -100,8 +100,8 @@ urlpatterns = [ # Reports path('reports/', views.ReportListView.as_view(), name='report_list'), - path('reports/./', views.ReportView.as_view(), name='report'), path('reports/results//', views.ReportResultView.as_view(), name='report_result'), + re_path(r'^reports/(?P.([^.]+)).(?P.(.+))/', views.ReportView.as_view(), name='report'), # Scripts path('scripts/', views.ScriptListView.as_view(), name='script_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 30f48f817..d8a015bb0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -534,9 +534,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): } ret = [] - for module, report_list in reports: + + for module, report_list in reports.items(): module_reports = [] - for report in report_list: + for report in report_list.values(): report.result = results.get(report.full_name, None) module_reports.append(report) ret.append((module, module_reports)) @@ -613,7 +614,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View): result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type) # Retrieve the Report and attach the JobResult to it - module, report_name = result.name.split('.') + module, report_name = result.name.split('.', maxsplit=1) report = get_report(module, report_name) report.result = result From c335b76ec69515ea2055a93a5b8cd0f735139dd6 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 14 Sep 2022 20:00:12 +0200 Subject: [PATCH 34/42] PEP8: Fix whitespace on blank line --- netbox/extras/reports.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 702ea0338..32e4efc2d 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -59,14 +59,14 @@ def get_reports(): report_order = getattr(module, "report_order", ()) ordered_reports = [cls() for cls in report_order if is_report(cls)] unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order] - + module_reports = {} for cls in [*ordered_reports, *unordered_reports]: # For reports in submodules use the full import path w/o the root module as the name report_name = cls.full_name.split(".", maxsplit=1)[1] module_reports[report_name] = cls - + if module_reports: module_list[module_name] = module_reports From 4d97043e268b29ef7ddfea848b8e768949896a9f Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 14 Sep 2022 15:50:45 -0700 Subject: [PATCH 35/42] #10359 add region column to site table --- netbox/dcim/tables/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c42731b90..a7cdf4b9f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -203,7 +203,7 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', + 'platform', 'serial', 'asset_tag', 'site', 'region', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) From 4e03419e85d30a3f9b4d3503aec57955208a584a Mon Sep 17 00:00:00 2001 From: kvedder Date: Wed, 14 Sep 2022 22:15:12 -0400 Subject: [PATCH 36/42] add custom fields to l2vpntermination edit template --- netbox/templates/ipam/l2vpntermination_edit.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html index c66b8a3d1..4379a0899 100644 --- a/netbox/templates/ipam/l2vpntermination_edit.html +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -46,4 +46,12 @@
+ {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+{% endif %} {% endblock %} From c4b7ab067a914349abd88398dd9bfef9f6c2f806 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Sep 2022 10:10:32 -0400 Subject: [PATCH 37/42] Fixes #10247: Allow changing selected device/VM when creating a new component (#10312) * Initial work on #10247 * Continued work on #10247 * Clean up component creation tests * Move valdiation of replicated field to form * Clean up ordering of fields in component creation forms * Omit fieldset header if none * Clean up ordering of fields in component template creation forms * View tests should not move component templates to new device type * Define replication_fields on VMInterfaceCreateForm * Clean up expandable field help texts * Update comments * Update component bulk update forms & views to support new replication fields * Fix ModularDeviceComponentForm parent class * Fix bulk creation of VM interfaces (thanks @kkthxbye-code!) --- netbox/dcim/forms/bulk_create.py | 25 +- netbox/dcim/forms/models.py | 247 ++++++++++------- netbox/dcim/forms/object_create.py | 260 ++++++++++++------ netbox/dcim/models/device_components.py | 24 +- netbox/dcim/tables/template_code.py | 2 +- netbox/dcim/tests/test_forms.py | 18 +- netbox/dcim/tests/test_views.py | 201 +++++++------- netbox/dcim/views.py | 97 +------ netbox/netbox/views/generic/bulk_views.py | 17 +- netbox/netbox/views/generic/object_views.py | 32 +-- .../dcim/component_template_create.html | 38 --- .../templates/dcim/device_component_edit.html | 16 -- .../dcim/frontporttemplate_create.html | 7 - .../templates/dcim/inventoryitem_create.html | 17 -- .../dcim/inventoryitemtemplate_create.html | 17 -- .../dcim/modulebaytemplate_create.html | 7 - netbox/templates/generic/object_edit.html | 8 +- .../virtualization/vminterface_edit.html | 69 ----- netbox/utilities/forms/fields/expandable.py | 2 +- netbox/utilities/testing/views.py | 5 +- netbox/virtualization/forms/bulk_create.py | 4 +- netbox/virtualization/forms/models.py | 12 +- netbox/virtualization/forms/object_create.py | 19 +- netbox/virtualization/tests/test_views.py | 7 +- netbox/virtualization/views.py | 2 - 25 files changed, 523 insertions(+), 630 deletions(-) delete mode 100644 netbox/templates/dcim/component_template_create.html delete mode 100644 netbox/templates/dcim/device_component_edit.html delete mode 100644 netbox/templates/dcim/frontporttemplate_create.html delete mode 100644 netbox/templates/dcim/inventoryitem_create.html delete mode 100644 netbox/templates/dcim/inventoryitemtemplate_create.html delete mode 100644 netbox/templates/dcim/modulebaytemplate_create.html delete mode 100644 netbox/templates/virtualization/vminterface_edit.html diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 43b852928..f6bc27079 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -3,7 +3,7 @@ from django import forms from dcim.models import * from extras.forms import CustomFieldsMixin from extras.models import Tag -from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model +from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model from .object_create import ComponentCreateForm __all__ = ( @@ -24,7 +24,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -37,6 +37,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): queryset=Tag.objects.all(), required=False ) + replication_fields = ('name', 'label') class ConsolePortBulkCreateForm( @@ -44,7 +45,7 @@ class ConsolePortBulkCreateForm( DeviceBulkAddComponentForm ): model = ConsolePort - field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') + field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags') class ConsoleServerPortBulkCreateForm( @@ -52,7 +53,7 @@ class ConsoleServerPortBulkCreateForm( DeviceBulkAddComponentForm ): model = ConsoleServerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') + field_order = ('name', 'label', 'type', 'speed', 'description', 'tags') class PowerPortBulkCreateForm( @@ -60,7 +61,7 @@ class PowerPortBulkCreateForm( DeviceBulkAddComponentForm ): model = PowerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') + field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') class PowerOutletBulkCreateForm( @@ -68,7 +69,7 @@ class PowerOutletBulkCreateForm( DeviceBulkAddComponentForm ): model = PowerOutlet - field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') + field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags') class InterfaceBulkCreateForm( @@ -79,7 +80,7 @@ class InterfaceBulkCreateForm( ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mark_connected', 'description', 'tags', ) @@ -96,13 +97,13 @@ class RearPortBulkCreateForm( DeviceBulkAddComponentForm ): model = RearPort - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') + field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags') class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags') - + field_order = ('name', 'label', 'position_pattern', 'description', 'tags') + replication_fields = ('name', 'label', 'position') position_pattern = ExpandableNameField( label='Position', required=False, @@ -112,7 +113,7 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): model = DeviceBay - field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + field_order = ('name', 'label', 'description', 'tags') class InventoryItemBulkCreateForm( @@ -121,6 +122,6 @@ class InventoryItemBulkCreateForm( ): model = InventoryItem field_order = ( - 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', ) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a21265db4..4fa27ae69 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -986,47 +986,74 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): # Device component templates # +class ComponentTemplateForm(BootstrapMixin, forms.ModelForm): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of DeviceType when editing an existing instance + if self.instance.pk: + self.fields['device_type'].disabled = True + + +class ModularComponentTemplateForm(ComponentTemplateForm): + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False + ) + + +class ConsolePortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + ) -class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect, } -class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): +class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + ) + class Meta: model = ConsoleServerPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect, } -class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): +class PowerPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ( + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + )), + ) + class Meta: model = PowerPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): +class PowerOutletTemplateForm(ModularComponentTemplateForm): power_port = DynamicModelChoiceField( queryset=PowerPortTemplate.objects.all(), required=False, @@ -1035,35 +1062,40 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } ) + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')), + ) + class Meta: model = PowerOutletTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), 'feed_leg': StaticSelect(), } -class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): +class InterfaceTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')), + ('PoE', ('poe_mode', 'poe_type')) + ) + class Meta: model = InterfaceTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), 'poe_mode': StaticSelect(), 'poe_type': StaticSelect(), } -class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): +class FrontPortTemplateForm(ModularComponentTemplateForm): rear_port = DynamicModelChoiceField( queryset=RearPortTemplate.objects.all(), required=False, @@ -1073,6 +1105,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): } ) + fieldsets = ( + (None, ( + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', + 'description', + )), + ) + class Meta: model = FrontPortTemplate fields = [ @@ -1080,48 +1119,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): +class RearPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')), + ) + class Meta: model = RearPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): +class ModuleBayTemplateForm(ComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'name', 'label', 'position', 'description')), + ) + class Meta: model = ModuleBayTemplate fields = [ 'device_type', 'name', 'label', 'position', 'description', ] - widgets = { - 'device_type': forms.HiddenInput(), - } -class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): +class DeviceBayTemplateForm(ComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'name', 'label', 'description')), + ) + class Meta: model = DeviceBayTemplate fields = [ 'device_type', 'name', 'label', 'description', ] - widgets = { - 'device_type': forms.HiddenInput(), - } -class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): +class InventoryItemTemplateForm(ComponentTemplateForm): parent = DynamicModelChoiceField( queryset=InventoryItemTemplate.objects.all(), required=False, @@ -1148,22 +1189,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): widget=forms.HiddenInput ) + fieldsets = ( + (None, ( + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + )), + ) + class Meta: model = InventoryItemTemplate fields = [ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'component_type', 'component_id', ] - widgets = { - 'device_type': forms.HiddenInput(), - } # # Device components # -class ConsolePortForm(NetBoxModelForm): +class DeviceComponentForm(NetBoxModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of Device when editing an existing instance + if self.instance.pk: + self.fields['device'].disabled = True + + +class ModularDeviceComponentForm(DeviceComponentForm): module = DynamicModelChoiceField( queryset=Module.objects.all(), required=False, @@ -1172,25 +1230,31 @@ class ConsolePortForm(NetBoxModelForm): } ) + +class ConsolePortForm(ModularDeviceComponentForm): + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + )), + ) + class Meta: model = ConsolePort fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': StaticSelect(), } -class ConsoleServerPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class ConsoleServerPortForm(ModularDeviceComponentForm): + + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + )), ) class Meta: @@ -1199,42 +1263,32 @@ class ConsoleServerPortForm(NetBoxModelForm): 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': StaticSelect(), } -class PowerPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class PowerPortForm(ModularDeviceComponentForm): + + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', + )), ) class Meta: model = PowerPort fields = [ 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', - 'tags', + 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class PowerOutletForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class PowerOutletForm(ModularDeviceComponentForm): power_port = DynamicModelChoiceField( queryset=PowerPort.objects.all(), required=False, @@ -1243,6 +1297,13 @@ class PowerOutletForm(NetBoxModelForm): } ) + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', + )), + ) + class Meta: model = PowerOutlet fields = [ @@ -1250,20 +1311,12 @@ class PowerOutletForm(NetBoxModelForm): 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'feed_leg': StaticSelect(), } -class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1338,7 +1391,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): ) fieldsets = ( - ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), + ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1358,7 +1411,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), 'poe_mode': StaticSelect(), @@ -1388,14 +1440,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) -class FrontPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class FrontPortForm(ModularDeviceComponentForm): rear_port = DynamicModelChoiceField( queryset=RearPort.objects.all(), query_params={ @@ -1403,6 +1448,13 @@ class FrontPortForm(NetBoxModelForm): } ) + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'description', 'tags', + )), + ) + class Meta: model = FrontPort fields = [ @@ -1410,18 +1462,15 @@ class FrontPortForm(NetBoxModelForm): 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class RearPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class RearPortForm(ModularDeviceComponentForm): + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', + )), ) class Meta: @@ -1430,33 +1479,32 @@ class RearPortForm(NetBoxModelForm): 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class ModuleBayForm(NetBoxModelForm): +class ModuleBayForm(DeviceComponentForm): + fieldsets = ( + (None, ('device', 'name', 'label', 'position', 'description', 'tags',)), + ) class Meta: model = ModuleBay fields = [ 'device', 'name', 'label', 'position', 'description', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } -class DeviceBayForm(NetBoxModelForm): +class DeviceBayForm(DeviceComponentForm): + fieldsets = ( + (None, ('device', 'name', 'label', 'description', 'tags',)), + ) class Meta: model = DeviceBay fields = [ 'device', 'name', 'label', 'description', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } class PopulateDeviceBayForm(BootstrapMixin, forms.Form): @@ -1479,10 +1527,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class InventoryItemForm(NetBoxModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) +class InventoryItemForm(DeviceComponentForm): parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d2c941b34..a03597db1 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -2,46 +2,56 @@ from django import forms from dcim.models import * from netbox.forms import NetBoxModelForm -from utilities.forms import ( - BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, -) +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from . import models as model_forms __all__ = ( - 'ComponentTemplateCreateForm', - 'DeviceComponentCreateForm', + 'ComponentCreateForm', + 'ConsolePortCreateForm', + 'ConsolePortTemplateCreateForm', + 'ConsoleServerPortCreateForm', + 'ConsoleServerPortTemplateCreateForm', + 'DeviceBayCreateForm', + 'DeviceBayTemplateCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', + 'InterfaceCreateForm', + 'InterfaceTemplateCreateForm', 'InventoryItemCreateForm', - 'ModularComponentTemplateCreateForm', + 'InventoryItemTemplateCreateForm', 'ModuleBayCreateForm', 'ModuleBayTemplateCreateForm', + 'PowerOutletCreateForm', + 'PowerOutletTemplateCreateForm', + 'PowerPortCreateForm', + 'PowerPortTemplateCreateForm', + 'RearPortCreateForm', + 'RearPortTemplateCreateForm', 'VirtualChassisCreateForm', ) -class ComponentCreateForm(BootstrapMixin, forms.Form): +class ComponentCreateForm(forms.Form): """ - Subclass this form when facilitating the creation of one or more device component or component templates based on + Subclass this form when facilitating the creation of one or more component or component template objects based on a name pattern. """ - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', + name = ExpandableNameField() + label = ExpandableNameField( required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' ) + # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by + # ComponentCreateView when creating objects. + replication_fields = ('name', 'label') + def clean(self): super().clean() - # Validate that all patterned fields generate an equal number of values - patterned_fields = [ - field_name for field_name in self.fields if field_name.endswith('_pattern') - ] - pattern_count = len(self.cleaned_data['name_pattern']) - for field_name in patterned_fields: + # Validate that all replication fields generate an equal number of values + pattern_count = len(self.cleaned_data[self.replication_fields[0]]) + for field_name in self.replication_fields: value_count = len(self.cleaned_data[field_name]) if self.cleaned_data[field_name] and value_count != pattern_count: raise forms.ValidationError({ @@ -50,56 +60,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form): }, code='label_pattern_mismatch') -class ComponentTemplateCreateForm(ComponentCreateForm): - """ - Creation form for component templates that can be assigned only to a DeviceType. - """ - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - ) - field_order = ('device_type', 'name_pattern', 'label_pattern') +# +# Device component templates +# + +class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm): + + class Meta(model_forms.ConsolePortTemplateForm.Meta): + exclude = ('name', 'label') -class ModularComponentTemplateCreateForm(ComponentCreateForm): - """ - Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType. - """ - name_pattern = ExpandableNameField( - label='Name', - help_text=""" - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Example: [ge,xe]-0/0/[0-9]. {module} is accepted as a substitution for - the module bay position. - """ - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - required=False - ) - module_type = DynamicModelChoiceField( - queryset=ModuleType.objects.all(), - required=False - ) - field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern') +class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm): + + class Meta(model_forms.ConsoleServerPortTemplateForm.Meta): + exclude = ('name', 'label') -class DeviceComponentCreateForm(ComponentCreateForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - field_order = ('device', 'name_pattern', 'label_pattern') +class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm): + + class Meta(model_forms.PowerPortTemplateForm.Meta): + exclude = ('name', 'label') -class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): - rear_port_set = forms.MultipleChoiceField( +class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm): + + class Meta(model_forms.PowerOutletTemplateForm.Meta): + exclude = ('name', 'label') + + +class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm): + + class Meta(model_forms.InterfaceTemplateForm.Meta): + exclude = ('name', 'label') + + +class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): + rear_port = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ( - 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set', + + # Override fieldsets from FrontPortTemplateForm to omit rear_port_position + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')), ) + class Meta(model_forms.FrontPortTemplateForm.Meta): + exclude = ('name', 'label', 'rear_port', 'rear_port_position') + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -130,12 +139,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): choices.append( ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) ) - self.fields['rear_port_set'].choices = choices + self.fields['rear_port'].choices = choices def get_iterative_data(self, iteration): # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') return { 'rear_port': int(rear_port), @@ -143,16 +152,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): } -class FrontPortCreateForm(DeviceComponentCreateForm): - rear_port_set = forms.MultipleChoiceField( +class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm): + + class Meta(model_forms.RearPortTemplateForm.Meta): + exclude = ('name', 'label') + + +class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm): + + class Meta(model_forms.DeviceBayTemplateForm.Meta): + exclude = ('name', 'label') + + +class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm): + position = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + ) + replication_fields = ('name', 'label', 'position') + + class Meta(model_forms.ModuleBayTemplateForm.Meta): + exclude = ('name', 'label', 'position') + + +class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm): + + class Meta(model_forms.InventoryItemTemplateForm.Meta): + exclude = ('name', 'label') + + +# +# Device components +# + +class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm): + + class Meta(model_forms.ConsolePortForm.Meta): + exclude = ('name', 'label') + + +class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm): + + class Meta(model_forms.ConsoleServerPortForm.Meta): + exclude = ('name', 'label') + + +class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm): + + class Meta(model_forms.PowerPortForm.Meta): + exclude = ('name', 'label') + + +class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm): + + class Meta(model_forms.PowerOutletForm.Meta): + exclude = ('name', 'label') + + +class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): + + class Meta(model_forms.InterfaceForm.Meta): + exclude = ('name', 'label') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'module' in self.fields: + self.fields['name'].help_text += ' The string {module} will be replaced with the position ' \ + 'of the assigned module, if any' + + +class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): + rear_port = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'rear_port_set', + + # Override fieldsets from FrontPortForm to omit rear_port_position + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags', + )), ) + class Meta(model_forms.FrontPortForm.Meta): + exclude = ('name', 'label', 'rear_port', 'rear_port_position') + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -176,12 +263,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm): choices.append( ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) ) - self.fields['rear_port_set'].choices = choices + self.fields['rear_port'].choices = choices def get_iterative_data(self, iteration): # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') return { 'rear_port': int(rear_port), @@ -189,28 +276,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm): } -class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): - position_pattern = ExpandableNameField( +class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm): + + class Meta(model_forms.RearPortForm.Meta): + exclude = ('name', 'label') + + +class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm): + + class Meta(model_forms.DeviceBayForm.Meta): + exclude = ('name', 'label') + + +class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm): + position = ExpandableNameField( label='Position', required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' ) - field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern') + replication_fields = ('name', 'label', 'position') + + class Meta(model_forms.ModuleBayForm.Meta): + exclude = ('name', 'label', 'position') -class ModuleBayCreateForm(DeviceComponentCreateForm): - position_pattern = ExpandableNameField( - label='Position', - required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') +class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm): + + class Meta(model_forms.InventoryItemForm.Meta): + exclude = ('name', 'label') -class InventoryItemCreateForm(ComponentCreateForm): - # Device is assigned by the model form - field_order = ('name_pattern', 'label_pattern') - +# +# Virtual chassis +# class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 838336e21..8f1285901 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel): def clean(self): super().clean() - # Validate rear port assignment - if self.rear_port.device != self.device: - raise ValidationError({ - "rear_port": f"Rear port ({self.rear_port}) must belong to the same device" - }) + if hasattr(self, 'rear_port'): - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError({ - "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " - f"{self.rear_port.name} has only {self.rear_port.positions} positions" - }) + # Validate rear port assignment + if self.rear_port.device != self.device: + raise ValidationError({ + "rear_port": f"Rear port ({self.rear_port}) must belong to the same device" + }) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError({ + "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " + f"{self.rear_port.name} has only {self.rear_port.positions} positions" + }) class RearPort(ModularComponentModel, CabledObjectModel): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index d34003ee5..dfc77b854 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -239,7 +239,7 @@ INTERFACE_BUTTONS = """
  • Inventory Item
  • {% endif %} {% if perms.dcim.add_interface %} -
  • Child Interface
  • +
  • Child Interface
  • {% endif %} {% if perms.ipam.add_l2vpntermination %}
  • L2VPN Termination
  • diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 53474314f..1cd75765a 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.choices import DeviceFaceChoices, DeviceStatusChoices +from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices from dcim.forms import * from dcim.models import * from utilities.testing import create_test_device @@ -129,10 +129,11 @@ class LabelTestCase(TestCase): """ interface_data = { 'device': self.device.pk, - 'name_pattern': 'eth[0-9]', - 'label_pattern': 'Interface[0-9]', + 'name': 'eth[0-9]', + 'label': 'Interface[0-9]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, } - form = DeviceComponentCreateForm(interface_data) + form = InterfaceCreateForm(interface_data) self.assertTrue(form.is_valid()) @@ -142,10 +143,11 @@ class LabelTestCase(TestCase): """ bad_interface_data = { 'device': self.device.pk, - 'name_pattern': 'eth[0-9]', - 'label_pattern': 'Interface[0-1]', + 'name': 'eth[0-9]', + 'label': 'Interface[0-1]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, } - form = DeviceComponentCreateForm(bad_interface_data) + form = InterfaceCreateForm(bad_interface_data) self.assertFalse(form.is_valid()) - self.assertIn('label_pattern', form.errors) + self.assertIn('label', form.errors) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a25267166..50b36e36d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1082,31 +1082,28 @@ front-ports: class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsolePortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') ConsolePortTemplate.objects.bulk_create(( - ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'), - ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'), - ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'), + ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'), + ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'), + ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Console Port Template X', 'type': ConsolePortTypeChoices.TYPE_RJ45, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Console Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Console Port Template [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, } @@ -1117,31 +1114,28 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ConsoleServerPortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') ConsoleServerPortTemplate.objects.bulk_create(( - ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'), - ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'), - ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'), + ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Console Server Port Template X', 'type': ConsolePortTypeChoices.TYPE_RJ45, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Console Server Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Console Server Port Template [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, } @@ -1152,24 +1146,21 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerPortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') PowerPortTemplate.objects.bulk_create(( - PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'), - PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'), - PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'), + PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Power Port Template X', 'type': PowerPortTypeChoices.TYPE_IEC_C14, 'maximum_draw': 100, @@ -1177,8 +1168,8 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Power Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Power Port Template [4-6]', 'type': PowerPortTypeChoices.TYPE_IEC_C14, 'maximum_draw': 100, 'allocated_draw': 50, @@ -1193,6 +1184,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = PowerOutletTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1220,7 +1212,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC cls.bulk_create_data = { 'device_type': devicetype.pk, - 'name_pattern': 'Power Outlet Template [4-6]', + 'name': 'Power Outlet Template [4-6]', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, 'power_port': powerports[0].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, @@ -1234,34 +1226,31 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = InterfaceTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') InterfaceTemplate.objects.bulk_create(( - InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'), - InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'), - InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'), + InterfaceTemplate(device_type=devicetype, name='Interface Template 1'), + InterfaceTemplate(device_type=devicetype, name='Interface Template 2'), + InterfaceTemplate(device_type=devicetype, name='Interface Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Interface Template X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'mgmt_only': True, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Interface Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Interface Template [4-6]', # Test that a label can be applied to each generated interface templates - 'label_pattern': 'Interface Template Label [3-5]', + 'label': 'Interface Template Label [3-5]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'mgmt_only': True, } @@ -1274,6 +1263,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = FrontPortTemplate + validation_excluded_fields = ('name', 'label', 'rear_port') @classmethod def setUpTestData(cls): @@ -1306,11 +1296,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas cls.bulk_create_data = { 'device_type': devicetype.pk, - 'name_pattern': 'Front Port [4-6]', + 'name': 'Front Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port_set': [ - '{}:1'.format(rp.pk) for rp in rearports[3:6] - ], + 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]], } cls.bulk_edit_data = { @@ -1320,32 +1308,29 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = RearPortTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') RearPortTemplate.objects.bulk_create(( - RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'), - RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'), - RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'), + RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Rear Port Template X', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 2, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Rear Port Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Rear Port Template [4-6]', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 2, } @@ -1357,30 +1342,27 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = ModuleBayTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') ModuleBayTemplate.objects.bulk_create(( - ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'), - ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'), - ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'), + ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Module Bay Template X', } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Module Bay Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Module Bay Template [4-6]', } cls.bulk_edit_data = { @@ -1390,30 +1372,27 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT) DeviceBayTemplate.objects.bulk_create(( - DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'), - DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'), - DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'), )) cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Device Bay Template X', } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Device Bay Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Device Bay Template [4-6]', } cls.bulk_edit_data = { @@ -1423,6 +1402,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = InventoryItemTemplate + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1431,30 +1411,25 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), ) Manufacturer.objects.bulk_create(manufacturers) - - devicetypes = ( - DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'), - ) - DeviceType.objects.bulk_create(devicetypes) + devicetype = DeviceType.objects.create(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1') inventory_item_templates = ( - InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]), - InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]), - InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturers[0]), ) for item in inventory_item_templates: item.save() cls.form_data = { - 'device_type': devicetypes[1].pk, + 'device_type': devicetype.pk, 'name': 'Inventory Item Template X', 'manufacturer': manufacturers[1].pk, } cls.bulk_create_data = { - 'device_type': devicetypes[1].pk, - 'name_pattern': 'Inventory Item Template [4-6]', + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template [4-6]', 'manufacturer': manufacturers[1].pk, } @@ -1912,6 +1887,7 @@ class ModuleTestCase( class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1935,9 +1911,9 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Console Port [4-6]', + 'name': 'Console Port [4-6]', # Test that a label can be applied to each generated console ports - 'label_pattern': 'Serial[3-5]', + 'label': 'Serial[3-5]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console port', 'tags': sorted([t.pk for t in tags]), @@ -1970,6 +1946,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsoleServerPort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -1993,7 +1970,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Console Server Port [4-6]', + 'name': 'Console Server Port [4-6]', 'type': ConsolePortTypeChoices.TYPE_RJ45, 'description': 'A console server port', 'tags': [t.pk for t in tags], @@ -2026,6 +2003,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerPort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2051,7 +2029,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Power Port [4-6]]', + 'name': 'Power Port [4-6]]', 'type': PowerPortTypeChoices.TYPE_IEC_C14, 'maximum_draw': 100, 'allocated_draw': 50, @@ -2088,6 +2066,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): model = PowerOutlet + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2119,7 +2098,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Power Outlet [4-6]', + 'name': 'Power Outlet [4-6]', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, @@ -2153,6 +2132,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = Interface + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2217,7 +2197,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Interface [4-6]', + 'name': 'Interface [4-6]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, 'bridge': interfaces[4].pk, @@ -2277,6 +2257,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort + validation_excluded_fields = ('name', 'label', 'rear_port') @classmethod def setUpTestData(cls): @@ -2312,11 +2293,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Front Port [4-6]', + 'name': 'Front Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port_set': [ - '{}:1'.format(rp.pk) for rp in rearports[3:6] - ], + 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]], 'description': 'New description', 'tags': [t.pk for t in tags], } @@ -2348,6 +2327,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = RearPort + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2372,7 +2352,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Rear Port [4-6]', + 'name': 'Rear Port [4-6]', 'type': PortTypeChoices.TYPE_8P8C, 'positions': 3, 'description': 'A rear port', @@ -2406,6 +2386,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ModuleBay + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2428,7 +2409,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Module Bay [4-6]', + 'name': 'Module Bay [4-6]', 'description': 'A module bay', 'tags': [t.pk for t in tags], } @@ -2447,6 +2428,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2472,7 +2454,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Device Bay [4-6]', + 'name': 'Device Bay [4-6]', 'description': 'A device bay', 'tags': [t.pk for t in tags], } @@ -2491,6 +2473,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): model = InventoryItem + validation_excluded_fields = ('name', 'label') @classmethod def setUpTestData(cls): @@ -2525,7 +2508,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, - 'name_pattern': 'Inventory Item [4-6]', + 'name': 'Inventory Item [4-6]', 'role': roles[1].pk, 'manufacturer': manufacturer.pk, 'parent': None, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6ee74377a..aee0cb384 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1120,9 +1120,8 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ConsolePortTemplateCreateView(generic.ComponentCreateView): queryset = ConsolePortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.ConsolePortTemplateCreateForm model_form = forms.ConsolePortTemplateForm - template_name = 'dcim/component_template_create.html' class ConsolePortTemplateEditView(generic.ObjectEditView): @@ -1155,9 +1154,8 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView): class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView): queryset = ConsoleServerPortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.ConsoleServerPortTemplateCreateForm model_form = forms.ConsoleServerPortTemplateForm - template_name = 'dcim/component_template_create.html' class ConsoleServerPortTemplateEditView(generic.ObjectEditView): @@ -1190,9 +1188,8 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerPortTemplateCreateView(generic.ComponentCreateView): queryset = PowerPortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.PowerPortTemplateCreateForm model_form = forms.PowerPortTemplateForm - template_name = 'dcim/component_template_create.html' class PowerPortTemplateEditView(generic.ObjectEditView): @@ -1225,9 +1222,8 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView): class PowerOutletTemplateCreateView(generic.ComponentCreateView): queryset = PowerOutletTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.PowerOutletTemplateCreateForm model_form = forms.PowerOutletTemplateForm - template_name = 'dcim/component_template_create.html' class PowerOutletTemplateEditView(generic.ObjectEditView): @@ -1260,9 +1256,8 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView): class InterfaceTemplateCreateView(generic.ComponentCreateView): queryset = InterfaceTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.InterfaceTemplateCreateForm model_form = forms.InterfaceTemplateForm - template_name = 'dcim/component_template_create.html' class InterfaceTemplateEditView(generic.ObjectEditView): @@ -1297,15 +1292,6 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView): queryset = FrontPortTemplate.objects.all() form = forms.FrontPortTemplateCreateForm model_form = forms.FrontPortTemplateForm - template_name = 'dcim/frontporttemplate_create.html' - - def initialize_forms(self, request): - form, model_form = super().initialize_forms(request) - - model_form.fields.pop('rear_port') - model_form.fields.pop('rear_port_position') - - return form, model_form class FrontPortTemplateEditView(generic.ObjectEditView): @@ -1338,9 +1324,8 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView): class RearPortTemplateCreateView(generic.ComponentCreateView): queryset = RearPortTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.RearPortTemplateCreateForm model_form = forms.RearPortTemplateForm - template_name = 'dcim/component_template_create.html' class RearPortTemplateEditView(generic.ObjectEditView): @@ -1375,8 +1360,6 @@ class ModuleBayTemplateCreateView(generic.ComponentCreateView): queryset = ModuleBayTemplate.objects.all() form = forms.ModuleBayTemplateCreateForm model_form = forms.ModuleBayTemplateForm - template_name = 'dcim/modulebaytemplate_create.html' - patterned_fields = ('name', 'label', 'position') class ModuleBayTemplateEditView(generic.ObjectEditView): @@ -1409,9 +1392,8 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView): class DeviceBayTemplateCreateView(generic.ComponentCreateView): queryset = DeviceBayTemplate.objects.all() - form = forms.ComponentTemplateCreateForm + form = forms.DeviceBayTemplateCreateForm model_form = forms.DeviceBayTemplateForm - template_name = 'dcim/component_template_create.html' class DeviceBayTemplateEditView(generic.ObjectEditView): @@ -1444,9 +1426,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView): class InventoryItemTemplateCreateView(generic.ComponentCreateView): queryset = InventoryItemTemplate.objects.all() - form = forms.ModularComponentTemplateCreateForm + form = forms.InventoryItemTemplateCreateForm model_form = forms.InventoryItemTemplateForm - template_name = 'dcim/inventoryitemtemplate_create.html' def alter_object(self, instance, request): # Set component (if any) @@ -1874,14 +1855,13 @@ class ConsolePortView(generic.ObjectView): class ConsolePortCreateView(generic.ComponentCreateView): queryset = ConsolePort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.ConsolePortCreateForm model_form = forms.ConsolePortForm class ConsolePortEditView(generic.ObjectEditView): queryset = ConsolePort.objects.all() form = forms.ConsolePortForm - template_name = 'dcim/device_component_edit.html' class ConsolePortDeleteView(generic.ObjectDeleteView): @@ -1933,14 +1913,13 @@ class ConsoleServerPortView(generic.ObjectView): class ConsoleServerPortCreateView(generic.ComponentCreateView): queryset = ConsoleServerPort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.ConsoleServerPortCreateForm model_form = forms.ConsoleServerPortForm class ConsoleServerPortEditView(generic.ObjectEditView): queryset = ConsoleServerPort.objects.all() form = forms.ConsoleServerPortForm - template_name = 'dcim/device_component_edit.html' class ConsoleServerPortDeleteView(generic.ObjectDeleteView): @@ -1992,14 +1971,13 @@ class PowerPortView(generic.ObjectView): class PowerPortCreateView(generic.ComponentCreateView): queryset = PowerPort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.PowerPortCreateForm model_form = forms.PowerPortForm class PowerPortEditView(generic.ObjectEditView): queryset = PowerPort.objects.all() form = forms.PowerPortForm - template_name = 'dcim/device_component_edit.html' class PowerPortDeleteView(generic.ObjectDeleteView): @@ -2051,14 +2029,13 @@ class PowerOutletView(generic.ObjectView): class PowerOutletCreateView(generic.ComponentCreateView): queryset = PowerOutlet.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.PowerOutletCreateForm model_form = forms.PowerOutletForm class PowerOutletEditView(generic.ObjectEditView): queryset = PowerOutlet.objects.all() form = forms.PowerOutletForm - template_name = 'dcim/device_component_edit.html' class PowerOutletDeleteView(generic.ObjectDeleteView): @@ -2154,42 +2131,13 @@ class InterfaceView(generic.ObjectView): class InterfaceCreateView(generic.ComponentCreateView): queryset = Interface.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.InterfaceCreateForm model_form = forms.InterfaceForm - # template_name = 'dcim/interface_create.html' - - # TODO: Figure out what to do with this - # def post(self, request): - # """ - # Override inherited post() method to handle request to assign newly created - # interface objects (first object) to an IP Address object. - # """ - # form = self.form(request.POST, initial=request.GET) - # new_objs = self.validate_form(request, form) - # - # if form.is_valid() and not form.errors: - # if '_addanother' in request.POST: - # return redirect(request.get_full_path()) - # elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \ - # request.user.has_perm('ipam.add_ipaddress'): - # first_obj = new_objs[0].pk - # return redirect( - # f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}' - # ) - # else: - # return redirect(self.get_return_url(request)) - # - # return render(request, self.template_name, { - # 'obj_type': self.queryset.model._meta.verbose_name, - # 'form': form, - # 'return_url': self.get_return_url(request), - # }) class InterfaceEditView(generic.ObjectEditView): queryset = Interface.objects.all() form = forms.InterfaceForm - template_name = 'dcim/interface_edit.html' class InterfaceDeleteView(generic.ObjectDeleteView): @@ -2244,19 +2192,10 @@ class FrontPortCreateView(generic.ComponentCreateView): form = forms.FrontPortCreateForm model_form = forms.FrontPortForm - def initialize_forms(self, request): - form, model_form = super().initialize_forms(request) - - model_form.fields.pop('rear_port') - model_form.fields.pop('rear_port_position') - - return form, model_form - class FrontPortEditView(generic.ObjectEditView): queryset = FrontPort.objects.all() form = forms.FrontPortForm - template_name = 'dcim/device_component_edit.html' class FrontPortDeleteView(generic.ObjectDeleteView): @@ -2308,14 +2247,13 @@ class RearPortView(generic.ObjectView): class RearPortCreateView(generic.ComponentCreateView): queryset = RearPort.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.RearPortCreateForm model_form = forms.RearPortForm class RearPortEditView(generic.ObjectEditView): queryset = RearPort.objects.all() form = forms.RearPortForm - template_name = 'dcim/device_component_edit.html' class RearPortDeleteView(generic.ObjectDeleteView): @@ -2369,13 +2307,11 @@ class ModuleBayCreateView(generic.ComponentCreateView): queryset = ModuleBay.objects.all() form = forms.ModuleBayCreateForm model_form = forms.ModuleBayForm - patterned_fields = ('name', 'label', 'position') class ModuleBayEditView(generic.ObjectEditView): queryset = ModuleBay.objects.all() form = forms.ModuleBayForm - template_name = 'dcim/device_component_edit.html' class ModuleBayDeleteView(generic.ObjectDeleteView): @@ -2423,14 +2359,13 @@ class DeviceBayView(generic.ObjectView): class DeviceBayCreateView(generic.ComponentCreateView): queryset = DeviceBay.objects.all() - form = forms.DeviceComponentCreateForm + form = forms.DeviceBayCreateForm model_form = forms.DeviceBayForm class DeviceBayEditView(generic.ObjectEditView): queryset = DeviceBay.objects.all() form = forms.DeviceBayForm - template_name = 'dcim/device_component_edit.html' class DeviceBayDeleteView(generic.ObjectDeleteView): @@ -2552,7 +2487,6 @@ class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm - template_name = 'dcim/inventoryitem_create.html' def alter_object(self, instance, request): # Set component (if any) @@ -2736,7 +2670,6 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView): filterset = filtersets.DeviceFilterSet table = tables.DeviceTable default_return_url = 'dcim:device_list' - patterned_fields = ('name', 'label', 'position') class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 7340ea2a0..f0741af2c 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -774,7 +774,6 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): model_form = None filterset = None table = None - patterned_fields = ('name', 'label') def get_required_permission(self): return f'dcim.add_{self.queryset.model._meta.model_name}' @@ -804,23 +803,25 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): new_components = [] data = deepcopy(form.cleaned_data) + replication_data = { + field: data.pop(field) for field in form.replication_fields + } try: with transaction.atomic(): for obj in data['pk']: - pattern_count = len(data[f'{self.patterned_fields[0]}_pattern']) + pattern_count = len(replication_data[form.replication_fields[0]]) for i in range(pattern_count): component_data = { self.parent_field: obj.pk } - - for field_name in self.patterned_fields: - if data.get(f'{field_name}_pattern'): - component_data[field_name] = data[f'{field_name}_pattern'][i] - component_data.update(data) + for field, values in replication_data.items(): + if values: + component_data[field] = values[i] + component_form = self.model_form(component_data) if component_form.is_valid(): instance = component_form.save() @@ -829,7 +830,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): else: for field, errors in component_form.errors.as_data().items(): for e in errors: - form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + form.add_error(field, '{}: {}'.format(obj, ', '.join(e))) # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 7617e0402..a56a832b6 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -538,10 +538,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - template_name = 'dcim/component_create.html' + template_name = 'generic/object_edit.html' form = None model_form = None - patterned_fields = ('name', 'label') def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -549,44 +548,38 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): def alter_object(self, instance, request): return instance - def initialize_forms(self, request): + def initialize_form(self, request): data = request.POST if request.method == 'POST' else None initial_data = normalize_querydict(request.GET) - form = self.form(data=data, initial=request.GET) - model_form = self.model_form(data=data, initial=initial_data) + form = self.form(data=data, initial=initial_data) - # These fields will be set from the pattern values - for field_name in self.patterned_fields: - model_form.fields[field_name].widget = HiddenInput() - - return form, model_form + return form def get(self, request): - form, model_form = self.initialize_forms(request) + form = self.initialize_form(request) instance = self.alter_object(self.queryset.model(), request) return render(request, self.template_name, { 'object': instance, - 'replication_form': form, - 'form': model_form, + 'form': form, 'return_url': self.get_return_url(request), }) def post(self, request): logger = logging.getLogger('netbox.views.ComponentCreateView') - form, model_form = self.initialize_forms(request) + form = self.initialize_form(request) instance = self.alter_object(self.queryset.model(), request) if form.is_valid(): new_components = [] data = deepcopy(request.POST) - pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern']) + pattern_count = len(form.cleaned_data[self.form.replication_fields[0]]) for i in range(pattern_count): - for field_name in self.patterned_fields: - if form.cleaned_data.get(f'{field_name}_pattern'): - data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i] + for field_name in self.form.replication_fields: + if form.cleaned_data.get(field_name): + data[field_name] = form.cleaned_data[field_name][i] if hasattr(form, 'get_iterative_data'): data.update(form.get_iterative_data(i)) @@ -626,7 +619,6 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): return render(request, self.template_name, { 'object': instance, - 'replication_form': form, - 'form': model_form, + 'form': form, 'return_url': self.get_return_url(request), }) diff --git a/netbox/templates/dcim/component_template_create.html b/netbox/templates/dcim/component_template_create.html deleted file mode 100644 index d164db872..000000000 --- a/netbox/templates/dcim/component_template_create.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} - {% if form.module_type %} -
    -
    - -
    -
    -
    -
    - {% render_field replication_form.device_type %} -
    -
    - {% render_field replication_form.module_type %} -
    -
    - {% else %} - {% render_field replication_form.device_type %} - {% endif %} - {% block replication_fields %} - {% render_field replication_form.name_pattern %} - {% render_field replication_form.label_pattern %} - {% endblock replication_fields %} - {{ block.super }} -{% endblock form %} diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html deleted file mode 100644 index 44b93d870..000000000 --- a/netbox/templates/dcim/device_component_edit.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    - {% if form.instance.device %} -
    - -
    - -
    -
    - {% endif %} - {% render_form form %} -
    -{% endblock form %} diff --git a/netbox/templates/dcim/frontporttemplate_create.html b/netbox/templates/dcim/frontporttemplate_create.html deleted file mode 100644 index 50e9d355c..000000000 --- a/netbox/templates/dcim/frontporttemplate_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% render_field replication_form.rear_port_set %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html deleted file mode 100644 index be910f143..000000000 --- a/netbox/templates/dcim/inventoryitem_create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'dcim/component_create.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% if object.component %} -
    - -
    - -
    -
    - {% endif %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitemtemplate_create.html b/netbox/templates/dcim/inventoryitemtemplate_create.html deleted file mode 100644 index 9180cf6ab..000000000 --- a/netbox/templates/dcim/inventoryitemtemplate_create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% if object.component %} -
    - -
    - -
    -
    - {% endif %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/modulebaytemplate_create.html b/netbox/templates/dcim/modulebaytemplate_create.html deleted file mode 100644 index 74323ac4b..000000000 --- a/netbox/templates/dcim/modulebaytemplate_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% render_field replication_form.position_pattern %} -{% endblock replication_fields %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 4ce270b30..56e4f5a32 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -59,9 +59,11 @@ Context: {# Render grouped fields according to Form #} {% for group, fields in form.fieldsets %}
    -
    -
    {{ group }}
    -
    + {% if group %} +
    +
    {{ group }}
    +
    + {% endif %} {% for name in fields %} {% with field=form|getfield:name %} {% if not field.field.widget.is_hidden %} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html deleted file mode 100644 index efb138954..000000000 --- a/netbox/templates/virtualization/vminterface_edit.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} - {# Render hidden fields #} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - -
    -
    -
    Interface
    -
    - {% if form.instance.virtual_machine %} -
    - -
    - -
    -
    - {% endif %} - {% render_field form.name %} - {% render_field form.description %} - {% render_field form.tags %} -
    - -
    -
    -
    Addressing
    -
    - {% render_field form.vrf %} - {% render_field form.mac_address %} -
    - -
    -
    -
    Operation
    -
    - {% render_field form.mtu %} - {% render_field form.enabled %} -
    - -
    -
    -
    Related Interfaces
    -
    - {% render_field form.parent %} - {% render_field form.bridge %} -
    - -
    -
    -
    802.1Q Switching
    -
    - {% render_field form.mode %} - {% render_field form.vlan_group %} - {% render_field form.untagged_vlan %} - {% render_field form.tagged_vlans %} -
    - - {% if form.custom_fields %} -
    -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    - {% endif %} -{% endblock %} diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py index 214775f03..fca370c26 100644 --- a/netbox/utilities/forms/fields/expandable.py +++ b/netbox/utilities/forms/fields/expandable.py @@ -22,7 +22,7 @@ class ExpandableNameField(forms.CharField): if not self.help_text: self.help_text = """ Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Example: [ge,xe]-0/0/[0-9] + are not supported (example: [ge,xe]-0/0/[0-9]). """ def to_python(self, value): diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 7fa9f66bc..93cb88088 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -466,6 +466,7 @@ class ViewTestCases: """ bulk_create_count = 3 bulk_create_data = {} + validation_excluded_fields = [] @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_multiple_objects_without_permission(self): @@ -500,7 +501,7 @@ class ViewTestCases: self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_multiple_objects_with_constrained_permission(self): @@ -532,7 +533,7 @@ class ViewTestCases: self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) class BulkImportObjectsViewTestCase(ModelViewTestCase): """ diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py index 6cf7c0d7c..03997f88d 100644 --- a/netbox/virtualization/forms/bulk_create.py +++ b/netbox/virtualization/forms/bulk_create.py @@ -13,7 +13,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput() ) - name_pattern = ExpandableNameField( + name = ExpandableNameField( label='Name' ) @@ -27,4 +27,4 @@ class VMInterfaceBulkCreateForm( form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): - pass + replication_fields = ('name',) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index fca9c6b56..268afb9bb 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -5,7 +5,6 @@ from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm from dcim.forms.models import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup -from extras.models import Tag from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -278,6 +277,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all() + ) parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, @@ -338,7 +340,6 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'virtual_machine': forms.HiddenInput(), 'mode': StaticSelect() } labels = { @@ -347,3 +348,10 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of VirtualMachine when editing an existing instance + if self.instance.pk: + self.fields['virtual_machine'].disabled = True diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index feab3bb3a..79457a56e 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,17 +1,14 @@ -from django import forms - -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, ExpandableNameField -from .models import VirtualMachine +from utilities.forms import ExpandableNameField +from .models import VMInterfaceForm __all__ = ( 'VMInterfaceCreateForm', ) -class VMInterfaceCreateForm(BootstrapMixin, forms.Form): - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all() - ) - name_pattern = ExpandableNameField( - label='Name' - ) +class VMInterfaceCreateForm(VMInterfaceForm): + name = ExpandableNameField() + replication_fields = ('name',) + + class Meta(VMInterfaceForm.Meta): + exclude = ('name',) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 01d4394f3..d00ceb5a2 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -251,6 +251,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = VMInterface + validation_excluded_fields = ('name',) @classmethod def setUpTestData(cls): @@ -290,10 +291,10 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'virtual_machine': virtualmachines[1].pk, + 'virtual_machine': virtualmachines[0].pk, 'name': 'Interface X', 'enabled': False, - 'bridge': interfaces[3].pk, + 'bridge': interfaces[1].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -306,7 +307,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'virtual_machine': virtualmachines[1].pk, - 'name_pattern': 'Interface [4-6]', + 'name': 'Interface [4-6]', 'enabled': False, 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 5b26f8503..611725d62 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -451,13 +451,11 @@ class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm - patterned_fields = ('name',) class VMInterfaceEditView(generic.ObjectEditView): queryset = VMInterface.objects.all() form = forms.VMInterfaceForm - template_name = 'virtualization/vminterface_edit.html' class VMInterfaceDeleteView(generic.ObjectDeleteView): From f97eb99950819789d10866981a8aead250905126 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 15 Sep 2022 10:14:16 -0400 Subject: [PATCH 38/42] Changelog for #10247, #10258, #10305, #10362 --- docs/release-notes/version-3.3.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c5ca3d5be..c249d9874 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -11,14 +11,18 @@ ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components * [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables +* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules * [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field * [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links +* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection * [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import +* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination --- From e9a91455e8827fffaab7f10e9938bce3022b9ba3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 15 Sep 2022 12:55:21 -0400 Subject: [PATCH 39/42] #10359: Add region and site group columns to the devices table --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/tables/devices.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c5ca3d5be..6c7d5d291 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -7,6 +7,7 @@ * [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` * [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI +* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table ### Bug Fixes diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a7cdf4b9f..142c7ef67 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -143,6 +143,15 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): template_code=DEVICE_LINK ) status = columns.ChoiceFieldColumn() + region = tables.Column( + accessor=Accessor('site__region'), + linkify=True + ) + site_group = tables.Column( + accessor=Accessor('site__group'), + linkify=True, + verbose_name='Site Group' + ) site = tables.Column( linkify=True ) @@ -203,9 +212,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'site', 'region', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', - 'contacts', 'tags', 'created', 'last_updated', + 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', + 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', + 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', From e05696dfcc8f3c711f1435795ea9676638fc12b8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 15 Sep 2022 13:17:04 -0400 Subject: [PATCH 40/42] Fixes #10337: Display SSO links when local authentication fails --- docs/release-notes/version-3.3.md | 1 + netbox/templates/login.html | 20 ++++++++++---------- netbox/users/views.py | 27 +++++++++++++++------------ 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index ba6e4a06e..3f4272f95 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -23,6 +23,7 @@ * [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection * [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import +* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails * [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination --- diff --git a/netbox/templates/login.html b/netbox/templates/login.html index ea5cfc3e5..66b519671 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -13,6 +13,16 @@
    {% endif %} + {# Login form errors #} + {% if form.non_field_errors %} + + {% endif %} + {# Login form #}