From a889b3a4be06ea44522b1fbf2476b213128f20a5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Apr 2022 15:04:44 -0400 Subject: [PATCH 001/124] Add title for missing okta-openidconnect backend --- netbox/netbox/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 6367d6d70..a13e8d192 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -39,6 +39,7 @@ AUTH_BACKEND_ATTRS = { 'keycloak': ('Keycloak', None), 'microsoft-graph': ('Microsoft Graph', 'microsoft'), 'okta': ('Okta', None), + 'okta-openidconnect': ('Okta (OIDC)', None), 'salesforce-oauth2': ('Salesforce', 'salesforce'), } From c21db0ff6ad768a8bf232b3f1769b69d861b87a4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Apr 2022 16:03:36 -0400 Subject: [PATCH 002/124] Closes #9137: Add SSO configuration guide for Okta --- docs/administration/authentication/okta.md | 70 ++++++++++++++++++ .../authentication/netbox_okta_login.png | Bin 0 -> 16859 bytes .../okta_create_app_registration.png | Bin 0 -> 102336 bytes .../okta_integration_parameters.png | Bin 0 -> 39346 bytes .../authentication/okta_login_portal.png | Bin 0 -> 12277 bytes .../okta_web_app_integration.png | Bin 0 -> 75645 bytes mkdocs.yml | 1 + 7 files changed, 71 insertions(+) create mode 100644 docs/administration/authentication/okta.md create mode 100644 docs/media/authentication/netbox_okta_login.png create mode 100644 docs/media/authentication/okta_create_app_registration.png create mode 100644 docs/media/authentication/okta_integration_parameters.png create mode 100644 docs/media/authentication/okta_login_portal.png create mode 100644 docs/media/authentication/okta_web_app_integration.png diff --git a/docs/administration/authentication/okta.md b/docs/administration/authentication/okta.md new file mode 100644 index 000000000..ff552d730 --- /dev/null +++ b/docs/administration/authentication/okta.md @@ -0,0 +1,70 @@ +# Okta + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Okta](https://www.okta.com/) as an authentication backend. + +## Okta Configuration + +!!! tip "Okta developer account" + Okta offers free developer accounts at . + +### 1. Create a test user (optional) + +Create a new user in the Okta admin portal to be used for testing. You can skip this step if you already have a suitable account created. + +### 2. Create an app registration + +Within the Okta administration dashboard, navigate to **Applications > Applications**, and click the "Create App Integration" button. Select "OIDC" as the sign-in method, and "Web application" for the application type. + +![Create an app registration](../../media/authentication/okta_create_app_registration.png) + +On the next page, give the app integration a name (e.g. "NetBox") and specify the sign-in and sign-out URIs. These URIs should follow the formats below: + +* Sign-in URI: `https://{netbox}/oauth/complete/okta-openidconnect/` +* Sign-out URI: `https://{netbox}/oauth/disconnect/okta-openidconnect/` + +![Web app integration](../../media/authentication/okta_web_app_integration.png) + +Under "Assignments," select the controlled access setting most appropriate for your organization. Click "Save" to complete the creation. + +Once finished, note the following parameters. These will be used to configured NetBox. + +* Client ID +* Client secret +* Okta domain + +![Okta integration parameters](../../media/authentication/okta_integration_parameters.png) + +## NetBox Configuration + +### 1. Enter configuration parameters + +Enter the following configuration parameters in `configuration.py`, substituting your own values: + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.okta_openidconnect.OktaOpenIdConnect' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_KEY = '{Client ID}' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_SECRET = '{Client secret}' +SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL = 'https://{Okta domain}/oauth2/' +``` + +### 2. Restart NetBox + +Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below: + +```no-highlight +sudo systemctl restart netbox +``` + +## Testing + +Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Okta. Click that link. + +![NetBox Okta login form](../../media/authentication/netbox_okta_login.png) + +You should be redirected to Okta's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account. + +![Okta login portal](../../media/authentication/okta_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the Okta user. You can verify this by navigating to your profile (using the button at top right). + +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. diff --git a/docs/media/authentication/netbox_okta_login.png b/docs/media/authentication/netbox_okta_login.png new file mode 100644 index 0000000000000000000000000000000000000000..34df39cba17d2f8dfcc04f19d8f968d6037e7f56 GIT binary patch literal 16859 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 z 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
L+ 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 MT85|dPT55K57JvFy#N3J literal 0 HcmV?d00001 diff --git a/docs/media/authentication/okta_integration_parameters.png b/docs/media/authentication/okta_integration_parameters.png new file mode 100644 index 0000000000000000000000000000000000000000..b5d7794ec1296b1dbfead0c265e566d5bc7b35da GIT binary patch literal 39346 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 WGlwlr?b9g~ytFkhY80y74E`_INrwIa literal 0 HcmV?d00001 diff --git a/docs/media/authentication/okta_login_portal.png b/docs/media/authentication/okta_login_portal.png new file mode 100644 index 0000000000000000000000000000000000000000..48b62d3e08603e39eaa6f86e70333a77495e4b20 GIT binary patch literal 12277 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 KIs4M}2mb*>H(j;> literal 0 HcmV?d00001 diff --git a/docs/media/authentication/okta_web_app_integration.png b/docs/media/authentication/okta_web_app_integration.png new file mode 100644 index 0000000000000000000000000000000000000000..6587127c6eb3ca08be24f70cf0771a4d4701542d GIT binary patch literal 75645 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 literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index 7db4d24d7..f66700095 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,6 +121,7 @@ nav: - Authentication: - Overview: 'administration/authentication/overview.md' - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' + - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' From 69a1cc8759bd1d574ceb2046affcbf2fa8a62150 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 15 Apr 2022 20:36:40 +0000 Subject: [PATCH 003/124] Closes #8998: Add site group filter to racks --- netbox/dcim/filtersets.py | 13 +++++++++++++ netbox/dcim/forms/filtersets.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0f4e7cf7e..4910e794d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -346,6 +346,19 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='rack__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='rack__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d5335947a..079927ea3 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -210,7 +210,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte model = Rack fieldsets = ( (None, ('q', 'tag')), - ('Location', ('region_id', 'site_id', 'location_id')), + ('Location', ('region_id', 'site_id', 'site_group_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte }, label=_('Site') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), required=False, @@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('User', ('user_id',)), - ('Rack', ('region_id', 'site_id', 'location_id')), + ('Rack', ('region_id', 'site_id', 'site_group_id', 'location_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( @@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Site') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.prefetch_related('site'), required=False, From bc2491e6b767c929435c51776bf793e32b7b1d7b Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 15 Apr 2022 21:50:24 +0000 Subject: [PATCH 004/124] Closes #8894: Add first and last name to APISelect widget if set --- netbox/users/api/nested_serializers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index df9af0f19..3b4959a1e 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -23,11 +23,17 @@ class NestedGroupSerializer(WritableNestedSerializer): class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') + display = serializers.SerializerMethodField(read_only=True) class Meta: model = User fields = ['id', 'url', 'display', 'username'] + def get_display(self, obj): + if obj.first_name and obj.last_name: + return f"{obj.username} ({obj.first_name} {obj.last_name})" + return obj.username + class NestedTokenSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') From 1636508a6ac8df6b93d0ea5c621c174f605fd47a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Apr 2022 08:36:41 -0400 Subject: [PATCH 005/124] Fixes #9156: Fix loading UserConfig data from fixtures --- docs/release-notes/version-3.2.md | 1 + netbox/users/models.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 97cedf0f5..339bc1061 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later +* [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures --- diff --git a/netbox/users/models.py b/netbox/users/models.py index 722ec5ba6..23068442e 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -173,11 +173,11 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) -def create_userconfig(instance, created, **kwargs): +def create_userconfig(instance, created, raw=False, **kwargs): """ - Automatically create a new UserConfig when a new User is created. + Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture. """ - if created: + if created and not raw: config = get_config() UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save() From 671e1aed9fba96e47377a003be362cce73c0082d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Apr 2022 08:43:46 -0400 Subject: [PATCH 006/124] Fixes #9151: Child prefix counts not annotated on aggregates list under RIR view --- docs/release-notes/version-3.2.md | 1 + netbox/ipam/views.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 339bc1061..d573222ca 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later +* [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view * [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures --- diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 41bef2527..57a682c94 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -158,8 +158,8 @@ class RIRView(generic.ObjectView): queryset = RIR.objects.all() def get_extra_context(self, request, instance): - aggregates = Aggregate.objects.restrict(request.user, 'view').filter( - rir=instance + aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate( + child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) aggregates_table.configure(request) From 41244dc677a5989c4d3d692b8048495fe6a3c300 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Apr 2022 08:56:28 -0400 Subject: [PATCH 007/124] Closes #9152: Annotate related object type under custom field view --- docs/release-notes/version-3.2.md | 4 ++++ netbox/templates/extras/customfield.html | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d573222ca..ab3a5528f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,10 @@ ## v3.2.2 (FUTURE) +### Enhancements + +* [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view + ### Bug Fixes * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 9be7a485a..e8c3df460 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -21,7 +21,10 @@ Type - {{ object.get_type_display }} + + {{ object.get_type_display }} + {% if object.object_type %}({{ object.object_type.model|bettertitle }}){% endif %} + Description From 118bf5152c17eaa8fe044269af7de65536ec944b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Apr 2022 09:02:03 -0400 Subject: [PATCH 008/124] Fixes #9132: Limit location options by selected site when creating a wireless link --- docs/release-notes/version-3.2.md | 1 + netbox/wireless/forms/models.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ab3a5528f..e1ad8e6bc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ ### Bug Fixes +* [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view * [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 6d7dc84a9..d1012ba59 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -105,6 +105,9 @@ class WirelessLinkForm(NetBoxModelForm): ) location_a = DynamicModelChoiceField( queryset=Location.objects.all(), + query_params={ + 'site_id': '$site_a', + }, required=False, label='Location', initial_params={ @@ -142,6 +145,9 @@ class WirelessLinkForm(NetBoxModelForm): ) location_b = DynamicModelChoiceField( queryset=Location.objects.all(), + query_params={ + 'site_id': '$site_b', + }, required=False, label='Location', initial_params={ From d4f1cb5d6a3bfd505c1fc1e3d98c39d9b0fad283 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Apr 2022 09:39:36 -0400 Subject: [PATCH 009/124] Fixes #9158: Do not list tags field for CSV forms which do not support tag assignment --- docs/release-notes/version-3.2.md | 1 + netbox/netbox/forms/base.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e1ad8e6bc..6037a171f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,7 @@ * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view * [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures +* [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment --- diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index c842c6c06..0e232af1d 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -61,6 +61,8 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm): """ Base form for creating a NetBox objects from CSV data. Used for bulk importing. """ + tags = None # Temporary fix in lieu of tag import support (see #9158) + def _get_form_field(self, customfield): return customfield.to_form_field(for_csv_import=True) From a3805fe04dd310d633c5f0f5cbf808429bef7995 Mon Sep 17 00:00:00 2001 From: minitriga Date: Mon, 18 Apr 2022 18:07:41 +0100 Subject: [PATCH 010/124] Closes #9060: Implement modulebay, iventory items and device bay filters (#9146) * Closes #9060: Implement modulebay, iventory items and device bay filters * add blank line --- netbox/dcim/filtersets.py | 7 +++++++ netbox/dcim/forms/filtersets.py | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0f4e7cf7e..54f533a7f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -435,6 +435,10 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): method='_device_bays', label='Has device bays', ) + inventory_items = django_filters.BooleanFilter( + method='_inventory_items', + label='Has inventory items', + ) class Meta: model = DeviceType @@ -479,6 +483,9 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): def _device_bays(self, queryset, name, value): return queryset.exclude(devicebaytemplates__isnull=value) + def _inventory_items(self, queryset, name, value): + return queryset.exclude(inventoryitemtemplates__isnull=value) + class ModuleTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d5335947a..7f30941a2 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -331,7 +331,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', + 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', )), ) manufacturer_id = DynamicModelMultipleChoiceField( @@ -392,6 +392,27 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + device_bays = forms.NullBooleanField( + required=False, + label='Has device bays', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + module_bays = forms.NullBooleanField( + required=False, + label='Has module bays', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + inventory_items = forms.NullBooleanField( + required=False, + label='Has inventory items', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) From bb99cee48abca3f1f3977deb2e7486503ef52501 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Apr 2022 13:14:30 -0400 Subject: [PATCH 011/124] Changelog & test for #9060 --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/tests/test_filtersets.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6037a171f..acbb68fad 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,7 @@ ### Enhancements +* [#9060](https://github.com/netbox-community/netbox/issues/9060) - Add device type filters for device bays, module bays, and inventory items * [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view ### Bug Fixes diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2e2c3baf7..8480c97bf 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -698,6 +698,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'), DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'), )) + # Assigned DeviceType must have parent subdevice_role + inventory_item = InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 1') + inventory_item.save() def test_model(self): params = {'model': ['Model 1', 'Model 2']} @@ -784,6 +787,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'module_bays': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_inventory_items(self): + params = {'inventory_items': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'inventory_items': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() From d22f9000d6ee103da81de5d8221ca4418be54a3a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 19 Apr 2022 10:00:41 -0400 Subject: [PATCH 012/124] Add troubleshooting section to Azure AD guide --- .../authentication/microsoft-azure-ad.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/administration/authentication/microsoft-azure-ad.md b/docs/administration/authentication/microsoft-azure-ad.md index b2de148ac..ee24e8232 100644 --- a/docs/administration/authentication/microsoft-azure-ad.md +++ b/docs/administration/authentication/microsoft-azure-ad.md @@ -75,5 +75,14 @@ If successful, you will be redirected back to the NetBox UI, and will be logged This user account has been replicated locally to NetBox, and can now be assigned groups and permissions within the NetBox admin UI. -!!! note "Troubleshooting" - If you are redirected to the NetBox UI after authenticating, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration. +## Troubleshooting + +### Redirect URI does not Match + +Azure requires that the authenticating client request a redirect URI that matches what you've configured for the app in step two. This URI **must** begin with `https://` (unless using `localhost` for the domain). + +If Azure complains that the requested URI starts with `http://` (not HTTPS), it's likely that your HTTP server is misconfigured or sitting behind a load balancer, so NetBox is not aware that HTTPS is being use. To force the use of an HTTPS redirect URI, set `SOCIAL_AUTH_REDIRECT_IS_HTTPS = True` in `configuration.py` per the [python-social-auth docs](https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html#processing-redirects-and-urlopen). + +### Not Logged in After Authenticating + +If you are redirected to the NetBox UI after authenticating successfully, but are _not_ logged in, double-check the configured backend and app registration. The instructions in this guide pertain only to the `azuread.AzureADOAuth2` backend using a single-tenant app registration. From 8315883db97286c2013c096fe5ed870b930be616 Mon Sep 17 00:00:00 2001 From: Kevin Meijer <640545+WarriorXK@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:11:31 +0200 Subject: [PATCH 013/124] Adds Ubiquiti SmartPower to the power port types (#9193) Co-authored-by: Kevin Meijer --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b0aa1c60c..e369201b4 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -349,6 +349,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32' TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' + TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' @@ -464,6 +465,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), + (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), From a91c46b4c0d61841321f8e331979a09d092380bf Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 22 Apr 2022 20:33:46 +0200 Subject: [PATCH 014/124] UI: Fix apiSelect scrolling while zoomed in chrome --- netbox/project-static/dist/netbox.js | Bin 375381 -> 375393 bytes netbox/project-static/dist/netbox.js.map | Bin 344709 -> 344719 bytes .../src/select/api/apiSelect.ts | 3 ++- 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 1a7581a6cf8d09d3332d41048c716065f9c08d39..acd1abbf28c867420280fb8364a550d7f54c9ec4 100644 GIT binary patch delta 44 zcmccmMeN}hv4$4L7N!>F7M3ln=~3Lii6t3&X*v1%MHD AS^xk5 delta 40 wcmaF(MeOPqv4$4L7N!>F7M3ln=~2@sMzM-;7AF_w=j4Rs7i^yp#dV)5ZD?U^VQOLC!m{-bud}g}j=Q6yua2jqv)A Date: Fri, 22 Apr 2022 20:42:29 +0200 Subject: [PATCH 015/124] Correct custom validators docs --- docs/customization/custom-validation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 9e01f8bb6..f88cd309b 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -105,11 +105,11 @@ from my_validators import Validator1, Validator2, Validator3 CUSTOM_VALIDATORS = { 'dcim.site': ( - Validator1, - Validator2, + Validator1(), + Validator2(), ), 'dcim.device': ( - Validator3, + Validator3(), ) } ``` From 84e415625968592106dd693094da4055df11846b Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 22 Apr 2022 21:21:01 +0200 Subject: [PATCH 016/124] Add lock around script loading to prevent race condition --- netbox/extras/scripts.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4eacddbeb..fc853a7a3 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -5,6 +5,7 @@ import os import pkgutil import sys import traceback +import threading from collections import OrderedDict import yaml @@ -42,6 +43,8 @@ __all__ = [ 'TextVar', ] +lock = threading.Lock() + # # Script variables @@ -491,11 +494,14 @@ def get_scripts(use_names=False): # Iterate through all modules within the scripts path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): - # Remove cached module to ensure consistency with filesystem - if module_name in sys.modules: - del sys.modules[module_name] + # Use a lock as removing and loading modules is not thread safe + with lock: + # Remove cached module to ensure consistency with filesystem + if module_name in sys.modules: + del sys.modules[module_name] + + module = importer.find_module(module_name).load_module(module_name) - module = importer.find_module(module_name).load_module(module_name) if use_names and hasattr(module, 'name'): module_name = module.name module_scripts = OrderedDict() From e63a1913733d7bafbf6cf69efa771a5da7d3d476 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 08:23:21 -0400 Subject: [PATCH 017/124] Closes #9214: Linkify cluster counts in cluster type & group tables --- docs/release-notes/version-3.2.md | 3 +++ netbox/virtualization/tables/clusters.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index acbb68fad..b781a09fe 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,9 +6,12 @@ * [#9060](https://github.com/netbox-community/netbox/issues/9060) - Add device type filters for device bays, module bays, and inventory items * [#9152](https://github.com/netbox-community/netbox/issues/9152) - Annotate related object type under custom field view +* [#9192](https://github.com/netbox-community/netbox/issues/9192) - Add Ubiquiti SmartPower connector type +* [#9214](https://github.com/netbox-community/netbox/issues/9214) - Linkify cluster counts in cluster type & group tables ### Bug Fixes +* [#8941](https://github.com/netbox-community/netbox/issues/8941) - Fix dynamic dropdown behavior when browser is zoomed * [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index 893d3c641..c9f87105d 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -14,7 +14,9 @@ class ClusterTypeTable(NetBoxTable): name = tables.Column( linkify=True ) - cluster_count = tables.Column( + cluster_count = columns.LinkedCountColumn( + viewname='virtualization:cluster_list', + url_params={'type_id': 'pk'}, verbose_name='Clusters' ) tags = columns.TagColumn( @@ -33,7 +35,9 @@ class ClusterGroupTable(NetBoxTable): name = tables.Column( linkify=True ) - cluster_count = tables.Column( + cluster_count = columns.LinkedCountColumn( + viewname='virtualization:cluster_list', + url_params={'group_id': 'pk'}, verbose_name='Clusters' ) contacts = tables.ManyToManyColumn( From 4f86d6a6906b3474201f30ae32d896fdbec04876 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 08:33:41 -0400 Subject: [PATCH 018/124] Fixes #9206: Show header for comments field under module & module type creation views --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/models.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b781a09fe..616329bea 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -17,6 +17,7 @@ * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view * [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures * [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment +* [#9206](https://github.com/netbox-community/netbox/issues/9206) - Show header for comments field under module & module type creation views --- diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index fe9daf938..31c5b957d 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -385,6 +385,12 @@ class ModuleTypeForm(NetBoxModelForm): ) comments = CommentField() + fieldsets = ( + ('Module Type', ( + 'manufacturer', 'model', 'part_number', 'tags', + )), + ) + class Meta: model = ModuleType fields = [ @@ -627,6 +633,15 @@ class ModuleForm(NetBoxModelForm): help_text="Automatically populate components associated with this module type" ) + fieldsets = ( + ('Module', ( + 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', + )), + ('Hardware', ( + 'serial', 'asset_tag', 'replicate_components', + )), + ) + class Meta: model = Module fields = [ From 562d1bfcd065ebabe9241879613e6a694e2bd474 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 08:41:38 -0400 Subject: [PATCH 019/124] Fixes #9194: Support position assignment when add module bays to multiple devices --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/bulk_create.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 616329bea..79a52df99 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -17,6 +17,7 @@ * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view * [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures * [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment +* [#9194](https://github.com/netbox-community/netbox/issues/9194) - Support position assignment when add module bays to multiple devices * [#9206](https://github.com/netbox-community/netbox/issues/9206) - Show header for comments field under module & module type creation views --- diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 4d73fcc2a..314a7a75f 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, form_from_model +from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model from .object_create import ComponentCreateForm __all__ = ( @@ -98,7 +98,13 @@ class RearPortBulkCreateForm( class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags') + + position_pattern = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): From a6a1bec437c41117d60f2b7adcc367cf49844836 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 09:48:39 -0400 Subject: [PATCH 020/124] Closes #9218: Update documentation links with docs.netbox.dev --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- contrib/netbox-rq.service | 2 +- contrib/netbox.service | 2 +- docs/installation/4-gunicorn.md | 2 +- docs/installation/migrating-to-systemd.md | 2 +- docs/release-notes/version-2.1.md | 2 +- docs/release-notes/version-2.2.md | 2 +- docs/release-notes/version-2.5.md | 2 +- docs/release-notes/version-2.6.md | 2 +- docs/release-notes/version-2.7.md | 4 ++-- docs/release-notes/version-2.8.md | 4 ++-- mkdocs.yml | 2 +- netbox/templates/home.html | 2 +- netbox/templates/media_failure.html | 2 +- 16 files changed, 20 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d74da64..a6d145951 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -The changelog has been moved to the [project release notes](https://netbox.readthedocs.io/en/stable/release-notes/). +The changelog has been moved to the [project release notes](https://docs.netbox.dev/en/stable/release-notes/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee69605c7..c01adf4c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,7 @@ appropriate labels will be applied for categorization. ## Submitting Pull Requests * If you're interested in contributing to NetBox, be sure to check out our -[getting started](https://netbox.readthedocs.io/en/stable/development/getting-started/) +[getting started](https://docs.netbox.dev/en/stable/development/getting-started/) documentation for tips on setting up your development environment. * Be sure to open an issue **before** starting work on a pull request, and @@ -171,7 +171,7 @@ an effort to circumvent the bot: Doing so will not remove the stale label. the understanding that all contributions are submitted under the Apache 2.0 license and that your employer may not make claim to any contributions. Contributions include code work, issue management, and community support. All - development must be in accordance with our [development guidance](https://netbox.readthedocs.io/en/stable/development/). + development must be in accordance with our [development guidance](https://docs.netbox.dev/en/stable/development/). * Maintainers are expected to attend (where feasible) our biweekly ~30-minute sync to review agenda items. This meeting provides opportunity to present and diff --git a/README.md b/README.md index 8429cd4b3..d75c2c1a5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). -The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). A public demo instance is available at https://demo.netbox.dev. +The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). A public demo instance is available at https://demo.netbox.dev. diff --git a/netbox/templates/media_failure.html b/netbox/templates/media_failure.html index e3b7ef309..971b3cbc5 100644 --- a/netbox/templates/media_failure.html +++ b/netbox/templates/media_failure.html @@ -29,7 +29,7 @@
  • The HTTP service (e.g. nginx or Apache) is configured to serve files from the STATIC_ROOT path. - Refer to the installation + Refer to the installation documentation for further guidance.
      {% if request.user.is_staff or request.user.is_superuser %} From 6b73d22da1c62c9bfcd6dcb8c7403c62f91fb47a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 10:11:51 -0400 Subject: [PATCH 021/124] Changelog for #8959 --- docs/release-notes/version-3.2.md | 1 + netbox/extras/scripts.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 79a52df99..d9c9ba737 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,7 @@ ### Bug Fixes * [#8941](https://github.com/netbox-community/netbox/issues/8941) - Fix dynamic dropdown behavior when browser is zoomed +* [#8959](https://github.com/netbox-community/netbox/issues/8959) - Prevent exception when refreshing scripts list (avoid race condition) * [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index fc853a7a3..4332d72f7 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -14,11 +14,9 @@ from django.conf import settings from django.core.validators import RegexValidator from django.db import transaction from django.utils.functional import classproperty -from django_rq import job from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices -from extras.models import JobResult from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction From 6a225e53f595e6948f157e852b0244473b802828 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Apr 2022 21:09:20 -0400 Subject: [PATCH 022/124] Fixes #9222: Fix circuit ID display under cable view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/dcim/inc/cable_termination.html | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d9c9ba737..5d2b118ce 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -20,6 +20,7 @@ * [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment * [#9194](https://github.com/netbox-community/netbox/issues/9194) - Support position assignment when add module bays to multiple devices * [#9206](https://github.com/netbox-community/netbox/issues/9206) - Show header for comments field under module & module type creation views +* [#9222](https://github.com/netbox-community/netbox/issues/9222) - Fix circuit ID display under cable view --- diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index f44c3b9d1..6d75aee85 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -32,7 +32,11 @@ Circuit - {{ termination.|linkify }} ({{ termination }}) + {{ termination.circuit|linkify }} + + + Termination + {{ termination }} {% endif %} From b5613a2cc6b677b25d4e7403b8f06a8e51ea6089 Mon Sep 17 00:00:00 2001 From: "Sean M. Collins" Date: Mon, 11 Apr 2022 13:56:44 -0400 Subject: [PATCH 023/124] Do not allocate subnet router anycast in certain IPv6 prefixes --- netbox/ipam/models/ip.py | 20 ++++++++++++-------- netbox/ipam/tests/test_models.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 9aec0cff8..a3b8fb2c1 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -507,16 +507,20 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): child_ranges.add(iprange.range) available_ips = prefix - child_ips - child_ranges - # IPv6, pool, or IPv4 /31-/32 sets are fully usable - if self.family == 6 or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31): + # IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable + if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31): return available_ips - # For "normal" IPv4 prefixes, omit first and last addresses - available_ips -= netaddr.IPSet([ - netaddr.IPAddress(self.prefix.first), - netaddr.IPAddress(self.prefix.last), - ]) - + if self.family == 4: + # For "normal" IPv4 prefixes, omit first and last addresses + available_ips -= netaddr.IPSet([ + netaddr.IPAddress(self.prefix.first), + netaddr.IPAddress(self.prefix.last), + ]) + else: + # For IPv6 prefixes, omit the Subnet-Router anycast address + # per RFC 4291 + available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)]) return available_ips def get_first_available_ip(self): diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index a664b34f4..09bc95799 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -185,6 +185,18 @@ class TestPrefix(TestCase): IPAddress.objects.create(address=IPNetwork('10.0.0.4/24')) self.assertEqual(parent_prefix.get_first_available_ip(), '10.0.0.5/24') + def test_get_first_available_ip_ipv6(self): + parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500::/64')) + self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500::1/64') + + def test_get_first_available_ip_ipv6_rfc3627(self): + parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:4::/126')) + self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:4::1/126') + + def test_get_first_available_ip_ipv6_rfc6164(self): + parent_prefix = Prefix.objects.create(prefix=IPNetwork('2001:db8:500:5::/127')) + self.assertEqual(parent_prefix.get_first_available_ip(), '2001:db8:500:5::/127') + def test_get_utilization_container(self): prefixes = ( Prefix(prefix=IPNetwork('10.0.0.0/24'), status=PrefixStatusChoices.STATUS_CONTAINER), From 8153406dd0d959077906d9ef472716198a0db0af Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 27 Apr 2022 14:12:20 -0400 Subject: [PATCH 024/124] Fixes #9227: Fix related object assignment when recording change record for interfaces --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/models/device_components.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 5d2b118ce..30835fc3e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -21,6 +21,7 @@ * [#9194](https://github.com/netbox-community/netbox/issues/9194) - Support position assignment when add module bays to multiple devices * [#9206](https://github.com/netbox-community/netbox/issues/9206) - Show header for comments field under module & module type creation views * [#9222](https://github.com/netbox-community/netbox/issues/9222) - Fix circuit ID display under cable view +* [#9227](https://github.com/netbox-community/netbox/issues/9227) - Fix related object assignment when recording change record for interfaces --- diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3ed786000..a3b182da1 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -77,7 +77,7 @@ class ComponentModel(NetBoxModel): def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.device - return super().to_objectchange(action) + return objectchange @property def parent_object(self): From a1c1532614237cde735e85a3a4502ce5a2834a35 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 27 Apr 2022 15:36:29 -0400 Subject: [PATCH 025/124] Changelog for #4264 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 30835fc3e..6a5c42007 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -11,6 +11,7 @@ ### Bug Fixes +* [#4264](https://github.com/netbox-community/netbox/issues/4264) - Treat 0th IP as unusable for IPv6 prefixes (excluding /127s) * [#8941](https://github.com/netbox-community/netbox/issues/8941) - Fix dynamic dropdown behavior when browser is zoomed * [#8959](https://github.com/netbox-community/netbox/issues/8959) - Prevent exception when refreshing scripts list (avoid race condition) * [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link From 314c41f47f8eba4df84688a478452b5178b091d7 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Thu, 28 Apr 2022 07:47:04 +0200 Subject: [PATCH 026/124] Prevent searching when pressing enter in Quick Search --- .../templates/dcim/device/consoleports.html | 3 +- .../dcim/device/consoleserverports.html | 3 +- netbox/templates/dcim/device/devicebays.html | 3 +- netbox/templates/dcim/device/frontports.html | 3 +- netbox/templates/dcim/device/interfaces.html | 74 ++++++++++--------- netbox/templates/dcim/device/inventory.html | 3 +- netbox/templates/dcim/device/modulebays.html | 3 +- .../templates/dcim/device/poweroutlets.html | 3 +- netbox/templates/dcim/device/powerports.html | 3 +- netbox/templates/dcim/device/rearports.html | 3 +- netbox/templates/ipam/aggregate/prefixes.html | 3 +- .../templates/ipam/iprange/ip_addresses.html | 3 +- .../templates/ipam/prefix/ip_addresses.html | 3 +- netbox/templates/ipam/prefix/ip_ranges.html | 3 +- netbox/templates/ipam/prefix/prefixes.html | 3 +- netbox/templates/ipam/vlan/interfaces.html | 3 +- netbox/templates/ipam/vlan/vminterfaces.html | 3 +- .../virtualization/cluster/devices.html | 3 +- .../cluster/virtual_machines.html | 3 +- .../virtualmachine/interfaces.html | 3 +- 20 files changed, 76 insertions(+), 55 deletions(-) diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index f96854ca8..afc306bd4 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %} +
      {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
      diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index eb27b4ab0..5f244cdc7 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
      diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index 672cb192a..5e33bdae0 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
      diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 816d193de..0d0f9577c 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
      diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index d7f8dff55..22f6d8be5 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -4,44 +4,46 @@ {% load static %} {% block content %} - - {% csrf_token %} -
      -
      -
      - -
      -
      -
      -
      - {% if request.user.is_authenticated %} - - {% endif %} - - -
      +
      +
      +
      +
      +
      +
      + {% if request.user.is_authenticated %} + + {% endif %} + + +
      +
      +
      + + + {% csrf_token %} +
      diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index c6452cf78..18a0712f3 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
      diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index e9c672b57..fc1c9a60d 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %}
      diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 19d8298af..d312fbbd0 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
      diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index 82c088392..cf71e81ba 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -4,9 +4,10 @@ {% load static %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
      diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index 868def466..73341990f 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -4,9 +4,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
      diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index bb574ebf0..d1b48429a 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -12,9 +12,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
      diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index a13910406..d9ac77fd0 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -10,9 +10,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
      diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index b26375ebe..d734b825f 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -10,9 +10,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
      diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index b262be821..268c290a1 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -10,9 +10,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %}
      diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 039b1ca3e..5d42596ba 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -12,9 +12,10 @@ {% endblock %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
      diff --git a/netbox/templates/ipam/vlan/interfaces.html b/netbox/templates/ipam/vlan/interfaces.html index 51df17edc..5707d5364 100644 --- a/netbox/templates/ipam/vlan/interfaces.html +++ b/netbox/templates/ipam/vlan/interfaces.html @@ -2,9 +2,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
      {% include 'htmx/table.html' %} diff --git a/netbox/templates/ipam/vlan/vminterfaces.html b/netbox/templates/ipam/vlan/vminterfaces.html index f12e9df86..ef4a0730a 100644 --- a/netbox/templates/ipam/vlan/vminterfaces.html +++ b/netbox/templates/ipam/vlan/vminterfaces.html @@ -2,9 +2,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
      {% include 'htmx/table.html' %} diff --git a/netbox/templates/virtualization/cluster/devices.html b/netbox/templates/virtualization/cluster/devices.html index 075f34c7e..cb4a1b3ee 100644 --- a/netbox/templates/virtualization/cluster/devices.html +++ b/netbox/templates/virtualization/cluster/devices.html @@ -3,9 +3,10 @@ {% load render_table from django_tables2 %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
      {% include 'htmx/table.html' %} diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 8b4191259..953d9f940 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -3,9 +3,10 @@ {% load render_table from django_tables2 %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
      {% include 'htmx/table.html' %} diff --git a/netbox/templates/virtualization/virtualmachine/interfaces.html b/netbox/templates/virtualization/virtualmachine/interfaces.html index de657b3b3..e3ffb84d4 100644 --- a/netbox/templates/virtualization/virtualmachine/interfaces.html +++ b/netbox/templates/virtualization/virtualmachine/interfaces.html @@ -3,9 +3,10 @@ {% load helpers %} {% block content %} + {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %} + {% csrf_token %} - {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %}
      From 5ee3ee618138bf579ca5ea3b8e9e6ce05627bc66 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Apr 2022 14:39:02 -0400 Subject: [PATCH 027/124] Release v3.2.2 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- base_requirements.txt | 5 ++--- docs/release-notes/version-3.2.md | 3 ++- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b5de9bfee..2d6ca5700 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.1 + placeholder: v3.2.2 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 138e0f9b4..13b162741 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.1 + placeholder: v3.2.2 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 4b814dbc7..095906914 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -68,8 +68,7 @@ gunicorn # Platform-agnostic template rendering engine # https://github.com/pallets/jinja -# Pin to v3.0 for mkdocstrings -Jinja2<3.1 +Jinja2 # Simple markup language for rendering HTML # https://github.com/Python-Markdown/markdown @@ -85,7 +84,7 @@ mkdocs-material # Introspection for embedded code # https://github.com/mkdocstrings/mkdocstrings -mkdocstrings<=0.17.0 +mkdocstrings[python-legacy] # Library for manipulating IP prefixes and addresses # https://github.com/netaddr/netaddr diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6a5c42007..56f6b7357 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,6 @@ # NetBox v3.2 -## v3.2.2 (FUTURE) +## v3.2.2 (2022-04-28) ### Enhancements @@ -16,6 +16,7 @@ * [#8959](https://github.com/netbox-community/netbox/issues/8959) - Prevent exception when refreshing scripts list (avoid race condition) * [#9132](https://github.com/netbox-community/netbox/issues/9132) - Limit location options by selected site when creating a wireless link * [#9133](https://github.com/netbox-community/netbox/issues/9133) - Upgrade script should require Python 3.8 or later +* [#9138](https://github.com/netbox-community/netbox/issues/9138) - Avoid inadvertent form submission when utilizing quick search field on object lists * [#9151](https://github.com/netbox-community/netbox/issues/9151) - Child prefix counts not annotated on aggregates list under RIR view * [#9156](https://github.com/netbox-community/netbox/issues/9156) - Fix loading UserConfig data from fixtures * [#9158](https://github.com/netbox-community/netbox/issues/9158) - Do not list tags field for CSV forms which do not support tag assignment diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e83ccdc73..7f0735546 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -26,7 +26,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.2-dev' +VERSION = '3.2.2' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 35867410b..32c13d455 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,11 +15,11 @@ djangorestframework==3.13.1 drf-yasg[validation]==1.20.0 graphene-django==2.15.0 gunicorn==20.1.0 -Jinja2==3.0.3 +Jinja2==3.1.2 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==8.2.9 -mkdocstrings==0.17.0 +mkdocs-material==8.2.11 +mkdocstrings[python-legacy]==0.18.1 netaddr==0.8.0 Pillow==9.1.0 psycopg2-binary==2.9.3 From 152d5a3b9a59539263122098bb4d8759b45371d5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 28 Apr 2022 15:06:27 -0400 Subject: [PATCH 028/124] PRVB --- docs/release-notes/version-3.2.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 56f6b7357..1760d4d2e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.3 (FUTURE) + +--- + ## v3.2.2 (2022-04-28) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7f0735546..6dee1081a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -26,7 +26,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.2' +VERSION = '3.2.3-dev' # Hostname HOSTNAME = platform.node() From 7b5625a722a9b3e69636ffe3a89b9d314a1ce8e3 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 29 Apr 2022 09:19:19 +0200 Subject: [PATCH 029/124] Add management command for clearing cache --- netbox/extras/management/commands/clearcache.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 netbox/extras/management/commands/clearcache.py diff --git a/netbox/extras/management/commands/clearcache.py b/netbox/extras/management/commands/clearcache.py new file mode 100644 index 000000000..22843c490 --- /dev/null +++ b/netbox/extras/management/commands/clearcache.py @@ -0,0 +1,11 @@ +from django.core.cache import cache +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Command to clear the entire cache.""" + help = 'Clears the cache.' + + def handle(self, *args, **kwargs): + cache.clear() + self.stdout.write('Cache has been cleared.', ending="\n") From 9f3846ec5f3d9f8fdb0b9758e00d81b0df623989 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 29 Apr 2022 09:19:37 +0200 Subject: [PATCH 030/124] Clear the cache when running the upgrade script --- upgrade.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/upgrade.sh b/upgrade.sh index 61e6106cd..161d65e32 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -108,6 +108,11 @@ COMMAND="python3 netbox/manage.py clearsessions" echo "Removing expired user sessions ($COMMAND)..." eval $COMMAND || exit 1 +# Clear the cache +COMMAND="python3 netbox/manage.py clearcache" +echo "Clearing the cache ($COMMAND)..." +eval $COMMAND || exit 1 + if [ -v WARN_MISSING_VENV ]; then echo "--------------------------------------------------------------------" echo "WARNING: No existing virtual environment was detected. A new one has" From 61d756c7c48d67417255d3ca277c09abfe147bcc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 29 Apr 2022 13:09:39 -0400 Subject: [PATCH 031/124] Closes #9261: NetBoxTable no longer automatically clears pre-existing calls to prefetch_related() on its queryset --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/tables/tables.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 1dd19a5c0..9b061b7d6 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -7,6 +7,10 @@ * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +### Other Changes + +* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset + ### REST API Changes * extras.CustomField diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..5ebb78865 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -97,7 +97,7 @@ class BaseTable(tables.Table): break if prefetch_path: prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + self.data.data = self.data.data.prefetch_related(*prefetch_fields) def _get_columns(self, visible=True): columns = [] From 3fb967b482a8239da8b8932f7795bd7f49adc47b Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 30 Apr 2022 02:19:11 +0200 Subject: [PATCH 032/124] Add ability to adopt components when adding a module --- netbox/dcim/forms/models.py | 15 +++++++++-- netbox/dcim/models/devices.py | 51 ++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 31c5b957d..c8ca1daf1 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -633,12 +633,18 @@ class ModuleForm(NetBoxModelForm): help_text="Automatically populate components associated with this module type" ) + adopt_components = forms.BooleanField( + required=False, + initial=False, + help_text="Adopt already existing components" + ) + fieldsets = ( ('Module', ( 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', )), ('Hardware', ( - 'serial', 'asset_tag', 'replicate_components', + 'serial', 'asset_tag', 'replicate_components', 'adopt_components', )), ) @@ -646,7 +652,7 @@ class ModuleForm(NetBoxModelForm): model = Module fields = [ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'comments', + 'replicate_components', 'adopt_components', 'comments', ] def __init__(self, *args, **kwargs): @@ -655,6 +661,8 @@ class ModuleForm(NetBoxModelForm): if self.instance.pk: self.fields['replicate_components'].initial = False self.fields['replicate_components'].disabled = True + self.fields['adopt_components'].initial = False + self.fields['adopt_components'].disabled = True def save(self, *args, **kwargs): @@ -662,6 +670,9 @@ class ModuleForm(NetBoxModelForm): if self.instance.pk or not self.cleaned_data['replicate_components']: self.instance._disable_replication = True + if self.cleaned_data['adopt_components']: + self.instance._adopt_components = True + return super().save(*args, **kwargs) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6ed7b349f..f0c7f31cb 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1065,31 +1065,38 @@ class Module(NetBoxModel, ConfigContextModel): super().save(*args, **kwargs) + adopt_components = getattr(self, '_adopt_components', False) + disable_replication = getattr(self, '_disable_replication', False) + # If this is a new Module and component replication has not been disabled, instantiate all its # related components per the ModuleType definition - if is_new and not getattr(self, '_disable_replication', False): - ConsolePort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] - ) - ConsoleServerPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] - ) - PowerPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] - ) - PowerOutlet.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] - ) - Interface.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] - ) - RearPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] - ) - FrontPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] - ) + if is_new and not disable_replication: + # Iterate all component templates + for templates, component_attribute in [ + ("consoleporttemplates", "consoleports"), + ("consoleserverporttemplates", "consoleserverports"), + ("interfacetemplates", "interfaces"), + ("powerporttemplates", "powerports"), + ("poweroutlettemplates", "poweroutlets"), + ("rearporttemplates", "rearports"), + ("frontporttemplates", "frontports") + ]: + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) + if adopt_components: + existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() + + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + existing_item.save() + continue + + # If we are not adopting components or the component doesn't already exist + template_instance.save() # # Virtual chassis From 30d4097fd8e3173c1f8f1df3fbaa61c2700b2816 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Mon, 2 May 2022 12:09:49 +0200 Subject: [PATCH 033/124] Fix early terminated tuple in IPAddressRoleChoices --- netbox/ipam/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 152d8b726..a364d3c6a 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet): (ROLE_VRRP, 'VRRP', 'green'), (ROLE_HSRP, 'HSRP', 'green'), (ROLE_GLBP, 'GLBP', 'green'), - (ROLE_CARP, 'CARP'), 'green', + (ROLE_CARP, 'CARP', 'green'), ) From c2a6a1c125fd4c2a286552c08529ebddf0bfc57c Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 2 May 2022 21:37:37 +0200 Subject: [PATCH 034/124] Create module components in bulk --- netbox/dcim/models/devices.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f0c7f31cb..25f07c3bd 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1072,15 +1072,18 @@ class Module(NetBoxModel, ConfigContextModel): # related components per the ModuleType definition if is_new and not disable_replication: # Iterate all component templates - for templates, component_attribute in [ - ("consoleporttemplates", "consoleports"), - ("consoleserverporttemplates", "consoleserverports"), - ("interfacetemplates", "interfaces"), - ("powerporttemplates", "powerports"), - ("poweroutlettemplates", "poweroutlets"), - ("rearporttemplates", "rearports"), - ("frontporttemplates", "frontports") + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort) ]: + create_instances = [] + update_instances = [] + # Get the template for the module type. for template in getattr(self.module_type, templates).all(): template_instance = template.instantiate(device=self.device, module=self) @@ -1092,11 +1095,15 @@ class Module(NetBoxModel, ConfigContextModel): if existing_item: # Assign it to the module existing_item.module = self - existing_item.save() + update_instances.append(existing_item) continue # If we are not adopting components or the component doesn't already exist - template_instance.save() + create_instances.append(template_instance) + + component_model.objects.bulk_create(create_instances) + component_model.objects.bulk_update(update_instances, ['module']) + # # Virtual chassis From 977ccb01f2f5d6407f0edfd29a4b64f7bd70b086 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 2 May 2022 21:55:34 +0200 Subject: [PATCH 035/124] Formatting: Remove whitespace on blank line --- netbox/dcim/models/devices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 25f07c3bd..980a4ea75 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1097,10 +1097,10 @@ class Module(NetBoxModel, ConfigContextModel): existing_item.module = self update_instances.append(existing_item) continue - + # If we are not adopting components or the component doesn't already exist create_instances.append(template_instance) - + component_model.objects.bulk_create(create_instances) component_model.objects.bulk_update(update_instances, ['module']) From 25c266e4de70a20b43c70b7b1d81f407b47555ce Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 09:00:52 +0100 Subject: [PATCH 036/124] Update netbox/users/api/nested_serializers.py Co-authored-by: Jeremy Stretch --- netbox/users/api/nested_serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3b4959a1e..d1950bf2d 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -30,8 +30,8 @@ class NestedUserSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'username'] def get_display(self, obj): - if obj.first_name and obj.last_name: - return f"{obj.username} ({obj.first_name} {obj.last_name})" + if full_name := obj.get_full_name(): + return f"{obj.username} ({full_name})" return obj.username From 535606a1852525e328f0ee220be0c3fa28fcde02 Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 09:01:06 +0100 Subject: [PATCH 037/124] Update netbox/users/api/nested_serializers.py Co-authored-by: Jeremy Stretch --- netbox/users/api/nested_serializers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index d1950bf2d..51e0c5b26 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -23,7 +23,6 @@ class NestedGroupSerializer(WritableNestedSerializer): class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') - display = serializers.SerializerMethodField(read_only=True) class Meta: model = User From 0a9ba3b2e6ee2c711ca09c56a2772a8f7957f0e8 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 3 May 2022 10:45:08 +0000 Subject: [PATCH 038/124] add get_display to users serializer --- netbox/users/api/serializers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index d490e8fe9..8e2b01477 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -44,6 +44,11 @@ class UserSerializer(ValidatedModelSerializer): user.save() return user + + def get_display(self, obj): + if full_name := obj.get_full_name(): + return f"{obj.username} ({full_name})" + return obj.username class GroupSerializer(ValidatedModelSerializer): From 15e91908e8b169dd38f051fa8b45c868d26103f5 Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 11:47:32 +0100 Subject: [PATCH 039/124] Update netbox/dcim/forms/filtersets.py Co-authored-by: Jeremy Stretch --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 079927ea3..da791001c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -210,7 +210,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte model = Rack fieldsets = ( (None, ('q', 'tag')), - ('Location', ('region_id', 'site_id', 'site_group_id', 'location_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), From 7cd840610b7fe718932574f4a9a2226075d2dd44 Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 11:47:37 +0100 Subject: [PATCH 040/124] Update netbox/dcim/forms/filtersets.py Co-authored-by: Jeremy Stretch --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index da791001c..0f2747906 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -287,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('User', ('user_id',)), - ('Rack', ('region_id', 'site_id', 'site_group_id', 'location_id')), + ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( From 8040804c753d070b386b41b650ec53bc10d08e26 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Tue, 3 May 2022 22:03:12 +0200 Subject: [PATCH 041/124] Allow mixture of component replication and adoption --- netbox/dcim/models/devices.py | 61 ++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 980a4ea75..023d3a83f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1068,41 +1068,44 @@ class Module(NetBoxModel, ConfigContextModel): adopt_components = getattr(self, '_adopt_components', False) disable_replication = getattr(self, '_disable_replication', False) - # If this is a new Module and component replication has not been disabled, instantiate all its - # related components per the ModuleType definition - if is_new and not disable_replication: - # Iterate all component templates - for templates, component_attribute, component_model in [ - ("consoleporttemplates", "consoleports", ConsolePort), - ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), - ("interfacetemplates", "interfaces", Interface), - ("powerporttemplates", "powerports", PowerPort), - ("poweroutlettemplates", "poweroutlets", PowerOutlet), - ("rearporttemplates", "rearports", RearPort), - ("frontporttemplates", "frontports", FrontPort) - ]: - create_instances = [] - update_instances = [] + # We skip adding components if the module is being edited or + # both replication and component adoption is disabled + if not is_new or (disable_replication and not adopt_components): + return - # Get the template for the module type. - for template in getattr(self.module_type, templates).all(): - template_instance = template.instantiate(device=self.device, module=self) + # Iterate all component types + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort) + ]: + create_instances = [] + update_instances = [] - if adopt_components: - existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) - # Check if there's a component with the same name already - if existing_item: - # Assign it to the module - existing_item.module = self - update_instances.append(existing_item) - continue + if adopt_components: + existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() - # If we are not adopting components or the component doesn't already exist + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + update_instances.append(existing_item) + continue + + # Only create new components if replication is enabled + if not disable_replication: create_instances.append(template_instance) - component_model.objects.bulk_create(create_instances) - component_model.objects.bulk_update(update_instances, ['module']) + component_model.objects.bulk_create(create_instances) + component_model.objects.bulk_update(update_instances, ['module']) # From bdaefc0e4d6f9cc179028ba913741f2cc155b1c7 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 3 May 2022 18:34:32 -0400 Subject: [PATCH 042/124] Closes #9278: Linkify device type in manufacturer table --- netbox/dcim/tables/devicetypes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f5f5ed7bf..c3064e7cd 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) - devicetype_count = tables.Column( + devicetype_count = columns.LinkedCountColumn( + viewname='dcim:devicetype_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Device Types' ) inventoryitem_count = tables.Column( From f455f91ea3eeb38b1480ef30e6521157b783c782 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Wed, 4 May 2022 08:58:42 +0200 Subject: [PATCH 043/124] Add view test for module component adoption --- netbox/dcim/tests/test_views.py | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 70eb4b659..b7020d663 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1869,6 +1869,54 @@ class ModuleTestCase( self.assertHttpStatus(self.client.post(**request), 302) self.assertEqual(Interface.objects.filter(device=device).count(), 5) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_module_component_adoption(self): + self.add_permissions('dcim.add_module') + + interface_name = "Interface-1" + + # Add an interface to the ModuleType + module_type = ModuleType.objects.first() + InterfaceTemplate(module_type=module_type, name=interface_name).save() + + form_data = self.form_data.copy() + device = Device.objects.get(pk=form_data['device']) + + # Create a module with replicated components + form_data['module_bay'] = ModuleBay.objects.filter(device=device)[0] + form_data['replicate_components'] = True + request = { + 'path': self._get_url('add'), + 'data': post_data(form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + + # Check that the interface was created + initial_interface = Interface.objects.filter(device=device, name=interface_name).first() + self.assertIsNotNone(initial_interface) + + # Save the module id associated with the interface + initial_module_id = initial_interface.module.id + + # Create a second module (in the next bay) with adopted components + # The module id of the interface should change + form_data['module_bay'] = ModuleBay.objects.filter(device=device)[1] + form_data['replicate_components'] = False + form_data['adopt_components'] = True + request = { + 'path': self._get_url('add'), + 'data': post_data(form_data), + } + + self.assertHttpStatus(self.client.post(**request), 302) + + # Re-retrieve interface to get new module id + initial_interface.refresh_from_db() + updated_module_id = initial_interface.module.id + + # Check that the module id has changed + self.assertNotEqual(initial_module_id, updated_module_id) + class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort From 7de27c69c054f382bf1baa68be9558476bab53fd Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Wed, 4 May 2022 09:16:19 +0200 Subject: [PATCH 044/124] Fix PEP8 --- netbox/dcim/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b7020d663..4104bd206 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1894,7 +1894,7 @@ class ModuleTestCase( # Check that the interface was created initial_interface = Interface.objects.filter(device=device, name=interface_name).first() self.assertIsNotNone(initial_interface) - + # Save the module id associated with the interface initial_module_id = initial_interface.module.id From eab187fb6be652ff82c27400360a64f3684e34dc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 13:59:38 -0400 Subject: [PATCH 045/124] Changelog for #9267, #9278 --- docs/release-notes/version-3.2.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 1760d4d2e..5fddf825c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,14 @@ ## v3.2.3 (FUTURE) +### Enhancements + +* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list + +### Bug Fixes + +* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices + --- ## v3.2.2 (2022-04-28) From da1aabdfc1b40dd81c402c6005232b1e3db86beb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 14:19:09 -0400 Subject: [PATCH 046/124] Changelog for #8894, #8998, #9122; PEP8 fix --- docs/release-notes/version-3.2.md | 3 +++ netbox/users/api/serializers.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 5fddf825c..9a6a7ae7b 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,9 @@ ### Enhancements +* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users +* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group +* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list ### Bug Fixes diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 8e2b01477..059bb0bd7 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -44,7 +44,7 @@ class UserSerializer(ValidatedModelSerializer): user.save() return user - + def get_display(self, obj): if full_name := obj.get_full_name(): return f"{obj.username} ({full_name})" From 015bc48345caa8aae4bd01995830b3cd02101843 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 14:29:36 -0400 Subject: [PATCH 047/124] #8998: Add region filter for rack reservations; Add filter tests --- netbox/dcim/filtersets.py | 13 ++++++++++ netbox/dcim/tests/test_filtersets.py | 36 +++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 11e5fb3f5..d57d0a59b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -346,6 +346,19 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='rack__site__region', + lookup_expr='in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='rack__site__region', + lookup_expr='in', + to_field_name='slug', + label='Region (slug)', + ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='rack__site__group', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8480c97bf..273ee6570 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): ) RackReservation.objects.bulk_create(reservations) + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} From 0301aec409fbf29834d3a4cfbecb481a60ec6b8a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 15:46:13 -0400 Subject: [PATCH 048/124] Closes #9260: Apply user preferences to tables under object detail views --- docs/release-notes/version-3.2.md | 1 + netbox/circuits/views.py | 6 +++--- netbox/dcim/views.py | 16 +++++++++------- netbox/ipam/views.py | 10 +++++----- netbox/tenancy/views.py | 8 ++++---- netbox/virtualization/views.py | 4 ++-- netbox/wireless/views.py | 4 ++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 9a6a7ae7b..6b626d992 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -7,6 +7,7 @@ * [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users * [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group * [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade +* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list ### Bug Fixes diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index c05aa31df..f3b1269f9 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ) - circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) + circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',)) circuits_table.configure(request) return { @@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ) - circuits_table = tables.CircuitTable(circuits) + circuits_table = tables.CircuitTable(circuits, user=request.user) circuits_table.configure(request) return { @@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) - circuits_table = tables.CircuitTable(circuits, exclude=('type',)) + circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',)) circuits_table.configure(request) return { diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2622a1405..57e8b1c79 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -166,7 +166,7 @@ class RegionView(generic.ObjectView): sites = Site.objects.restrict(request.user, 'view').filter( region=instance ) - sites_table = tables.SiteTable(sites, exclude=('region',)) + sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',)) sites_table.configure(request) return { @@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView): sites = Site.objects.restrict(request.user, 'view').filter( group=instance ) - sites_table = tables.SiteTable(sites, exclude=('group',)) + sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',)) sites_table.configure(request) return { @@ -435,7 +435,7 @@ class LocationView(generic.ObjectView): 'rack_count', cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) - child_locations_table = tables.LocationTable(child_locations) + child_locations_table = tables.LocationTable(child_locations, user=request.user) child_locations_table.configure(request) nonracked_devices = Device.objects.filter( @@ -514,7 +514,9 @@ class RackRoleView(generic.ObjectView): role=instance ) - racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) + racks_table = tables.RackTable(racks, user=request.user, exclude=( + 'role', 'get_utilization', 'get_power_utilization', + )) racks_table.configure(request) return { @@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView): manufacturer=instance ) - devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',)) + devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',)) devicetypes_table.configure(request) return { @@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView): devices = Device.objects.restrict(request.user, 'view').filter( device_role=instance ) - devices_table = tables.DeviceTable(devices, exclude=('device_role',)) + devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',)) devices_table.configure(request) return { @@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView): devices = Device.objects.restrict(request.user, 'view').filter( platform=instance ) - devices_table = tables.DeviceTable(devices, exclude=('platform',)) + devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',)) devices_table.configure(request) return { diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 57a682c94..79804aabd 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -161,7 +161,7 @@ class RIRView(generic.ObjectView): aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) - aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) + aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization')) aggregates_table.configure(request) return { @@ -221,12 +221,12 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): # Gather assigned Sites sites = instance.sites.restrict(request.user, 'view') - sites_table = SiteTable(sites) + sites_table = SiteTable(sites, user=request.user) sites_table.configure(request) # Gather assigned Providers providers = instance.providers.restrict(request.user, 'view') - providers_table = ProviderTable(providers) + providers_table = ProviderTable(providers, user=request.user) providers_table.configure(request) return { @@ -366,7 +366,7 @@ class RoleView(generic.ObjectView): role=instance ) - prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) + prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization')) prefixes_table.configure(request) return { @@ -805,7 +805,7 @@ class VLANGroupView(generic.ObjectView): vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) - vlans_table = tables.VLANTable(vlans, exclude=('group',)) + vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',)) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') vlans_table.configure(request) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 195871813..58ad98e8f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -35,7 +35,7 @@ class TenantGroupView(generic.ObjectView): tenants = Tenant.objects.restrict(request.user, 'view').filter( group=instance ) - tenants_table = tables.TenantTable(tenants, exclude=('group',)) + tenants_table = tables.TenantTable(tenants, user=request.user, exclude=('group',)) tenants_table.configure(request) return { @@ -184,7 +184,7 @@ class ContactGroupView(generic.ObjectView): contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance ) - contacts_table = tables.ContactTable(contacts, exclude=('group',)) + contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',)) contacts_table.configure(request) return { @@ -250,7 +250,7 @@ class ContactRoleView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( role=instance ) - contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) contacts_table.columns.hide('role') contacts_table.configure(request) @@ -307,7 +307,7 @@ class ContactView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( contact=instance ) - assignments_table = tables.ContactAssignmentTable(contact_assignments) + assignments_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) assignments_table.columns.hide('contact') assignments_table.configure(request) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 850cb6388..0b593289b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -39,7 +39,7 @@ class ClusterTypeView(generic.ObjectView): device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ) - clusters_table = tables.ClusterTable(clusters, exclude=('type',)) + clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',)) clusters_table.configure(request) return { @@ -101,7 +101,7 @@ class ClusterGroupView(generic.ObjectView): device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ) - clusters_table = tables.ClusterTable(clusters, exclude=('group',)) + clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('group',)) clusters_table.configure(request) return { diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index eee7fe1ed..988aa1b6d 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -29,7 +29,7 @@ class WirelessLANGroupView(generic.ObjectView): wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter( group=instance ) - wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) + wirelesslans_table = tables.WirelessLANTable(wirelesslans, user=request.user, exclude=('group',)) wirelesslans_table.configure(request) return { @@ -97,7 +97,7 @@ class WirelessLANView(generic.ObjectView): attached_interfaces = Interface.objects.restrict(request.user, 'view').filter( wireless_lans=instance ) - interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) + interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces, user=request.user) interfaces_table.configure(request) return { From 81c7fe2084b59dcfc16c821f661119bd95adf6f0 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 4 May 2022 22:59:28 +0200 Subject: [PATCH 049/124] Don't adopt components already belonging to a module --- netbox/dcim/models/devices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 023d3a83f..bcf0f6e79 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1091,7 +1091,8 @@ class Module(NetBoxModel, ConfigContextModel): template_instance = template.instantiate(device=self.device, module=self) if adopt_components: - existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() + existing_item = getattr(self.device, component_attribute).filter( + module__isnull=True, name=template_instance.name).first() # Check if there's a component with the same name already if existing_item: From c52aa2196df72f30553c1610905dd3a5b0745982 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 4 May 2022 23:21:03 +0200 Subject: [PATCH 050/124] Prefetch installed components when adding modules --- netbox/dcim/models/devices.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index bcf0f6e79..8d50db958 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1086,13 +1086,17 @@ class Module(NetBoxModel, ConfigContextModel): create_instances = [] update_instances = [] + # Prefetch installed components + installed_components = { + component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True) + } + # Get the template for the module type. for template in getattr(self.module_type, templates).all(): template_instance = template.instantiate(device=self.device, module=self) if adopt_components: - existing_item = getattr(self.device, component_attribute).filter( - module__isnull=True, name=template_instance.name).first() + existing_item = installed_components.get(template_instance.name) # Check if there's a component with the same name already if existing_item: From 9c3dfdfd14fe4321bbcdc1b642ea79fd2e176a60 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 5 May 2022 09:30:13 +0200 Subject: [PATCH 051/124] Fix test_module_component_adoption --- netbox/dcim/tests/test_views.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4104bd206..e17f94682 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1882,25 +1882,16 @@ class ModuleTestCase( form_data = self.form_data.copy() device = Device.objects.get(pk=form_data['device']) - # Create a module with replicated components - form_data['module_bay'] = ModuleBay.objects.filter(device=device)[0] - form_data['replicate_components'] = True - request = { - 'path': self._get_url('add'), - 'data': post_data(form_data), - } - self.assertHttpStatus(self.client.post(**request), 302) + # Create an interface to be adopted + interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED) + interface.save() - # Check that the interface was created - initial_interface = Interface.objects.filter(device=device, name=interface_name).first() - self.assertIsNotNone(initial_interface) + # Ensure that interface is created with no module + self.assertIsNone(interface.module) - # Save the module id associated with the interface - initial_module_id = initial_interface.module.id - - # Create a second module (in the next bay) with adopted components - # The module id of the interface should change - form_data['module_bay'] = ModuleBay.objects.filter(device=device)[1] + # Create a module with adopted components + form_data['module_bay'] = ModuleBay.objects.filter(device=device).first() + form_data['module_type'] = module_type form_data['replicate_components'] = False form_data['adopt_components'] = True request = { @@ -1911,11 +1902,10 @@ class ModuleTestCase( self.assertHttpStatus(self.client.post(**request), 302) # Re-retrieve interface to get new module id - initial_interface.refresh_from_db() - updated_module_id = initial_interface.module.id + interface.refresh_from_db() - # Check that the module id has changed - self.assertNotEqual(initial_module_id, updated_module_id) + # Check that the Interface now has a module + self.assertIsNotNone(interface.module) class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): From bddca8e2321b9fb1930f0e69d556c13fbeaf0e1c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 5 May 2022 14:14:49 -0400 Subject: [PATCH 052/124] Changelog for #9280 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6b626d992..1dadb3eba 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -9,6 +9,7 @@ * [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade * [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list +* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module ### Bug Fixes From 13584693757b4c90fc9b190799e15f1bce47c813 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 6 May 2022 08:01:15 +0200 Subject: [PATCH 053/124] Remove stray characters from Config Context tab --- netbox/templates/extras/object_configcontext.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index ab730410e..2a7003b8d 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -43,7 +43,7 @@
      {{ context.weight }}
      - {{ context|linkify:"name" }}"> + {{ context|linkify:"name" }} {% if context.description %}
      {{ context.description }} {% endif %} From 422ec7ecec81bb55c9c81874bb6e8dedbec58986 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 6 May 2022 09:25:40 -0400 Subject: [PATCH 054/124] Fixes #9311: Permit creating contact assignment without a priority via the REST API --- docs/release-notes/version-3.2.md | 1 + netbox/tenancy/api/serializers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 1dadb3eba..ddd4c2488 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,7 @@ ### Bug Fixes * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices +* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API --- diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 8749dc63f..a2286efed 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -97,7 +97,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): object = serializers.SerializerMethodField(read_only=True) contact = NestedContactSerializer() role = NestedContactRoleSerializer(required=False, allow_null=True) - priority = ChoiceField(choices=ContactPriorityChoices, required=False) + priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='') class Meta: model = ContactAssignment From 9b4e016fe40f71f81dc8992b4fa379e722ac1b93 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 6 May 2022 09:47:52 -0400 Subject: [PATCH 055/124] Fixes #9306: Include VC master interfaces when selecting a LAG/bridge for a VC member interface --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/models.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ddd4c2488..665bfb99e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,7 @@ ### Bug Fixes * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices +* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API --- diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index c8ca1daf1..1d3677cce 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1295,6 +1295,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Restrict LAG/bridge interface assignment by device/VC + device_id = self.data['device'] if self.is_bound else self.initial.get('device') + device = Device.objects.filter(pk=device_id).first() + if device and device.virtual_chassis and device.virtual_chassis.master: + self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + class FrontPortForm(NetBoxModelForm): module = DynamicModelChoiceField( From 39a9ebaeee982dc787de5df3b42f66ac9fbe39d4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 6 May 2022 10:26:02 -0400 Subject: [PATCH 056/124] Fixes #9313: Remove HTML code from CSV output of many-to-many relationships --- docs/release-notes/version-3.2.md | 1 + netbox/circuits/tables/circuits.py | 2 +- netbox/circuits/tables/providers.py | 4 +- netbox/dcim/tables/devices.py | 2 +- netbox/dcim/tables/devicetypes.py | 2 +- netbox/dcim/tables/power.py | 2 +- netbox/dcim/tables/racks.py | 2 +- netbox/dcim/tables/sites.py | 10 +-- netbox/ipam/tables/ip.py | 2 +- netbox/netbox/tables/columns.py | 62 ++++++++++++------- netbox/tenancy/tables/tenants.py | 2 +- netbox/virtualization/tables/clusters.py | 4 +- .../virtualization/tables/virtualmachines.py | 2 +- 13 files changed, 57 insertions(+), 40 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 665bfb99e..7b9a9e4b2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -16,6 +16,7 @@ * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API +* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships --- diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index cb8c940b0..40f8918ae 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable): ) commit_rate = CommitRateColumn() comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index e97ade7d8..0ec6d439d 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) - asns = tables.ManyToManyColumn( + asns = columns.ManyToManyColumn( linkify_item=True, verbose_name='ASNs' ) @@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable): verbose_name='Circuits' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 25ad1415d..0f015b7f3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable): verbose_name='VC Priority' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c3064e7cd..2da9daee7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -43,7 +43,7 @@ class ManufacturerTable(NetBoxTable): verbose_name='Platforms' ) slug = tables.Column() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index cab95bb02..92c4bb0aa 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index e5a1c8488..e6368cb74 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -69,7 +69,7 @@ class RackTable(NetBoxTable): orderable=False, verbose_name='Power' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 84522480f..fa3c73e12 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -26,7 +26,7 @@ class RegionTable(NetBoxTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -86,7 +86,7 @@ class SiteTable(NetBoxTable): group = tables.Column( linkify=True ) - asns = tables.ManyToManyColumn( + asns = columns.ManyToManyColumn( linkify_item=True, verbose_name='ASNs' ) @@ -98,7 +98,7 @@ class SiteTable(NetBoxTable): ) tenant = TenantColumn() comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -137,7 +137,7 @@ class LocationTable(NetBoxTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 244bcee8e..475ad787e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -118,7 +118,7 @@ class ASNTable(NetBoxTable): url_params={'asn_id': 'pk'}, verbose_name='Provider Count' ) - sites = tables.ManyToManyColumn( + sites = columns.ManyToManyColumn( linkify_item=True, verbose_name='Sites' ) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index ba5583a2e..801b97766 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db.models import DateField, DateTimeField from django.template import Context, Template -from django.urls import NoReverseMatch, reverse +from django.urls import reverse from django.utils.formats import date_format from django.utils.safestring import mark_safe from django_tables2.columns import library @@ -27,6 +27,7 @@ __all__ = ( 'CustomLinkColumn', 'LinkedCountColumn', 'MarkdownColumn', + 'ManyToManyColumn', 'MPTTColumn', 'TagColumn', 'TemplateColumn', @@ -35,6 +36,10 @@ __all__ = ( ) +# +# Django-tables2 overrides +# + @library.register class DateColumn(tables.DateColumn): """ @@ -42,7 +47,6 @@ class DateColumn(tables.DateColumn): tables and null when exporting data. It is registered in the tables library to use this class instead of the default, making this behavior consistent in all fields of type DateField. """ - def value(self, value): return value @@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn): tables and null when exporting data. It is registered in the tables library to use this class instead of the default, making this behavior consistent in all fields of type DateTimeField. """ - def value(self, value): if value: return date_format(value, format="SHORT_DATETIME_FORMAT") @@ -71,6 +74,39 @@ class DateTimeColumn(tables.DateTimeColumn): return cls(**kwargs) +class ManyToManyColumn(tables.ManyToManyColumn): + """ + Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data. + """ + def value(self, value): + items = [self.transform(item) for item in self.filter(value)] + return self.separator.join(items) + + +class TemplateColumn(tables.TemplateColumn): + """ + Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value + is an empty string. + """ + PLACEHOLDER = mark_safe('—') + + def render(self, *args, **kwargs): + ret = super().render(*args, **kwargs) + if not ret.strip(): + return self.PLACEHOLDER + return ret + + def value(self, **kwargs): + ret = super().value(**kwargs) + if ret == self.PLACEHOLDER: + return '' + return ret + + +# +# Custom columns +# + class ToggleColumn(tables.CheckBoxColumn): """ Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. @@ -112,26 +148,6 @@ class BooleanColumn(tables.Column): return str(value) -class TemplateColumn(tables.TemplateColumn): - """ - Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value - is an empty string. - """ - PLACEHOLDER = mark_safe('—') - - def render(self, *args, **kwargs): - ret = super().render(*args, **kwargs) - if not ret.strip(): - return self.PLACEHOLDER - return ret - - def value(self, **kwargs): - ret = super().value(**kwargs) - if ret == self.PLACEHOLDER: - return '' - return ret - - @dataclass class ActionsItem: title: str diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 5577d90e0..8f18423be 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -38,7 +38,7 @@ class TenantTable(NetBoxTable): linkify=True ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index c9f87105d..a0c98425a 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -40,7 +40,7 @@ class ClusterGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Clusters' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -83,7 +83,7 @@ class ClusterTable(NetBoxTable): verbose_name='VMs' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index d5017eb53..89dbdf901 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -78,7 +78,7 @@ class VMInterfaceTable(BaseInterfaceTable): vrf = tables.Column( linkify=True ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( From af126fe7e353c259f867956601b6eb2183e91bf6 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Tue, 10 May 2022 17:50:33 +0200 Subject: [PATCH 057/124] Added form validation to model installation Raises a ValidationError whenever installation would cause a foreign key violation. --- netbox/dcim/forms/models.py | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 1d3677cce..81c798c71 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -675,6 +675,56 @@ class ModuleForm(NetBoxModelForm): return super().save(*args, **kwargs) + def clean(self): + super().clean() + + replicate_components = self.cleaned_data.get("replicate_components") + adopt_components = self.cleaned_data.get("adopt_components") + device = self.cleaned_data['device'] + module_type = self.cleaned_data['module_type'] + module_bay = self.cleaned_data['module_bay'] + + # Bail out if we are not installing a new module or if we are not replicating components + if self.instance.pk or not replicate_components: + return + + for templates, component_attribute in [ + ("consoleporttemplates", "consoleports"), + ("consoleserverporttemplates", "consoleserverports"), + ("interfacetemplates", "interfaces"), + ("powerporttemplates", "powerports"), + ("poweroutlettemplates", "poweroutlets"), + ("rearporttemplates", "rearports"), + ("frontporttemplates", "frontports") + ]: + # Prefetch installed components + installed_components = { + component.name: component for component in getattr(device, component_attribute).all() + } + + # Get the templates for the module type. + for template in getattr(module_type, templates).all(): + # Installing modules with placeholders require that the bay has a position value + if '{module}' in template.name and not module_bay.position: + raise forms.ValidationError( + "Cannot install module with placeholder values in a module bay with no position defined" + ) + + resolved_name = template.name.replace('{module}', module_bay.position) + existing_item = installed_components.get(resolved_name) + + # It is not possible to adopt components already belonging to a module + if adopt_components and existing_item and existing_item.module: + raise forms.ValidationError( + f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs to a module" + ) + + # If we are not adopting components we error if the component exists + if not adopt_components and resolved_name in installed_components: + raise forms.ValidationError( + f"{template.component_model.__name__} - {resolved_name} already exists" + ) + class CableForm(TenancyForm, NetBoxModelForm): From d858eceb387f65ec96d297def940205cddee7bf3 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Tue, 10 May 2022 17:53:01 +0200 Subject: [PATCH 058/124] Fix pep8 --- netbox/dcim/forms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 81c798c71..cd0be3096 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -718,7 +718,7 @@ class ModuleForm(NetBoxModelForm): raise forms.ValidationError( f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs to a module" ) - + # If we are not adopting components we error if the component exists if not adopt_components and resolved_name in installed_components: raise forms.ValidationError( From e759e123ac5f143d75ce636eee8e8f82f1387f6d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 08:09:51 -0400 Subject: [PATCH 059/124] Fixes #9333: Annotate unit on interface speed field --- netbox/dcim/models/device_components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a3b182da1..9a0609c12 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo ) speed = models.PositiveIntegerField( blank=True, - null=True + null=True, + verbose_name='Speed (Kbps)' ) duplex = models.CharField( max_length=50, From bdb21da26e06a764237199ef63325af9aec0bd92 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 08:57:19 -0400 Subject: [PATCH 060/124] Fixes #9330: Add missing module_type field to REST API serializers for modular device component templates --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/api/serializers.py | 103 ++++++++++++++++++++++++------ netbox/dcim/tests/test_api.py | 98 +++++++++++++++++++++++----- 3 files changed, 167 insertions(+), 35 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 7b9a9e4b2..eac616a2c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -17,6 +17,7 @@ * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API * [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships +* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 813c946a3..7fcab6ba3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer): class ConsolePortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', ] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', ] class PowerPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', + 'allocated_draw', 'description', 'created', 'last_updated', ] class PowerOutletTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'created', 'last_updated', ] class InterfaceTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=InterfaceTypeChoices) class Meta: model = InterfaceTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', - 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'created', 'last_updated', ] class RearPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=PortTypeChoices) class Meta: model = RearPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'description', 'created', 'last_updated', ] class FrontPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=PortTypeChoices) rear_port = NestedRearPortTemplateSerializer() class Meta: model = FrontPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'created', 'last_updated', ] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5c7d22955..22537abe0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) console_port_templates = ( ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'), @@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Console Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Console Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Console Port Template 7', + }, ] @@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) console_server_port_templates = ( ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'), @@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Console Server Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Console Server Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Console Server Port Template 7', + }, ] @@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) power_port_templates = ( PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), @@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Power Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Power Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Power Port Template 7', + }, ] @@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) power_port_templates = ( PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), @@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Power Outlet Template 6', 'power_port': None, }, + { + 'module_type': moduletype.pk, + 'name': 'Power Outlet Template 7', + }, + { + 'module_type': moduletype.pk, + 'name': 'Power Outlet Template 8', + }, ] @@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) interface_templates = ( InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'), @@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): 'type': '1000base-t', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Interface Template 6', 'type': '1000base-t', }, + { + 'module_type': moduletype.pk, + 'name': 'Interface Template 7', + 'type': '1000base-t', + }, ] @@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_port_templates) @@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): rear_port=rear_port_templates[1] ), FrontPortTemplate( - device_type=devicetype, - name='Front Port Template 3', + module_type=moduletype, + name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[2] + rear_port=rear_port_templates[4] + ), + FrontPortTemplate( + module_type=moduletype, + name='Front Port Template 6', + type=PortTypeChoices.TYPE_8P8C, + rear_port=rear_port_templates[5] ), ) FrontPortTemplate.objects.bulk_create(front_port_templates) cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Front Port Template 3', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_port_templates[2].pk, + 'rear_port_position': 1, + }, { 'device_type': devicetype.pk, 'name': 'Front Port Template 4', @@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'rear_port_position': 1, }, { - 'device_type': devicetype.pk, - 'name': 'Front Port Template 5', + 'module_type': moduletype.pk, + 'name': 'Front Port Template 7', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[4].pk, + 'rear_port': rear_port_templates[6].pk, 'rear_port_position': 1, }, { - 'device_type': devicetype.pk, - 'name': 'Front Port Template 6', + 'module_type': moduletype.pk, + 'name': 'Front Port Template 8', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[5].pk, + 'rear_port': rear_port_templates[7].pk, 'rear_port_position': 1, }, ] @@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), @@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Rear Port Template 6', 'type': PortTypeChoices.TYPE_8P8C, }, + { + 'module_type': moduletype.pk, + 'name': 'Rear Port Template 7', + 'type': PortTypeChoices.TYPE_8P8C, + }, ] From 22f186347518cbed8a5b8ecd4063872543b7c8c6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 09:12:07 -0400 Subject: [PATCH 061/124] Add security document --- SECURITY.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..b389dd2b3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## No Warranty + +Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release. + +## Recommendations + +Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as: + +* Do not expose your NetBox installation to the public Internet +* Do not permit multiple users to share an account +* Enforce minimum password complexity requirements for local accounts +* Prohibit access to your database from clients other than the NetBox application +* Keep your deployment updated to the most recent stable release + +## Reporting a Suspected Vulnerability + +If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions: + +* Affects the most recent stable release of NetBox, or a current beta release +* Affects a NetBox instance installed and configured per the official documentation +* Is reproducible following a prescribed set of instructions + +Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous. + +If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project. + +### Bug Bounties + +As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated. From cffc064a33498340ab5d5f9e5f3082591a92d9f5 Mon Sep 17 00:00:00 2001 From: devon-mar Date: Wed, 11 May 2022 07:27:50 -0700 Subject: [PATCH 062/124] Add device & vm to `FHRPGroupAssignmentFilterSet` (#9314) * Add device & vm to `FHRPGroupAssignmentFilterSet` * Apply suggestions from code review * Update netbox/ipam/tests/test_filtersets.py * Update netbox/ipam/filtersets.py Co-authored-by: Jeremy Stretch --- netbox/ipam/filtersets.py | 42 ++++++++++++++++++++++++++++ netbox/ipam/tests/test_filtersets.py | 14 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 53c589bb3..7839dc03e 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -681,11 +681,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): queryset=FHRPGroup.objects.all(), label='Group (ID)', ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', + label='Virtual machine (ID)', + ) class Meta: model = FHRPGroupAssignment fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{f'{name}__in': value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids) + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids) + ) + class VLANGroupFilterSet(OrganizationalModelFilterSet): scope_type = ContentTypeFilter() diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 4bb72dce2..198f9d62d 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1024,6 +1024,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'priority': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device(self): + device = Device.objects.first() + params = {'device': [device.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'device_id': [device.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_virtual_machine(self): + vm = VirtualMachine.objects.first() + params = {'virtual_machine': [vm.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'virtual_machine_id': [vm.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() From e8575495dbddd9e65e9e17b84b1ae3faed645985 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 10:31:04 -0400 Subject: [PATCH 063/124] Changelog for #9190, #9314 --- docs/release-notes/version-3.2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index eac616a2c..6eadb3d9e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -10,9 +10,11 @@ * [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list * [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module +* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments ### Bug Fixes +* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API From 1726593fb00f9e393322fb6c25ea6a0f48d53ee9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 10:37:04 -0400 Subject: [PATCH 064/124] Introduce MODULE_TOKEN constant --- netbox/dcim/constants.py | 2 ++ netbox/dcim/forms/models.py | 7 ++++--- netbox/dcim/models/device_component_templates.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 45844b049..38bf16f0b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # Device components # +MODULE_TOKEN = '{module}' + MODULAR_COMPONENT_TEMPLATE_MODELS = Q( app_label='dcim', model__in=( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index cd0be3096..179893219 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -705,18 +705,19 @@ class ModuleForm(NetBoxModelForm): # Get the templates for the module type. for template in getattr(module_type, templates).all(): # Installing modules with placeholders require that the bay has a position value - if '{module}' in template.name and not module_bay.position: + if MODULE_TOKEN in template.name and not module_bay.position: raise forms.ValidationError( "Cannot install module with placeholder values in a module bay with no position defined" ) - resolved_name = template.name.replace('{module}', module_bay.position) + resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position) existing_item = installed_components.get(resolved_name) # It is not possible to adopt components already belonging to a module if adopt_components and existing_item and existing_item.module: raise forms.ValidationError( - f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs to a module" + f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs " + f"to a module" ) # If we are not adopting components we error if the component exists diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 647abe148..92658d310 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel): def resolve_name(self, module): if module: - return self.name.replace('{module}', module.module_bay.position) + return self.name.replace(MODULE_TOKEN, module.module_bay.position) return self.name def resolve_label(self, module): if module: - return self.label.replace('{module}', module.module_bay.position) + return self.label.replace(MODULE_TOKEN, module.module_bay.position) return self.label From 6f5c2f1e2973e75e7c8772e1bddbcd703a6b8fd4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 14:13:50 -0400 Subject: [PATCH 065/124] Enable & document Sentry integration --- docs/administration/error-reporting.md | 27 ++++++++++++++++++++ docs/configuration/optional-settings.md | 33 +++++++++++++++++++++++++ docs/release-notes/version-3.2.md | 1 + mkdocs.yml | 1 + netbox/netbox/settings.py | 25 +++++++++++++++++++ 5 files changed, 87 insertions(+) create mode 100644 docs/administration/error-reporting.md diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md new file mode 100644 index 000000000..b2977bf39 --- /dev/null +++ b/docs/administration/error-reporting.md @@ -0,0 +1,27 @@ +# Error Reporting + +## Sentry + +NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this feature, begin by creating a new project in Sentry to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below: + +``` +https://examplePublicKey@o0.ingest.sentry.io/0 +``` + +Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters: + +```python +SENTRY_ENABLED = True +SENTRY_DSN = "https://YourDSNgoesHere@o0.ingest.sentry.io/0" +``` + +You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter: + +```python +SENTRY_TAGS = { + "custom.foo": "123", + "custom.bar": "abc", +} +``` + +Once the configuration has been saved, restart the NetBox service. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 76fd0a12c..4a2d62e91 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -404,6 +404,39 @@ The file path to the location where [custom scripts](../customization/custom-scr --- +## SENTRY_DSN + +Default: None + +Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: + +``` +SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" +``` + +--- + +## SENTRY_ENABLED + +Default: False + +Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). Requires `SENTRY_DSN` to be defined. + +--- + +## SENTRY_TAGS + +An optional dictionary of tags to apply to Sentry error reports. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: + +``` +SENTRY_TAGS = { + "custom.foo": "123", + "custom.bar": "abc", +} +``` + +--- + ## SESSION_COOKIE_NAME Default: `sessionid` diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6eadb3d9e..bfd432741 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -11,6 +11,7 @@ * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list * [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module * [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments +* [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry ### Bug Fixes diff --git a/mkdocs.yml b/mkdocs.yml index 225c6d4bf..0b7108cd0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -123,6 +123,7 @@ nav: - Microsoft Azure AD: 'administration/authentication/microsoft-azure-ad.md' - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' + - Error Reporting: 'administration/error-reporting.md' - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6dee1081a..e539ecbbe 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -8,9 +8,11 @@ import sys import warnings from urllib.parse import urlsplit +import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from sentry_sdk.integrations.django import DjangoIntegration from netbox.config import PARAMS @@ -113,6 +115,9 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') +SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) +SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) +SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') @@ -428,6 +433,26 @@ EXEMPT_PATHS = ( ) +# +# Sentry +# + +if SENTRY_ENABLED: + if not SENTRY_DSN: + raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.") + sentry_sdk.init( + dsn=SENTRY_DSN, + release=VERSION, + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, + https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None + ) + for k, v in SENTRY_TAGS.items(): + sentry_sdk.set_tag(k, v) + + # # Django social auth # From c14659656474fe46ef06a92b8b99562d3c19ee78 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 14:27:18 -0400 Subject: [PATCH 066/124] Implement a custom 404 handler to enable Sentry reporting --- docs/administration/error-reporting.md | 2 ++ netbox/netbox/urls.py | 1 + netbox/netbox/views/__init__.py | 14 +++++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md index b2977bf39..8ec83bdf0 100644 --- a/docs/administration/error-reporting.md +++ b/docs/administration/error-reporting.md @@ -25,3 +25,5 @@ SENTRY_TAGS = { ``` Once the configuration has been saved, restart the NetBox service. + +To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry. diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index e76efe0fe..e8ee4b7b6 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -100,4 +100,5 @@ urlpatterns = [ path('{}'.format(settings.BASE_PATH), include(_patterns)) ] +handler404 = 'netbox.views.handler_404' handler500 = 'netbox.views.server_error' diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index fad347c36..f159ee637 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -2,7 +2,6 @@ import platform import sys from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.db.models import F from django.http import HttpResponseServerError @@ -11,9 +10,10 @@ from django.template import loader from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse from django.views.decorators.csrf import requires_csrf_token -from django.views.defaults import ERROR_500_TEMPLATE_NAME +from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View from packaging import version +from sentry_sdk import capture_message from circuits.models import Circuit, Provider from dcim.models import ( @@ -190,13 +190,21 @@ class StaticMediaFailureView(View): """ Display a user-friendly error message with troubleshooting tips when a static media file fails to load. """ - def get(self, request): return render(request, 'media_failure.html', { 'filename': request.GET.get('filename') }) +def handler_404(request, exception): + """ + Wrap Django's default 404 handler to enable Sentry reporting. + """ + capture_message("Page not found", level="error") + + return page_not_found(request, exception) + + @requires_csrf_token def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): """ From 312d6c890e64b6d2d4699916ff8c53cb72147337 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 15:20:18 -0400 Subject: [PATCH 067/124] Add sentry-sdk as a dependency --- base_requirements.txt | 4 ++++ requirements.txt | 1 + 2 files changed, 5 insertions(+) diff --git a/base_requirements.txt b/base_requirements.txt index 095906914..6bb537a6a 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -102,6 +102,10 @@ psycopg2-binary # https://github.com/yaml/pyyaml PyYAML +# Sentry SDK +# https://github.com/getsentry/sentry-python +sentry-sdk + # Social authentication framework # https://github.com/python-social-auth/social-core social-auth-core diff --git a/requirements.txt b/requirements.txt index 32c13d455..1e40f0b3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ netaddr==0.8.0 Pillow==9.1.0 psycopg2-binary==2.9.3 PyYAML==6.0 +sentry-sdk==1.5.12 social-auth-app-django==5.0.0 social-auth-core==4.2.0 svgwrite==1.4.2 From e2a02de6e914cc6fd37c7c67570ddaaa88f19380 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 16:13:35 -0400 Subject: [PATCH 068/124] Remove erroneous field from prefetch --- netbox/ipam/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 79804aabd..078848b3e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -585,7 +585,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', + 'vrf', 'tenant', ) def get_extra_context(self, request, instance): From 01d2ede097b8f908f8a57302d868ae0e50ef4dcb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 16:22:07 -0400 Subject: [PATCH 069/124] Closes #1202: Support overlapping assignment of NAT IP addresses --- docs/release-notes/version-3.3.md | 8 ++++++++ netbox/ipam/api/serializers.py | 3 +-- .../0058_ipaddress_nat_inside_nonunique.py | 17 +++++++++++++++++ netbox/ipam/models/ip.py | 2 +- netbox/templates/ipam/ipaddress.html | 10 ++++++++-- 5 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 9b061b7d6..c46fceea5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,8 +2,13 @@ ## v3.3.0 (FUTURE) +### Breaking Changes + +* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). + ### Enhancements +* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results @@ -15,3 +20,6 @@ * extras.CustomField * Added `group_name` field +* ipam.IPAddress + * The `nat_inside` field no longer requires a unique value + * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3fa1bcc7e..ea5c37f91 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -360,7 +360,7 @@ class IPAddressSerializer(NetBoxModelSerializer): ) assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) - nat_outside = NestedIPAddressSerializer(required=False, read_only=True) + nat_outside = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = IPAddress @@ -369,7 +369,6 @@ class IPAddressSerializer(NetBoxModelSerializer): 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family', 'nat_outside'] @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, obj): diff --git a/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py new file mode 100644 index 000000000..63e93d137 --- /dev/null +++ b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a3b8fb2c1..db662f49c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -813,7 +813,7 @@ class IPAddress(NetBoxModel): ct_field='assigned_object_type', fk_field='assigned_object_id' ) - nat_inside = models.OneToOneField( + nat_inside = models.ForeignKey( to='self', on_delete=models.SET_NULL, related_name='nat_outside', diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7867e829b..96a76cf8c 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -91,8 +91,14 @@ - NAT (outside) - {{ object.nat_outside|linkify|placeholder }} + Outside NAT IPs + + {% for ip in object.nat_outside.all %} + {{ ip|linkify }}
      + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
      From 991950650b22141d9e6bca28c53cedcf877a7c2f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 16:44:26 -0400 Subject: [PATCH 070/124] Add Sentry as a sponsor --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d75c2c1a5..60f007946 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ The complete documentation for NetBox can be found at [docs.netbox.dev](https://            [![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
      + [![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/) +            [![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
      From 4cefe26f80488016247ca79978e0ece8f582ef60 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 09:35:13 -0400 Subject: [PATCH 071/124] #9340: Add default Sentry DSN --- docs/administration/error-reporting.md | 23 ++++++++++++++++++++--- docs/configuration/optional-settings.md | 5 ++++- netbox/netbox/settings.py | 10 +++++++++- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md index 8ec83bdf0..e04372338 100644 --- a/docs/administration/error-reporting.md +++ b/docs/administration/error-reporting.md @@ -2,7 +2,17 @@ ## Sentry -NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this feature, begin by creating a new project in Sentry to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below: +### Enabling Error Reporting + +NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis. + +```python +SENTRY_ENABLED = True +``` + +### Using a Custom DSN + +If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below: ``` https://examplePublicKey@o0.ingest.sentry.io/0 @@ -12,9 +22,11 @@ Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` fi ```python SENTRY_ENABLED = True -SENTRY_DSN = "https://YourDSNgoesHere@o0.ingest.sentry.io/0" +SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" ``` +### Assigning Tags + You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter: ```python @@ -24,6 +36,11 @@ SENTRY_TAGS = { } ``` +!!! warning "Reserved tag prefixes" + Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. + +### Testing + Once the configuration has been saved, restart the NetBox service. -To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry. +To test Sentry operation, try generating a 404 (page not found) error by navigating to an invalid URL, such as `https://netbox/404-error-testing`. (Be sure that debug mode has been disabled.) After receiving a 404 response from the NetBox server, you should see the issue appear shortly in Sentry. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 4a2d62e91..58f8cb526 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -426,7 +426,7 @@ Set to True to enable automatic error reporting via [Sentry](https://sentry.io/) ## SENTRY_TAGS -An optional dictionary of tags to apply to Sentry error reports. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: +An optional dictionary of tag names and values to apply to Sentry error reports. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: ``` SENTRY_TAGS = { @@ -435,6 +435,9 @@ SENTRY_TAGS = { } ``` +!!! warning "Reserved tag prefixes" + Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. + --- ## SESSION_COOKIE_NAME diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e539ecbbe..2dfbf9be6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,3 +1,4 @@ +import hashlib import importlib import logging import os @@ -42,6 +43,7 @@ if sys.version_info < (3, 8): f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" ) +DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485' # # Configuration import @@ -115,7 +117,7 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') -SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) +SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) @@ -451,6 +453,12 @@ if SENTRY_ENABLED: ) for k, v in SENTRY_TAGS.items(): sentry_sdk.set_tag(k, v) + # If using the default DSN, append a unique deployment ID tag for error correlation + if SENTRY_DSN == DEFAULT_SENTRY_DSN: + sentry_sdk.set_tag( + 'netbox.deployment_id', + hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] + ) # From 72b2ab03cc11abcf97984f3f0eac027b618066e2 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 10:00:57 -0400 Subject: [PATCH 072/124] #9340: Introduce config parameters for Sentry sampling rates --- docs/configuration/error-reporting.md | 54 +++++++++++++++++++++++++ docs/configuration/optional-settings.md | 36 ----------------- mkdocs.yml | 1 + netbox/netbox/settings.py | 19 ++++++--- 4 files changed, 69 insertions(+), 41 deletions(-) create mode 100644 docs/configuration/error-reporting.md diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md new file mode 100644 index 000000000..d1c47e2fb --- /dev/null +++ b/docs/configuration/error-reporting.md @@ -0,0 +1,54 @@ +# Error Reporting Settings + +## SENTRY_DSN + +Default: None + +Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: + +``` +SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" +``` + +--- + +## SENTRY_ENABLED + +Default: False + +Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). + +--- + +## SENTRY_SAMPLE_RATE + +Default: 1.0 (all) + +The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (report on all errors). + +--- + +## SENTRY_TAGS + +An optional dictionary of tag names and values to apply to Sentry error reports.For example: + +``` +SENTRY_TAGS = { + "custom.foo": "123", + "custom.bar": "abc", +} +``` + +!!! warning "Reserved tag prefixes" + Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. + +--- + +## SENTRY_TRACES_SAMPLE_RATE + +Default: 0 (disabled) + +The sampling rate for transactions. Must be a value between 0 (disabled) and 1.0 (report on all transactions). + +!!! warning "Consider performance implications" + A high sampling rate for transactions can induce significant performance penalties. If transaction reporting is desired, it is recommended to use a relatively low sample rate of 10% to 20% (0.1 to 0.2). diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 58f8cb526..76fd0a12c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -404,42 +404,6 @@ The file path to the location where [custom scripts](../customization/custom-scr --- -## SENTRY_DSN - -Default: None - -Defines a Sentry data source name (DSN) for automated error reporting. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: - -``` -SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0" -``` - ---- - -## SENTRY_ENABLED - -Default: False - -Set to True to enable automatic error reporting via [Sentry](https://sentry.io/). Requires `SENTRY_DSN` to be defined. - ---- - -## SENTRY_TAGS - -An optional dictionary of tag names and values to apply to Sentry error reports. `SENTRY_ENABLED` must be True for this parameter to take effect. For example: - -``` -SENTRY_TAGS = { - "custom.foo": "123", - "custom.bar": "abc", -} -``` - -!!! warning "Reserved tag prefixes" - Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application. - ---- - ## SESSION_COOKIE_NAME Default: `sessionid` diff --git a/mkdocs.yml b/mkdocs.yml index 0b7108cd0..5c973e0d6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Required Settings: 'configuration/required-settings.md' - Optional Settings: 'configuration/optional-settings.md' - Dynamic Settings: 'configuration/dynamic-settings.md' + - Error Reporting: 'configuration/error-reporting.md' - Remote Authentication: 'configuration/remote-authentication.md' - Core Functionality: - IP Address Management: 'core-functionality/ipam.md' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2dfbf9be6..fafcf35a1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -72,6 +72,9 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') +# Calculate a unique deployment ID from the secret key +DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] + # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) @@ -119,6 +122,8 @@ RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) +SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) +SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') @@ -442,23 +447,27 @@ EXEMPT_PATHS = ( if SENTRY_ENABLED: if not SENTRY_DSN: raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.") + # If using the default DSN, force sampling rates + if SENTRY_DSN == DEFAULT_SENTRY_DSN: + SENTRY_SAMPLE_RATE = 1.0 + SENTRY_TRACES_SAMPLE_RATE = 0 + # Initialize the SDK sentry_sdk.init( dsn=SENTRY_DSN, release=VERSION, integrations=[DjangoIntegration()], - traces_sample_rate=1.0, + sample_rate=SENTRY_SAMPLE_RATE, + traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, send_default_pii=True, http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None ) + # Assign any configured tags for k, v in SENTRY_TAGS.items(): sentry_sdk.set_tag(k, v) # If using the default DSN, append a unique deployment ID tag for error correlation if SENTRY_DSN == DEFAULT_SENTRY_DSN: - sentry_sdk.set_tag( - 'netbox.deployment_id', - hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] - ) + sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID) # From c4c93ee34649140e9793fc4869ceea9ad7a47010 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 10:17:29 -0400 Subject: [PATCH 073/124] Closes #9343: Add Ubiquiti SmartPower power outlet type --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/choices.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index bfd432741..d3010d215 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,7 @@ * [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module * [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments * [#9340](https://github.com/netbox-community/netbox/issues/9340) - Introduce support for error reporting via Sentry +* [#9343](https://github.com/netbox-community/netbox/issues/9343) - Add Ubiquiti SmartPower power outlet type ### Bug Fixes diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index e369201b4..5bc53bf88 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -575,6 +575,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a' TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1' TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top' + TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' @@ -683,6 +684,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), + (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'), )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), From 37903776fd7e2af4f3029ba43760e76d76c25939 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 10:41:29 -0400 Subject: [PATCH 074/124] Fixes #9296: Improve Markdown link sanitization --- docs/release-notes/version-3.2.md | 1 + netbox/utilities/templatetags/builtins/filters.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d3010d215..cc5c41f9c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices +* [#9296](https://github.com/netbox-community/netbox/issues/9296) - Improve Markdown link sanitization * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API * [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 4a3db0a3c..1c1258d5c 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -150,11 +150,11 @@ def render_markdown(value): value = strip_tags(value) # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)' + pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) # Sanitize Markdown reference links - pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)' + pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)' value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown From ad12ad4a773f1aca74b14f75f9ce5d870db42d13 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 11:05:34 -0400 Subject: [PATCH 075/124] Closes #9221: Add definition list support for Markdown --- docs/release-notes/version-3.2.md | 1 + netbox/utilities/templatetags/builtins/filters.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index cc5c41f9c..8297ab1e0 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -7,6 +7,7 @@ * [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users * [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group * [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade +* [#9221](https://github.com/netbox-community/netbox/issues/9221) - Add definition list support for Markdown * [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list * [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 1c1258d5c..44ad5ac47 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -158,7 +158,7 @@ def render_markdown(value): value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown - html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()]) + html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) # If the string is not empty wrap it in rendered-markdown to style tables if html: From 5f3695d2d06f2bc04e1404a2bab8b8bea386c889 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 12:18:58 -0400 Subject: [PATCH 076/124] Closes #8805: Add "mixed" option for device airflow indication --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/choices.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 8297ab1e0..17918601a 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,7 @@ ### Enhancements +* [#8805](https://github.com/netbox-community/netbox/issues/8805) - Add "mixed" option for device airflow indication * [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users * [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group * [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 5bc53bf88..a89960457 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -159,6 +159,7 @@ class DeviceAirflowChoices(ChoiceSet): AIRFLOW_RIGHT_TO_LEFT = 'right-to-left' AIRFLOW_SIDE_TO_REAR = 'side-to-rear' AIRFLOW_PASSIVE = 'passive' + AIRFLOW_MIXED = 'mixed' CHOICES = ( (AIRFLOW_FRONT_TO_REAR, 'Front to rear'), @@ -167,6 +168,7 @@ class DeviceAirflowChoices(ChoiceSet): (AIRFLOW_RIGHT_TO_LEFT, 'Right to left'), (AIRFLOW_SIDE_TO_REAR, 'Side to rear'), (AIRFLOW_PASSIVE, 'Passive'), + (AIRFLOW_MIXED, 'Mixed'), ) From a6aec9ebac82478c630c170af90b1838112b36a4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 13:53:26 -0400 Subject: [PATCH 077/124] Release v3.2.3 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.2.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 2d6ca5700..df5ac6e81 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.2 + placeholder: v3.2.3 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 13b162741..422b87f52 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.2 + placeholder: v3.2.3 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 17918601a..0c56c92f7 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,6 @@ # NetBox v3.2 -## v3.2.3 (FUTURE) +## v3.2.3 (2022-05-12) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index fafcf35a1..999e39479 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.2.3-dev' +VERSION = '3.2.3' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 1e40f0b3f..0a15fcf20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==4.0.4 -django-cors-headers==3.11.0 +django-cors-headers==3.12.0 django-debug-toolbar==3.2.4 django-filter==21.1 django-graphiql-debug-toolbar==0.2.0 @@ -16,9 +16,9 @@ drf-yasg[validation]==1.20.0 graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 -Markdown==3.3.6 +Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.11 +mkdocs-material==8.2.14 mkdocstrings[python-legacy]==0.18.1 netaddr==0.8.0 Pillow==9.1.0 From 3c7c8c8776e35d82706420bab9f1a93f1c528913 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 12 May 2022 14:14:40 -0400 Subject: [PATCH 078/124] PRVB --- docs/release-notes/version-3.2.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 0c56c92f7..408d572c7 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.4 (FUTURE) + +--- + ## v3.2.3 (2022-05-12) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 999e39479..59306b8fa 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.2.3' +VERSION = '3.2.4-dev' # Hostname HOSTNAME = platform.node() From 1d4409c70323f366e73e562f129f07c0170cf5cb Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:08:00 -0500 Subject: [PATCH 079/124] Fixes #9094 - Fix partial address search within Prefix and Aggregate filters --- docs/release-notes/version-3.2.md | 5 +++++ netbox/ipam/filtersets.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 408d572c7..df7436e04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.4 (FUTURE) +### Bug Fixes + +* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters + + --- ## v3.2.3 (2022-05-12) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 7839dc03e..3416e72eb 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -148,6 +148,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -337,6 +338,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) From 752a497218060e0012892e441d5d8e1e5cc7f4a7 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:08:00 -0500 Subject: [PATCH 080/124] Fixes #9094 - Fix partial address search within Prefix and Aggregate filters --- docs/release-notes/version-3.2.md | 5 +++++ netbox/ipam/filtersets.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 408d572c7..df7436e04 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.4 (FUTURE) +### Bug Fixes + +* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters + + --- ## v3.2.3 (2022-05-12) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 7839dc03e..bdb7c463d 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -145,6 +145,7 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) @@ -334,6 +335,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) From 24ff360ee0c8413aed423a5f9e84fe667a716110 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:40:24 -0500 Subject: [PATCH 081/124] Fixes #8922 - Add service list to IP address view --- docs/release-notes/version-3.2.md | 4 ++++ netbox/ipam/views.py | 3 +++ netbox/templates/ipam/ipaddress.html | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index df7436e04..ef68aab09 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,10 @@ ## v3.2.4 (FUTURE) +### Enhancements + +* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view + ### Bug Fixes * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 79804aabd..d5c1e670e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -674,11 +674,14 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) + services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'more_duplicate_ips': duplicate_ips.count() > 10, 'related_ips_table': related_ips_table, + 'services': services, } diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7867e829b..ab47c11af 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -128,6 +128,24 @@
      {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
      +
      +
      + Services +
      +
      + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
      + {% else %} +
      + None +
      + {% endif %} +
      +
      {% plugin_right_page object %}
      From f415d810491d9eb1791eb38a2d9bec5d0b1c8aee Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 13 May 2022 09:49:07 -0500 Subject: [PATCH 082/124] Fixes #8374 - Display device type and asset tag if name is blank but asset tag is populated --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/models/devices.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ef68aab09..46a22fb6c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,7 @@ ### Enhancements +* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view ### Bug Fixes diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d50db958..e88af2d05 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -748,8 +748,12 @@ class Device(NetBoxModel, ConfigContextModel): return f'{self.name} ({self.asset_tag})' elif self.name: return self.name + elif self.virtual_chassis and self.asset_tag: + return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})' elif self.virtual_chassis: return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' + elif self.device_type and self.asset_tag: + return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' elif self.device_type: return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() From 6a99b36cce106a25cfb16057a94cc2d62cc12c02 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 14 May 2022 12:01:49 +0200 Subject: [PATCH 083/124] Fix provider table in ASN view when ordering by circuit_count --- netbox/ipam/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d5c1e670e..84f6db6d5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,7 +4,7 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from circuits.models import Provider +from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site @@ -225,7 +225,9 @@ class ASNView(generic.ObjectView): sites_table.configure(request) # Gather assigned Providers - providers = instance.providers.restrict(request.user, 'view') + providers = instance.providers.restrict(request.user, 'view').annotate( + count_circuits=count_related(Circuit, 'provider') + ) providers_table = ProviderTable(providers, user=request.user) providers_table.configure(request) From aba4e03d3b60ac21e1d727fcfd6308066dcb1fbc Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 14 May 2022 17:48:37 +0200 Subject: [PATCH 084/124] Add contact_group to ContactModelFilterSet --- netbox/circuits/forms/filtersets.py | 4 ++-- netbox/dcim/forms/filtersets.py | 10 +++++----- netbox/tenancy/filtersets.py | 6 ++++++ netbox/tenancy/forms/forms.py | 5 +++++ netbox/virtualization/forms/filtersets.py | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index ca3b003b9..46d3824bb 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -87,7 +87,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('type_id', 'status', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0c7d02f9d..9a41e71cb 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (None, ('q', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) status = MultipleChoiceField( choices=SiteStatusChoices, @@ -168,7 +168,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF (None, ('q', 'tag')), ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -214,7 +214,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -518,7 +518,7 @@ class DeviceFilterForm( ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), @@ -788,7 +788,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 8ca4ae29c..dd14a412b 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet): queryset=ContactRole.objects.all(), label='Contact Role' ) + contact_group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contacts__contact__group', + lookup_expr='in', + label='Contact group', + ) # diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 5dcad1d43..5e78bc540 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form): required=False, label=_('Contact Role') ) + contact_group = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Contact Group') + ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f386e889..670729d56 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -38,7 +38,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi ('Attributes', ('group_id', 'type_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), @@ -87,7 +87,7 @@ class VirtualMachineFilterForm( ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), From 8ad203f97ac3362fc312558997fa543011590bf5 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 14 May 2022 17:53:40 +0200 Subject: [PATCH 085/124] Added contact_group to region, site, manufacturer, tenant filters --- netbox/dcim/forms/filtersets.py | 6 +++--- netbox/tenancy/forms/filtersets.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 9a41e71cb..6998b40e8 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -329,7 +329,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( (None, ('q', 'tag')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 15d7773b7..02589d733 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( (None, ('q', 'tag', 'group_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), From 17fb5627401284a6cf32d90ad7671cd135425419 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 16 May 2022 09:55:17 -0400 Subject: [PATCH 086/124] #9239: Organize contact form fields --- netbox/virtualization/forms/filtersets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 670729d56..88aa1a6c2 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -29,6 +29,10 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm): class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ) class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): From 9e1d8beaf0f5cb87c238424d6f4276d42aa656f5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 16 May 2022 09:56:02 -0400 Subject: [PATCH 087/124] Changelog for #9239, #9358 --- docs/release-notes/version-3.2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 46a22fb6c..991972899 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,10 +6,12 @@ * [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view +* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment ### Bug Fixes * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters +* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view --- From e5aa9d47f740eae5cb5c56389ce0a00c06db9318 Mon Sep 17 00:00:00 2001 From: bluikko <14869000+bluikko@users.noreply.github.com> Date: Wed, 18 May 2022 15:08:08 +0700 Subject: [PATCH 088/124] Add other power, front/rear port types Fixes #9098 --- netbox/dcim/choices.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a89960457..2e96f9c67 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -354,6 +354,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -471,6 +472,7 @@ class PowerPortTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -580,6 +582,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -690,6 +693,7 @@ class PowerOutletTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -1047,6 +1051,7 @@ class PortTypeChoices(ChoiceSet): TYPE_URM_P2 = 'urm-p2' TYPE_URM_P4 = 'urm-p4' TYPE_URM_P8 = 'urm-p8' + TYPE_OTHER = 'other' CHOICES = ( ( @@ -1099,6 +1104,12 @@ class PortTypeChoices(ChoiceSet): (TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P8, 'URM-P8'), (TYPE_SPLICE, 'Splice'), + ), + ), + ( + 'Other', + ( + (TYPE_OTHER, 'Other'), ) ) ) From 3b3247592e7b6aa7e97434f70f960ed861c6d682 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 18 May 2022 08:42:20 -0400 Subject: [PATCH 089/124] Changelog for #9098 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 991972899..14e2639f4 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view +* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment ### Bug Fixes From 64146b8cb1fa39b7c7ea7dbab900546c46c8504e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 19 May 2022 16:13:22 -0400 Subject: [PATCH 090/124] Closes #8471: Add status field to Cluster --- docs/models/virtualization/cluster.md | 2 +- docs/release-notes/version-3.3.md | 3 +++ netbox/virtualization/api/serializers.py | 5 +++-- netbox/virtualization/choices.py | 22 +++++++++++++++++++ netbox/virtualization/filtersets.py | 4 ++++ netbox/virtualization/forms/bulk_edit.py | 8 ++++++- netbox/virtualization/forms/bulk_import.py | 6 ++++- netbox/virtualization/forms/filtersets.py | 6 ++++- netbox/virtualization/forms/models.py | 8 +++++-- .../migrations/0030_cluster_status.py | 18 +++++++++++++++ netbox/virtualization/models.py | 8 +++++++ netbox/virtualization/tables/clusters.py | 7 +++--- netbox/virtualization/tests/test_api.py | 11 +++++++--- .../virtualization/tests/test_filtersets.py | 10 ++++++--- netbox/virtualization/tests/test_views.py | 16 ++++++++------ 15 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 netbox/virtualization/migrations/0030_cluster_status.py diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 7fc9bfc06..3e3516cd6 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index c46fceea5..d1b6b4cda 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results @@ -23,3 +24,5 @@ * ipam.IPAddress * The `nat_inside` field no longer requires a unique value * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses +* virtualization.Cluster + * Add required `status` field (default value: `active`) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index afdf50b96..e127bd5fa 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -45,6 +45,7 @@ class ClusterSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) + status = ChoiceField(choices=ClusterStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) @@ -53,8 +54,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 693e53df6..2cf6357e1 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -1,6 +1,28 @@ from utilities.choices import ChoiceSet +# +# Clusters +# + +class ClusterStatusChoices(ChoiceSet): + key = 'Cluster.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] + + # # VirtualMachines # diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 5a2aa8b42..63e3557a3 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label='Cluster type (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=ClusterStatusChoices, + null_value=None + ) class Meta: model = Cluster diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..e7369f53a 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=ClusterGroup.objects.all(), required=False ) + status = forms.ChoiceField( + choices=add_blank_choice(ClusterStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'tenant',)), + (None, ('type', 'group', 'status', 'tenant',)), ('Site', ('region', 'site_group', 'site',)), ) nullable_fields = ( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index eab6fc9e7..ef688367e 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm): required=False, help_text='Assigned cluster group' ) + status = CSVChoiceField( + choices=ClusterStatusChoices, + help_text='Operational status' + ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f386e889..753f509f7 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -35,7 +35,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi model = Cluster fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('group_id', 'type_id')), + ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role')), @@ -50,6 +50,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) + status = MultipleChoiceField( + choices=ClusterStatusChoices, + required=False + ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..a94cc3920 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) + widgets = { + 'status': StaticSelect(), + } class ClusterAddDevicesForm(BootstrapMixin, forms.Form): diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py new file mode 100644 index 000000000..e836bb914 --- /dev/null +++ b/netbox/virtualization/migrations/0030_cluster_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-19 19:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0029_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 586bb8a9e..afc450ddd 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -119,6 +119,11 @@ class Cluster(NetBoxModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -165,6 +170,9 @@ class Cluster(NetBoxModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index a0c98425a..dfcae052a 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = tables.Column( linkify=True ) @@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count', + 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') + default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index f6c07fa54..4d559dc49 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -4,6 +4,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases +from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -85,6 +86,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { + 'status': 'offline', 'comments': 'New comment', } @@ -104,9 +106,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ClusterGroup.objects.bulk_create(cluster_groups) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), ) Cluster.objects.bulk_create(clusters) @@ -115,16 +117,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): 'name': 'Cluster 4', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 5', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 6', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 9e264ac5c..8b4e79bed 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]), + Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]), + Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]), ) Cluster.objects.bulk_create(clusters) @@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): types = ClusterType.objects.all()[:2] params = {'type_id': [types[0].pk, types[1].pk]} diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 8edc14f00..df90bfc37 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ClusterType.objects.bulk_create(clustertypes) Cluster.objects.bulk_create([ - Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]), + Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Cluster X', 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', @@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,type", - "Cluster 4,Cluster Type 1", - "Cluster 5,Cluster Type 1", - "Cluster 6,Cluster Type 1", + "name,type,status", + "Cluster 4,Cluster Type 1,active", + "Cluster 5,Cluster Type 1,active", + "Cluster 6,Cluster Type 1,active", ) cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'New comments', From 05702038915cf93d77fb2d75898a484257844a83 Mon Sep 17 00:00:00 2001 From: lastorel Date: Sun, 22 May 2022 17:22:28 +0300 Subject: [PATCH 091/124] add role attribute to filter inventoryitems --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 6998b40e8..1535e5718 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1102,7 +1102,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), + ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) role_id = DynamicModelMultipleChoiceField( From 20eaa7d069b20bc8b123ef270ffd4ba3ea410105 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 24 May 2022 10:12:32 +0200 Subject: [PATCH 092/124] #9166 - Add UI Visibility setting for custom fields --- netbox/extras/api/serializers.py | 3 ++- netbox/extras/choices.py | 13 +++++++++++++ netbox/extras/filtersets.py | 4 +++- netbox/extras/forms/bulk_edit.py | 7 +++++++ netbox/extras/forms/bulk_import.py | 2 +- netbox/extras/forms/customfields.py | 7 +++++++ netbox/extras/forms/filtersets.py | 8 +++++++- netbox/extras/forms/models.py | 3 ++- .../0075_customfield_ui_visibility.py | 18 ++++++++++++++++++ netbox/extras/models/customfields.py | 6 ++++++ netbox/extras/tables/tables.py | 3 ++- netbox/extras/tests/test_views.py | 9 +++++---- netbox/netbox/models/features.py | 10 +++++++--- netbox/templates/extras/customfield.html | 4 ++++ 14 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 netbox/extras/migrations/0075_customfield_ui_visibility.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index eed7f7603..1a26faec1 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() + ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) class Meta: model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'choices', 'created', 'last_updated', + 'validation_regex', 'choices', 'created', 'last_updated', 'ui_visibility', ] def get_data_type(self, obj): diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index f14368d3d..123fd2cd4 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) +class CustomFieldVisibilityChoices(ChoiceSet): + + VISIBILITY_READ_WRITE = 'read-write' + VISIBILITY_READ_ONLY = 'read-only' + VISIBILITY_HIDDEN = 'hidden' + + CHOICES = ( + (VISIBILITY_READ_WRITE, 'Read/Write'), + (VISIBILITY_READ_ONLY, 'Read-only'), + (VISIBILITY_HIDDEN, 'Hidden'), + ) + + # # CustomLinks # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 467ae23af..ea74dfc82 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -62,7 +62,9 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description'] + fields = [ + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description', 'ui_visibility' + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b722bd751..b1d8a6c21 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm): weight = forms.IntegerField( required=False ) + ui_visibility = forms.ChoiceField( + label="UI visibility", + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + initial='', + widget=StaticSelect() + ) nullable_fields = ('group_name', 'description',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index dabf2f811..c0483d36e 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -37,7 +37,7 @@ class CustomFieldCSVForm(CSVModelForm): model = CustomField fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', - 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index bb8028eec..c4496c5f8 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from extras.models import * +from extras.choices import CustomFieldVisibilityChoices __all__ = ( 'CustomFieldsMixin', @@ -42,8 +43,14 @@ class CustomFieldsMixin: Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + self.fields[field_name].disabled = True + # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1710ecb89..cd59a9db1 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -32,7 +32,7 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')), + ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + ui_visibility = forms.ChoiceField( + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + label=_('UI Visibility'), + widget=StaticSelect() + ) class CustomLinkFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index b07853f86..16874c49e 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -41,7 +41,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): fieldsets = ( ('Custom Field', ( - 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', 'ui_visibility', )), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), @@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), + 'ui_visibility': StaticSelect(), } diff --git a/netbox/extras/migrations/0075_customfield_ui_visibility.py b/netbox/extras/migrations/0075_customfield_ui_visibility.py new file mode 100644 index 000000000..29ee65516 --- /dev/null +++ b/netbox/extras/migrations/0075_customfield_ui_visibility.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-23 20:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0074_customfield_group_name'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='ui_visibility', + field=models.CharField(default='read-write', max_length=50), + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 55caa4a70..c48b6895c 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -136,6 +136,12 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) + ui_visibility = models.CharField( + max_length=50, + choices=CustomFieldVisibilityChoices, + default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + help_text='Specifies the visibility of custom field in the UI.' + ) objects = CustomFieldManager() class Meta: diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 1a0f5d58a..d294fd231 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable): ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() + ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', + 'description', 'filter_logic', 'choices', 'created', 'last_updated', 'ui_visibility', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ea3a952d6..0a9d85e15 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -36,13 +36,14 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, + 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', + 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3},read-write', + 'field5,Field 5,integer,dcim.site,100,exact,,1,100,,read-write', + 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,,read-write', ) cls.bulk_edit_data = { diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 4bd1b0e9c..76b546192 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -9,7 +9,7 @@ from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager -from extras.choices import ObjectChangeActionChoices +from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import register_features from netbox.signals import post_clean from utilities.utils import serialize_object @@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model): """ return self.custom_field_data - def get_custom_fields(self): + def get_custom_fields(self, omit_hidden=False): """ Return a dictionary of custom fields for a single object in the form `{field: value}`. @@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + value = self.custom_field_data.get(field.name) data[field] = field.deserialize(value) @@ -124,7 +128,7 @@ class CustomFieldsMixin(models.Model): Return a dictionary of custom field/value mappings organized by group. """ grouped_custom_fields = defaultdict(dict) - for cf, value in self.get_custom_fields().items(): + for cf, value in self.get_custom_fields(omit_hidden=True).items(): grouped_custom_fields[cf.group_name][cf] = value return dict(grouped_custom_fields) diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index dc51d3e82..72dc2e4c3 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -42,6 +42,10 @@ Weight {{ object.weight }} + + UI Visibility + {{ object.get_ui_visibility_display }} +
      From c14a2a0a392b2dc408e9d08ffc92db1ca0911d81 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 24 May 2022 10:27:29 +0200 Subject: [PATCH 093/124] Exclude hidden custom fields from tables --- netbox/netbox/tables/tables.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 5ebb78865..66f5e1f7e 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink +from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -178,7 +179,10 @@ class NetBoxTable(BaseTable): # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) + custom_fields = CustomField.objects.filter( + content_types=content_type + ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) From 6e7c5dcaede247b9e65a9b22cf372ef5df59dab4 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Tue, 24 May 2022 10:38:55 +0200 Subject: [PATCH 094/124] Remove whitespace from blank line --- netbox/netbox/tables/tables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 66f5e1f7e..38399b5fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -182,7 +182,7 @@ class NetBoxTable(BaseTable): custom_fields = CustomField.objects.filter( content_types=content_type ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) - + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) From a73dda35e8c2e387ccf079de8b79ca2f47fb4f20 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 08:39:43 -0400 Subject: [PATCH 095/124] Bump stale to v5 --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8099923f..7390ec1df 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4 + - uses: actions/stale@v5 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an From f03c5037c4ba39d1575d352efd3c1ce82dbd5589 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:14:25 -0400 Subject: [PATCH 096/124] Fixes #9387: Ensure ActionsColumn extra_buttons are always displayed --- docs/release-notes/version-3.2.md | 1 + netbox/netbox/tables/columns.py | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 14e2639f4..7c5e454ef 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -13,6 +13,7 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view +* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed --- diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 801b97766..0c26e541e 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -192,32 +192,35 @@ class ActionsColumn(tables.Column): model = table.Meta.model request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' + html = '' + # Compile actions menu links = [] user = getattr(request, 'user', AnonymousUser()) for action, attrs in self.actions.items(): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append(f'
    • ' - f' {attrs.title}
    • ') - - if not links: - return '' - - menu = f'' \ - f'' \ - f'' \ - f'' + links.append( + f'
    • ' + f' {attrs.title}
    • ' + ) + if links: + html += ( + f'' + f'' + f'' + f'' + ) # Render any extra buttons from template code if self.extra_buttons: template = Template(self.extra_buttons) context = getattr(table, "context", Context()) context.update({'record': record}) - menu = template.render(context) + menu + html = template.render(context) + html - return mark_safe(menu) + return mark_safe(html) class ChoiceFieldColumn(tables.Column): From a9ec1a7b4e56957bdb3f629421c49fa66be1feb5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:20:05 -0400 Subject: [PATCH 097/124] Closes #9379: Redirect to virtual chassis view after adding a member device --- docs/release-notes/version-3.2.md | 1 + netbox/templates/dcim/virtualchassis.html | 122 +++++++++++----------- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 7c5e454ef..7497a7374 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view * [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device ### Bug Fixes diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 4683b775b..1ff9f2e9a 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -15,74 +15,70 @@ {% block content %}
      -
      -
      - Virtual Chassis -
      -
      - - - - - - - - - -
      Domain{{ object.domain|placeholder }}
      Master{{ object.master|linkify }}
      -
      -
      - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
      +
      Virtual Chassis
      +
      + + + + + + + + + +
      Domain{{ object.domain|placeholder }}
      Master{{ object.master|linkify }}
      +
      +
      + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
      -
      -
      - Members -
      -
      - - - - - - - - {% for vc_member in members %} - - - - - - - {% endfor %} -
      DevicePositionMasterPriority
      - {{ vc_member|linkify }} - - {% badge vc_member.vc_position show_empty=True %} - - {% if object.master == vc_member %} - {% checkmark True %} - {% endif %} - - {{ vc_member.vc_priority|placeholder }} -
      -
      - {% if perms.dcim.change_virtualchassis %} - - {% endif %} +
      +
      Members
      +
      + + + + + + + + {% for vc_member in members %} + + + + + + + {% endfor %} +
      DevicePositionMasterPriority
      + {{ vc_member|linkify }} + + {% badge vc_member.vc_position show_empty=True %} + + {% if object.master == vc_member %} + {% checkmark True %} + {% endif %} + + {{ vc_member.vc_priority|placeholder }} +
      - {% plugin_right_page object %} + {% if perms.dcim.change_virtualchassis %} + + {% endif %} +
      + {% plugin_right_page object %}
      -
      - {% plugin_full_width_page object %} -
      +
      + {% plugin_full_width_page object %} +
      {% endblock %} From 662b02e2d8659d72fe0c4271171c9b45b096e281 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:49:36 -0400 Subject: [PATCH 098/124] Closes #9347: Include services in global search --- docs/release-notes/version-3.2.md | 1 + netbox/netbox/constants.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 7497a7374..cc558cd20 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view * [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search * [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device ### Bug Fixes diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e054dc9da..4b080276f 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -16,10 +16,11 @@ from dcim.tables import ( RackReservationTable, SiteTable, VirtualChassisTable, ) from ipam.filtersets import ( - AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet, + AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, ServiceFilterSet, VLANFilterSet, + VRFFilterSet, ) -from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF -from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF +from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, ServiceTable, VLANTable, VRFTable from tenancy.filtersets import ContactFilterSet, TenantFilterSet from tenancy.models import Contact, Tenant, ContactAssignment from tenancy.tables import ContactTable, TenantTable @@ -191,6 +192,12 @@ IPAM_TYPES = OrderedDict( 'table': ASNTable, 'url': 'ipam:asn_list', }), + ('service', { + 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), + 'filterset': ServiceFilterSet, + 'table': ServiceTable, + 'url': 'ipam:service_list', + }), ) ) From 72726c784a125bf1829fa55a78b107d24fb361ee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 09:56:14 -0400 Subject: [PATCH 099/124] Clean up imports --- netbox/netbox/constants.py | 129 +++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 69 deletions(-) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 4b080276f..8ca0d98c1 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,33 +1,24 @@ from collections import OrderedDict from typing import Dict -from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet +import circuits.filtersets +import circuits.tables +import dcim.filtersets +import dcim.tables +import ipam.filtersets +import ipam.tables +import tenancy.filtersets +import tenancy.tables +import virtualization.filtersets +import virtualization.tables from circuits.models import Circuit, ProviderNetwork, Provider -from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable -from dcim.filtersets import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet, - PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet, -) from dcim.models import ( Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, ) -from dcim.tables import ( - CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable, - RackReservationTable, SiteTable, VirtualChassisTable, -) -from ipam.filtersets import ( - AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, ServiceFilterSet, VLANFilterSet, - VRFFilterSet, -) from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF -from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, ServiceTable, VLANTable, VRFTable -from tenancy.filtersets import ContactFilterSet, TenantFilterSet from tenancy.models import Contact, Tenant, ContactAssignment -from tenancy.tables import ContactTable, TenantTable from utilities.utils import count_related -from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine -from virtualization.tables import ClusterTable, VirtualMachineTable SEARCH_MAX_RESULTS = 15 @@ -37,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict( 'queryset': Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ), - 'filterset': ProviderFilterSet, - 'table': ProviderTable, + 'filterset': circuits.filtersets.ProviderFilterSet, + 'table': circuits.tables.ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' ), - 'filterset': CircuitFilterSet, - 'table': CircuitTable, + 'filterset': circuits.filtersets.CircuitFilterSet, + 'table': circuits.tables.CircuitTable, 'url': 'circuits:circuit_list', }), ('providernetwork', { 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': ProviderNetworkFilterSet, - 'table': ProviderNetworkTable, + 'filterset': circuits.filtersets.ProviderNetworkFilterSet, + 'table': circuits.tables.ProviderNetworkTable, 'url': 'circuits:providernetwork_list', }), ) @@ -63,22 +54,22 @@ DCIM_TYPES = OrderedDict( ( ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), - 'filterset': SiteFilterSet, - 'table': SiteTable, + 'filterset': dcim.filtersets.SiteFilterSet, + 'table': dcim.tables.SiteTable, 'url': 'dcim:site_list', }), ('rack', { 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( device_count=count_related(Device, 'rack') ), - 'filterset': RackFilterSet, - 'table': RackTable, + 'filterset': dcim.filtersets.RackFilterSet, + 'table': dcim.tables.RackTable, 'url': 'dcim:rack_list', }), ('rackreservation', { 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), - 'filterset': RackReservationFilterSet, - 'table': RackReservationTable, + 'filterset': dcim.filtersets.RackReservationFilterSet, + 'table': dcim.tables.RackReservationTable, 'url': 'dcim:rackreservation_list', }), ('location', { @@ -95,60 +86,60 @@ DCIM_TYPES = OrderedDict( 'rack_count', cumulative=True ).prefetch_related('site'), - 'filterset': LocationFilterSet, - 'table': LocationTable, + 'filterset': dcim.filtersets.LocationFilterSet, + 'table': dcim.tables.LocationTable, 'url': 'dcim:location_list', }), ('devicetype', { 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ), - 'filterset': DeviceTypeFilterSet, - 'table': DeviceTypeTable, + 'filterset': dcim.filtersets.DeviceTypeFilterSet, + 'table': dcim.tables.DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), - 'filterset': DeviceFilterSet, - 'table': DeviceTable, + 'filterset': dcim.filtersets.DeviceFilterSet, + 'table': dcim.tables.DeviceTable, 'url': 'dcim:device_list', }), ('moduletype', { 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Module, 'module_type') ), - 'filterset': ModuleTypeFilterSet, - 'table': ModuleTypeTable, + 'filterset': dcim.filtersets.ModuleTypeFilterSet, + 'table': dcim.tables.ModuleTypeTable, 'url': 'dcim:moduletype_list', }), ('module', { 'queryset': Module.objects.prefetch_related( 'module_type__manufacturer', 'device', 'module_bay', ), - 'filterset': ModuleFilterSet, - 'table': ModuleTable, + 'filterset': dcim.filtersets.ModuleFilterSet, + 'table': dcim.tables.ModuleTable, 'url': 'dcim:module_list', }), ('virtualchassis', { 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( member_count=count_related(Device, 'virtual_chassis') ), - 'filterset': VirtualChassisFilterSet, - 'table': VirtualChassisTable, + 'filterset': dcim.filtersets.VirtualChassisFilterSet, + 'table': dcim.tables.VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { 'queryset': Cable.objects.all(), - 'filterset': CableFilterSet, - 'table': CableTable, + 'filterset': dcim.filtersets.CableFilterSet, + 'table': dcim.tables.CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { 'queryset': PowerFeed.objects.all(), - 'filterset': PowerFeedFilterSet, - 'table': PowerFeedTable, + 'filterset': dcim.filtersets.PowerFeedFilterSet, + 'table': dcim.tables.PowerFeedTable, 'url': 'dcim:powerfeed_list', }), ) @@ -158,44 +149,44 @@ IPAM_TYPES = OrderedDict( ( ('vrf', { 'queryset': VRF.objects.prefetch_related('tenant'), - 'filterset': VRFFilterSet, - 'table': VRFTable, + 'filterset': ipam.filtersets.VRFFilterSet, + 'table': ipam.tables.VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': AggregateFilterSet, - 'table': AggregateTable, + 'filterset': ipam.filtersets.AggregateFilterSet, + 'table': ipam.tables.AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), - 'filterset': PrefixFilterSet, - 'table': PrefixTable, + 'filterset': ipam.filtersets.PrefixFilterSet, + 'table': ipam.tables.PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), - 'filterset': IPAddressFilterSet, - 'table': IPAddressTable, + 'filterset': ipam.filtersets.IPAddressFilterSet, + 'table': ipam.tables.IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), - 'filterset': VLANFilterSet, - 'table': VLANTable, + 'filterset': ipam.filtersets.VLANFilterSet, + 'table': ipam.tables.VLANTable, 'url': 'ipam:vlan_list', }), ('asn', { 'queryset': ASN.objects.prefetch_related('rir', 'tenant'), - 'filterset': ASNFilterSet, - 'table': ASNTable, + 'filterset': ipam.filtersets.ASNFilterSet, + 'table': ipam.tables.ASNTable, 'url': 'ipam:asn_list', }), ('service', { 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), - 'filterset': ServiceFilterSet, - 'table': ServiceTable, + 'filterset': ipam.filtersets.ServiceFilterSet, + 'table': ipam.tables.ServiceTable, 'url': 'ipam:service_list', }), ) @@ -205,15 +196,15 @@ TENANCY_TYPES = OrderedDict( ( ('tenant', { 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': TenantFilterSet, - 'table': TenantTable, + 'filterset': tenancy.filtersets.TenantFilterSet, + 'table': tenancy.tables.TenantTable, 'url': 'tenancy:tenant_list', }), ('contact', { 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': ContactFilterSet, - 'table': ContactTable, + 'filterset': tenancy.filtersets.ContactFilterSet, + 'table': tenancy.tables.ContactTable, 'url': 'tenancy:contact_list', }), ) @@ -226,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict( device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ), - 'filterset': ClusterFilterSet, - 'table': ClusterTable, + 'filterset': virtualization.filtersets.ClusterFilterSet, + 'table': virtualization.tables.ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), - 'filterset': VirtualMachineFilterSet, - 'table': VirtualMachineTable, + 'filterset': virtualization.filtersets.VirtualMachineFilterSet, + 'table': virtualization.tables.VirtualMachineTable, 'url': 'virtualization:virtualmachine_list', }), ) From d34d5869bec206c84137fd63cea5031d2d8c93d6 Mon Sep 17 00:00:00 2001 From: tyler-8 <17618971+tyler-8@users.noreply.github.com> Date: Tue, 24 May 2022 10:57:38 -0400 Subject: [PATCH 100/124] Add optional CSRF_COOKIE_NAME setting, update example config, and docs. --- docs/configuration/optional-settings.md | 8 ++++++++ netbox/netbox/configuration_example.py | 3 +++ netbox/netbox/settings.py | 1 + 3 files changed, 12 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 76fd0a12c..e53a14aa1 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_COOKIE_NAME + +Default: `csrftoken` + +The name of the cookie to use for the CSRF authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. + +--- + ## CSRF_TRUSTED_ORIGINS Default: `[]` diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index c82749e3f..ad0dcc7c3 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300 # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' +# The name to use for the csrf token cookie. +CSRF_COOKIE_NAME = 'csrftoken' + # The name to use for the session cookie. SESSION_COOKIE_NAME = 'sessionid' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 59306b8fa..524557db6 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -84,6 +84,7 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') From 2e5a5f71ba4622b2989d892d4215e2aefe59b91c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 16:00:18 -0400 Subject: [PATCH 101/124] Changelog for #9277 --- docs/configuration/optional-settings.md | 2 +- docs/release-notes/version-3.2.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index e53a14aa1..670cf524b 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -70,7 +70,7 @@ CORS_ORIGIN_WHITELIST = [ Default: `csrftoken` -The name of the cookie to use for the CSRF authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. +The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. --- diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index cc558cd20..c5b224359 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -8,6 +8,7 @@ * [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view * [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports * [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter * [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search * [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device From 31024ce6724a4c93a21e4b9e0bc2c6eb564d7dd0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 24 May 2022 16:39:05 -0400 Subject: [PATCH 102/124] Changelog & cleanup for #9166 --- docs/release-notes/version-3.3.md | 3 ++- netbox/extras/api/serializers.py | 4 ++-- netbox/extras/filtersets.py | 3 ++- netbox/extras/forms/bulk_import.py | 3 ++- netbox/extras/forms/customfields.py | 4 ++++ netbox/extras/forms/filtersets.py | 2 +- netbox/extras/forms/models.py | 4 ++-- netbox/extras/models/customfields.py | 3 ++- netbox/extras/tables/tables.py | 2 +- netbox/netbox/models/features.py | 2 +- netbox/templates/extras/customfield.html | 8 ++++---- 11 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index d1b6b4cda..cefab428e 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -12,6 +12,7 @@ * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields ### Other Changes @@ -20,7 +21,7 @@ ### REST API Changes * extras.CustomField - * Added `group_name` field + * Added `group_name` and `ui_visibility` fields * ipam.IPAddress * The `nat_inside` field no longer requires a unique value * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 1a26faec1..cb317d6c7 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -90,8 +90,8 @@ class CustomFieldSerializer(ValidatedModelSerializer): model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'choices', 'created', 'last_updated', 'ui_visibility', + 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index ea74dfc82..b59e28018 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -63,7 +63,8 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField fields = [ - 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description', 'ui_visibility' + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', + 'description', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index c0483d36e..95de7a2fe 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -37,7 +37,8 @@ class CustomFieldCSVForm(CSVModelForm): model = CustomField fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', - 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'ui_visibility', + 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index c4496c5f8..4cf8b5e0a 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -51,6 +51,10 @@ class CustomFieldsMixin: if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: self.fields[field_name].disabled = True + if self.fields[field_name].help_text: + self.fields[field_name].help_text += '
      ' + self.fields[field_name].help_text += ' ' \ + 'Field is set to read-only.' # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index cd59a9db1..aaeb45dbe 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -59,7 +59,7 @@ class CustomFieldFilterForm(FilterForm): ui_visibility = forms.ChoiceField( choices=add_blank_choice(CustomFieldVisibilityChoices), required=False, - label=_('UI Visibility'), + label=_('UI visibility'), widget=StaticSelect() ) diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 16874c49e..ab423e2fb 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -41,9 +41,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): fieldsets = ( ('Custom Field', ( - 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', 'ui_visibility', + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', )), - ('Behavior', ('filter_logic',)), + ('Behavior', ('filter_logic', 'ui_visibility')), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index c48b6895c..c91f96c15 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -140,7 +140,8 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): max_length=50, choices=CustomFieldVisibilityChoices, default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - help_text='Specifies the visibility of custom field in the UI.' + verbose_name='UI visibility', + help_text='Specifies the visibility of custom field in the UI' ) objects = CustomFieldManager() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d294fd231..540034696 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -34,7 +34,7 @@ class CustomFieldTable(NetBoxTable): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', 'ui_visibility', + 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 76b546192..817da526b 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -125,7 +125,7 @@ class CustomFieldsMixin(models.Model): def get_custom_fields_by_group(self): """ - Return a dictionary of custom field/value mappings organized by group. + Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. """ grouped_custom_fields = defaultdict(dict) for cf, value in self.get_custom_fields(omit_hidden=True).items(): diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 72dc2e4c3..aca0b5012 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -42,6 +42,10 @@ Weight {{ object.weight }} + + Filter Logic + {{ object.get_filter_logic_display }} + UI Visibility {{ object.get_ui_visibility_display }} @@ -69,10 +73,6 @@ {% endif %} - - Filter Logic - {{ object.get_filter_logic_display }} -
      From b331f047afb197fb131bcc58cc1c18d0a5ceddee Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 25 May 2022 16:01:10 -0400 Subject: [PATCH 103/124] Closes #8222: Enable the assignment of a VM to a specific host device within a cluster --- docs/models/virtualization/virtualmachine.md | 2 +- docs/release-notes/version-3.3.md | 5 +++- .../virtualization/virtualmachine.html | 14 +++++---- netbox/utilities/testing/utils.py | 4 +-- netbox/virtualization/api/serializers.py | 17 ++++++----- netbox/virtualization/api/views.py | 2 +- netbox/virtualization/filtersets.py | 12 +++++++- netbox/virtualization/forms/bulk_edit.py | 13 +++++++-- netbox/virtualization/forms/bulk_import.py | 10 +++++-- netbox/virtualization/forms/filtersets.py | 9 ++++-- netbox/virtualization/forms/models.py | 13 +++++++-- .../migrations/0031_virtualmachine_device.py | 20 +++++++++++++ netbox/virtualization/models.py | 13 +++++++++ .../virtualization/tables/virtualmachines.py | 5 +++- netbox/virtualization/tests/test_api.py | 12 ++++++-- .../virtualization/tests/test_filtersets.py | 29 ++++++++++++++----- netbox/virtualization/tests/test_views.py | 23 ++++++++++----- 17 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 netbox/virtualization/migrations/0031_virtualmachine_device.py diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index de9b5f214..b903ea131 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,6 +1,6 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster, and may optionally be assigned to a particular host device within that cluster. Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index cefab428e..6f07ea87d 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results @@ -26,4 +27,6 @@ * The `nat_inside` field no longer requires a unique value * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses * virtualization.Cluster - * Add required `status` field (default value: `active`) + * Added required `status` field (default value: `active`) +* virtualization.VirtualMachine + * Added `device` field diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..ac8409e09 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -78,9 +78,7 @@
      -
      - Cluster -
      +
      Cluster
      @@ -96,13 +94,17 @@ + + + +
      Cluster Type {{ object.cluster.type }}
      Device + {{ object.device|linkify|placeholder }} +
      -
      - Resources -
      +
      Resources
      diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 466b5e22b..6157d342d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,7 +34,7 @@ def post_data(data): return ret -def create_test_device(name): +def create_test_device(name, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ @@ -42,7 +42,7 @@ def create_test_device(name): manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs) return device diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index e127bd5fa..d12d9affd 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,7 +1,9 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, +) from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN @@ -68,6 +70,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) cluster = NestedClusterSerializer() + device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) @@ -78,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -90,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 665114881..d86241b4f 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 63e3557a3..3e1d50da4 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -150,6 +150,16 @@ class VirtualMachineFilterSet( to_field_name='name', label='Cluster', ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='cluster__site__region', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index e7369f53a..67126d6c7 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import VLAN, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -110,6 +110,13 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): queryset=Cluster.objects.all(), required=False ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } + ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -146,11 +153,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'status', 'role', 'tenant', 'platform')), + (None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index ef688367e..41f9b3773 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,5 @@ from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Platform, Site +from dcim.models import Device, DeviceRole, Platform, Site from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant @@ -76,6 +76,12 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): to_field_name='name', help_text='Assigned cluster' ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned device within cluster' + ) role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -100,7 +106,7 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 753f509f7..b3da87f7a 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm @@ -87,7 +87,7 @@ class VirtualMachineFilterForm( model = VirtualMachine fieldsets = ( (None, ('q', 'tag')), - ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -110,6 +110,11 @@ class VirtualMachineFilterForm( required=False, label=_('Cluster') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index a94cc3920..dba12d64d 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -179,6 +179,13 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): 'group_id': '$cluster_group' } ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } + ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, @@ -197,7 +204,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), + ('Cluster', ('cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -207,8 +214,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', + 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " diff --git a/netbox/virtualization/migrations/0031_virtualmachine_device.py b/netbox/virtualization/migrations/0031_virtualmachine_device.py new file mode 100644 index 000000000..407d60e79 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_device.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.4 on 2022-05-25 19:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index afc450ddd..51dbc9f43 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -200,6 +200,13 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): on_delete=models.PROTECT, related_name='virtual_machines' ) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -316,6 +323,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Validate assigned cluster device + if self.device and self.device not in self.cluster.devices.all(): + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).' + }) + # Validate primary IP addresses interfaces = self.interfaces.all() for field in ['primary_ip4', 'primary_ip6']: diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 89dbdf901..80eb0b37f 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -33,6 +33,9 @@ class VirtualMachineTable(NetBoxTable): cluster = tables.Column( linkify=True ) + device = tables.Column( + linkify=True + ) role = columns.ColoredLabelColumn() tenant = TenantColumn() comments = columns.MarkdownColumn() @@ -56,7 +59,7 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 4d559dc49..887781e01 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -3,7 +3,7 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from ipam.models import VLAN, VRF -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -152,8 +152,15 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): ) Cluster.objects.bulk_create(clusters) + device1 = create_test_device('device1') + device1.cluster = clusters[0] + device1.save() + device2 = create_test_device('device2') + device2.cluster = clusters[1] + device2.save() + virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=device1, local_context_data={'A': 1}), VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), ) @@ -163,6 +170,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): { 'name': 'Virtual Machine 4', 'cluster': clusters[1].pk, + 'device': device2.pk, }, { 'name': 'Virtual Machine 5', diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 8b4e79bed..3fd43d0c1 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,9 +1,9 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * from virtualization.filtersets import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -225,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -252,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ) DeviceRole.objects.bulk_create(roles) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + create_test_device('device3', cluster=clusters[2]), + ) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -268,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) @@ -331,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cluster': [clusters[0].name, clusters[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index df90bfc37..4b1d64de5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,7 +5,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN, VRF -from utilities.testing import ViewTestCases, create_tags +from utilities.testing import ViewTestCases, create_tags, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -176,16 +176,22 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Cluster.objects.bulk_create(clusters) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + ) + VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -202,14 +208,15 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster", - "Virtual Machine 4,active,Cluster 1", - "Virtual Machine 5,active,Cluster 1", - "Virtual Machine 6,active,Cluster 1", + "name,status,cluster,device", + "Virtual Machine 4,active,Cluster 1,device1", + "Virtual Machine 5,active,Cluster 1,device1", + "Virtual Machine 6,active,Cluster 1,", ) cls.bulk_edit_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'status': VirtualMachineStatusChoices.STATUS_STAGED, From db42589cca93fedb15757ad3a32e03afa0611a18 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 26 May 2022 14:59:49 -0400 Subject: [PATCH 104/124] Closes #5303: A virtual machine may be assigned to a site and/or cluster --- docs/models/virtualization/virtualmachine.md | 2 +- docs/release-notes/version-3.3.md | 3 ++ .../virtualization/virtualmachine.html | 8 +++- netbox/utilities/testing/utils.py | 5 ++- netbox/virtualization/api/serializers.py | 4 +- netbox/virtualization/api/views.py | 2 +- netbox/virtualization/filtersets.py | 11 +++-- netbox/virtualization/forms/bulk_edit.py | 19 ++++++--- netbox/virtualization/forms/bulk_import.py | 10 ++++- netbox/virtualization/forms/models.py | 13 ++++-- .../migrations/0031_virtualmachine_device.py | 20 ---------- .../0031_virtualmachine_site_device.py | 28 +++++++++++++ .../0032_virtualmachine_update_sites.py | 27 +++++++++++++ netbox/virtualization/models.py | 33 ++++++++++++--- .../virtualization/tables/virtualmachines.py | 9 +++-- netbox/virtualization/tests/test_api.py | 35 ++++++++++------ .../virtualization/tests/test_filtersets.py | 6 +-- netbox/virtualization/tests/test_models.py | 40 ++++++++++++++++--- netbox/virtualization/tests/test_views.py | 34 ++++++++++------ 19 files changed, 223 insertions(+), 86 deletions(-) delete mode 100644 netbox/virtualization/migrations/0031_virtualmachine_device.py create mode 100644 netbox/virtualization/migrations/0031_virtualmachine_site_device.py create mode 100644 netbox/virtualization/migrations/0032_virtualmachine_update_sites.py diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index b903ea131..4ddffb99a 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,6 +1,6 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster, and may optionally be assigned to a particular host device within that cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster. Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 6f07ea87d..63fd9731f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping @@ -30,3 +31,5 @@ * Added required `status` field (default value: `active`) * virtualization.VirtualMachine * Added `device` field + * The `site` field is now directly writable (rather than being inferred from the assigned cluster) + * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned. diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ac8409e09..2831a452a 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -81,13 +81,19 @@
      Cluster
      + + + + diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 6157d342d..52ccd002d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,11 +34,12 @@ def post_data(data): return ret -def create_test_device(name, **attrs): +def create_test_device(name, site=None, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + if site is None: + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index d12d9affd..bd01b5533 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -68,8 +68,8 @@ class ClusterSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) - site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index d86241b4f..d2a90ae34 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 3e1d50da4..00d3e2313 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -162,37 +162,36 @@ class VirtualMachineFilterSet( ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', label='Site group (ID)', ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', to_field_name='slug', label='Site group (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 67126d6c7..88dee3978 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -106,9 +106,16 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect(), ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } ) device = DynamicModelChoiceField( queryset=Device.objects.all(), @@ -153,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'device', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) @@ -236,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): # See 5643 if 'pk' in self.initial: site = None - interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( - 'virtual_machine__cluster__site' + interfaces = VMInterface.objects.filter( + pk__in=self.initial['pk'] + ).prefetch_related( + 'virtual_machine__site' ) # Check interface sites. First interface should set site, further interfaces will either continue the diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 41f9b3773..2d7ee52e2 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -71,9 +71,16 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): choices=VirtualMachineStatusChoices, help_text='Operational status' ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned site' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', + required=False, help_text='Assigned cluster' ) device = CSVModelChoiceField( @@ -106,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', ) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index dba12d64d..cfafd7e39 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -165,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm): class VirtualMachineForm(TenancyForm, NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -176,7 +179,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), query_params={ - 'group_id': '$cluster_group' + 'site_id': '$site', + 'group_id': '$cluster_group', } ) device = DynamicModelChoiceField( @@ -204,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster', 'device')), + ('Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -214,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', 'platform', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " diff --git a/netbox/virtualization/migrations/0031_virtualmachine_device.py b/netbox/virtualization/migrations/0031_virtualmachine_device.py deleted file mode 100644 index 407d60e79..000000000 --- a/netbox/virtualization/migrations/0031_virtualmachine_device.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-25 19:30 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0153_created_datetimefield'), - ('virtualization', '0030_cluster_status'), - ] - - operations = [ - migrations.AddField( - model_name='virtualmachine', - name='device', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), - ), - ] diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py new file mode 100644 index 000000000..85ea24455 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'), + ), + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'), + ), + ] diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py new file mode 100644 index 000000000..e9c52bfde --- /dev/null +++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def update_virtualmachines_site(apps, schema_editor): + """ + Automatically set the site for all virtual machines. + """ + VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') + + virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False) + for vm in virtual_machines: + vm.site = vm.cluster.site + VirtualMachine.objects.bulk_update(virtual_machines, ['site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0031_virtualmachine_site_device'), + ] + + operations = [ + migrations.RunPython( + code=update_virtualmachines_site, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 51dbc9f43..02560a962 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -195,10 +195,19 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.PROTECT, - related_name='virtual_machines' + related_name='virtual_machines', + blank=True, + null=True ) device = models.ForeignKey( to='dcim.Device', @@ -291,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ - 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', + 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ] class Meta: @@ -323,6 +332,22 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Must be assigned to a site and/or cluster + if not self.site and not self.cluster: + raise ValidationError({ + 'cluster': f'A virtual machine must be assigned to a site and/or cluster.' + }) + + # Validate site for cluster & device + if self.cluster and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).' + }) + if self.device and self.device.site != self.site: + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).' + }) + # Validate assigned cluster device if self.device and self.device not in self.cluster.devices.all(): raise ValidationError({ @@ -357,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): else: return None - @property - def site(self): - return self.cluster.site - # # Interfaces diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 80eb0b37f..0fe2571b1 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -30,6 +30,9 @@ class VirtualMachineTable(NetBoxTable): linkify=True ) status = columns.ChoiceFieldColumn() + site = tables.Column( + linkify=True + ) cluster = tables.Column( linkify=True ) @@ -59,11 +62,11 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', + 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', ) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 887781e01..b2ae68860 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,6 +2,7 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices +from dcim.models import Site from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.choices import * @@ -146,39 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + clusters = ( - Cluster(name='Cluster 1', type=clustertype, group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 3', type=clustertype), ) Cluster.objects.bulk_create(clusters) - device1 = create_test_device('device1') - device1.cluster = clusters[0] - device1.save() - device2 = create_test_device('device2') - device2.cluster = clusters[1] - device2.save() + device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) + device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=device1, local_context_data={'A': 1}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}), ) VirtualMachine.objects.bulk_create(virtual_machines) cls.create_data = [ { 'name': 'Virtual Machine 4', + 'site': sites[1].pk, 'cluster': clusters[1].pk, 'device': device2.pk, }, { 'name': 'Virtual Machine 5', + 'site': sites[1].pk, 'cluster': clusters[1].pk, }, { 'name': 'Virtual Machine 6', - 'cluster': clusters[1].pk, + 'site': sites[1].pk, + }, + { + 'name': 'Virtual Machine 7', + 'cluster': clusters[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 3fd43d0c1..d3ff12887 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -274,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 3b4d73a30..df5816efa 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -1,21 +1,19 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from dcim.models import Site from virtualization.models import * from tenancy.models import Tenant class VirtualMachineTestCase(TestCase): - def setUp(self): - - cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1') - self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type) - def test_vm_duplicate_name_per_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) vm1 = VirtualMachine( - cluster=self.cluster, + cluster=cluster, name='Test VM 1' ) vm1.save() @@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase): # Two VMs assigned to the same Cluster and different Tenants should pass validation vm2.full_clean() vm2.save() + + def test_vm_mismatched_site_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, site=None), + ) + Cluster.objects.bulk_create(clusters) + + # VM with site only should pass + VirtualMachine(name='vm1', site=sites[0]).full_clean() + + # VM with non-site cluster only should pass + VirtualMachine(name='vm1', cluster=clusters[2]).full_clean() + + # VM with mismatched site & cluster should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean() + + # VM with cluster site but no direct site should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 4b1d64de5..01d4394f3 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -168,23 +168,29 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype), - Cluster(name='Cluster 2', type=clustertype), + Cluster(name='Cluster 1', type=clustertype, site=sites[0]), + Cluster(name='Cluster 2', type=clustertype, site=sites[1]), ) Cluster.objects.bulk_create(clusters) devices = ( - create_test_device('device1', cluster=clusters[0]), - create_test_device('device2', cluster=clusters[1]), + create_test_device('device1', site=sites[0], cluster=clusters[0]), + create_test_device('device2', site=sites[1], cluster=clusters[1]), ) VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -192,6 +198,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'cluster': clusters[1].pk, 'device': devices[1].pk, + 'site': sites[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -208,13 +215,14 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster,device", - "Virtual Machine 4,active,Cluster 1,device1", - "Virtual Machine 5,active,Cluster 1,device1", - "Virtual Machine 6,active,Cluster 1,", + "name,status,site,cluster,device", + "Virtual Machine 4,active,Site 1,Cluster 1,device1", + "Virtual Machine 5,active,Site 1,Cluster 1,device1", + "Virtual Machine 6,active,Site 1,Cluster 1,", ) cls.bulk_edit_data = { + 'site': sites[1].pk, 'cluster': clusters[1].pk, 'device': devices[1].pk, 'tenant': None, @@ -252,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) virtualmachines = ( - VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole), - VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole), ) VirtualMachine.objects.bulk_create(virtualmachines) From 6d3cded57934327f3d4167069be41fde860ee7dd Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Fri, 27 May 2022 20:41:50 +0200 Subject: [PATCH 105/124] Make sure initial data is passed as array for DynamicModelChoiceFields --- netbox/utilities/forms/fields/dynamic.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index f83fc6a7c..dc3bab9fc 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -88,7 +88,12 @@ class DynamicModelChoiceMixin: # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() + if data: + # When the field is multiple choice pass the data as a list if it's not already + if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list: + data = [data] + field_name = getattr(self, 'to_field_name') or 'pk' filter = self.filter(field_name=field_name) try: From fe899d9d7cdb458298b92c2f46792adaf211851d Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 28 May 2022 11:29:18 +0200 Subject: [PATCH 106/124] Iterate base classes when searching for ScriptVariables --- netbox/extras/scripts.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4332d72f7..29fab5be8 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -306,9 +306,16 @@ class BaseScript: @classmethod def _get_vars(cls): vars = {} - for name, attr in cls.__dict__.items(): - if name not in vars and issubclass(attr.__class__, ScriptVariable): - vars[name] = attr + + # Iterate all base classes looking for ScriptVariables + for base_class in inspect.getmro(cls): + # When object is reached there's no reason to continue + if base_class is object: + break + + for name, attr in base_class.__dict__.items(): + if name not in vars and issubclass(attr.__class__, ScriptVariable): + vars[name] = attr # Order variables according to field_order field_order = getattr(cls.Meta, 'field_order', None) From a0a87fc4c09e3e19d573925dbdf6f07370edfec0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 09:14:23 -0400 Subject: [PATCH 107/124] Changelog for #9420, #9430 --- docs/release-notes/version-3.2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index c5b224359..3a46e060f 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -17,6 +17,8 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance +* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields --- From 998a392bd3f67ee13c01cb099a189cc09c797385 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 11:37:30 -0400 Subject: [PATCH 108/124] Fixes #9425: Fix bulk import for object and multi-object custom fields --- docs/release-notes/version-3.2.md | 1 + netbox/extras/forms/bulk_import.py | 11 +++++++++-- netbox/extras/tests/test_views.py | 9 +++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3a46e060f..8fdfafdb7 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed * [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance +* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fa6d8af55..878b83c7c 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm): choices=CustomFieldTypeChoices, help_text='Field data type (e.g. text, integer, etc.)' ) + object_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False, + help_text="Object type (for object or multi-object fields)" + ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, @@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'name', 'label', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', + 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', + 'validation_regex', ) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ea3a952d6..1cfc4b3cc 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -39,10 +39,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', + 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', + 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3}', + 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,', + 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,', + 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,', ) cls.bulk_edit_data = { From 5838a9f3a00b32900ce8ef62fa0936fd18cf0636 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 12:01:33 -0400 Subject: [PATCH 109/124] Closes #9451: Add export_raw argument for TemplateColumn --- docs/plugins/development/tables.md | 3 ++- docs/release-notes/version-3.2.md | 1 + netbox/netbox/tables/columns.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index 77e258def..6dccb4ee2 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl ::: netbox.tables.TemplateColumn selection: - members: false + members: + - __init__ diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 8fdfafdb7..b4efda4dc 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -11,6 +11,7 @@ * [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter * [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search * [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device +* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn ### Bug Fixes diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 0c26e541e..e82e8a1ea 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn): """ PLACEHOLDER = mark_safe('—') + def __init__(self, export_raw=False, **kwargs): + """ + Args: + export_raw: If true, data export returns the raw field value rather than the rendered template. (Default: + False) + """ + super().__init__(**kwargs) + self.export_raw = export_raw + def render(self, *args, **kwargs): ret = super().render(*args, **kwargs) if not ret.strip(): @@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn): return ret def value(self, **kwargs): + if self.export_raw: + # Skip template rendering and export raw value + return kwargs.get('value') + ret = super().value(**kwargs) if ret == self.PLACEHOLDER: return '' From f1d0d8e57a4fc14f501b0b6502e3eb8b3f9ef9cc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 12:23:22 -0400 Subject: [PATCH 110/124] Fixes #9407: Clean up display of prefixes values when exporting prefixes list --- docs/release-notes/version-3.2.md | 1 + netbox/ipam/tables/ip.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b4efda4dc..e4594cd6c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list * [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 475ad787e..558631585 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(NetBoxTable): - prefix = tables.TemplateColumn( + prefix = columns.TemplateColumn( template_code=PREFIX_LINK, + export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) prefix_flat = tables.TemplateColumn( From 201b9f635eb0127d241341d5784e01775e887b09 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 13:26:25 -0400 Subject: [PATCH 111/124] Fixes #9402: Fix custom field population when creating a virtual chassis --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/object_create.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e4594cd6c..b38b95ba8 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,7 @@ * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis * [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list * [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index e3e9c1179..8c9ddab19 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm): ] def clean(self): + super().clean() + if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: raise forms.ValidationError({ 'initial_position': "A position must be specified for the first VC member." From b0a56a71bb037e883eddca8234caabf8e93e7f5b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 13:37:14 -0400 Subject: [PATCH 112/124] Fixes #9291: Improve data validation for MultiObjectVar script fields --- docs/release-notes/version-3.2.md | 1 + netbox/utilities/forms/fields/dynamic.py | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index b38b95ba8..90c2143d2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -16,6 +16,7 @@ ### Bug Fixes * [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters +* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields * [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view * [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed * [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index dc3bab9fc..68e71610c 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -135,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip widget = widgets.APISelectMultiple def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ + value = value or [] + + # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] return [None, *value] + return super().clean(value) From 6c035eb13dee9d75bcd58e7346047f06325006bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 15:08:33 -0400 Subject: [PATCH 113/124] Release v3.2.4 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-3.2.md | 2 +- netbox/netbox/settings.py | 2 +- requirements.txt | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index df5ac6e81..a9af9c653 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.3 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 422b87f52..1fff99f1d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.3 + placeholder: v3.2.4 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 90c2143d2..fa533a475 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,6 +1,6 @@ # NetBox v3.2 -## v3.2.4 (FUTURE) +## v3.2.4 (2022-05-31) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 524557db6..bd351a0a1 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.2.4-dev' +VERSION = '3.2.4' # Hostname HOSTNAME = platform.node() diff --git a/requirements.txt b/requirements.txt index 0a15fcf20..293a33542 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,10 +18,10 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.14 -mkdocstrings[python-legacy]==0.18.1 +mkdocs-material==8.2.16 +mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.1.0 +Pillow==9.1.1 psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12 From 3fbf1f7e71801487abd3524797298d82dca4db4f Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 31 May 2022 15:31:22 -0400 Subject: [PATCH 114/124] PRVB --- docs/release-notes/version-3.2.md | 5 ++++- netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index fa533a475..ea5e580b8 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,9 @@ # NetBox v3.2 +## v3.2.5 (FUTURE) + +--- + ## v3.2.4 (2022-05-31) ### Enhancements @@ -25,7 +29,6 @@ * [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields * [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields - --- ## v3.2.3 (2022-05-12) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index bd351a0a1..16c2b8b6e 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.2.4' +VERSION = '3.2.5-dev' # Hostname HOSTNAME = platform.node() From bb2d21abdd552e028392f685a0f0d68710d2961f Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sun, 5 Jun 2022 10:31:21 +0200 Subject: [PATCH 115/124] Make the Service and ServiceTemplate tables sortable by ports --- netbox/ipam/tables/services.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8c81a28c2..58d0a9aff 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable): linkify=True ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:servicetemplate_list' @@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable): order_by=('device', 'virtual_machine') ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:service_list' From 9f4e565b8e1c25ab142305700bbc1d4254d727f2 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 6 Jun 2022 16:28:33 +0200 Subject: [PATCH 116/124] List services listening on all IPs in IPAddressView --- netbox/ipam/views.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 84f6db6d5..a01f2d052 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -7,12 +7,12 @@ from django.urls import reverse from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site +from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables from .constants import * from .models import * @@ -676,7 +676,19 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) - services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance) + # Find services belonging to the IP + service_filter = Q(ipaddresses=instance) + + # Find services listening on all IPs on the assigned device/vm + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object + + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + + services = Service.objects.restrict(request.user, 'view').filter(service_filter) return { 'parent_prefixes_table': parent_prefixes_table, From 15080aad6662e63846e1fe9c0e9fc42b95ee4d58 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 08:51:53 -0400 Subject: [PATCH 117/124] Changelog for #9480, #9484 --- docs/release-notes/version-3.2.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ea5e580b8..baab085ce 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,11 @@ ## v3.2.5 (FUTURE) +### Bug Fixes + +* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers +* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view + --- ## v3.2.4 (2022-05-31) From 1b8350fe48323c2e5961e1b2add65948b54094b6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 09:59:59 -0400 Subject: [PATCH 118/124] Add warning against bumping stale issues --- .github/workflows/stale.yml | 5 ++++- CONTRIBUTING.md | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7390ec1df..57666417a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,10 @@ jobs: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + issues may receive direct feedback. **Do not** attempt to circumvent this + process by "bumping" the issue; doing so will result in its immediate closure + and you may be barred from participating in any future discussions. Please see + our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c01adf4c9..1b4733cbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,9 +160,9 @@ to aid in issue management. It is natural that some new issues get more attention than others. The stale bot helps bring renewed attention to potentially valuable issues that may have -been overlooked. **Do not** comment on an issue that has been marked stale in -an effort to circumvent the bot: Doing so will not remove the stale label. -(Stale labels can be removed only by maintainers.) +been overlooked. **Do not** comment on a stale issue merely to "bump" it in an +effort to circumvent the bot: This will result in the immediate closure of the +issue, and you may be barred from participating in future discussions. ## Maintainer Guidance From 6ed2dbf17288a5e5939c7156189c34a730cb912c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 10:06:19 -0400 Subject: [PATCH 119/124] Fixes #9486: Fix redirect URL when adding device components from the module view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/dcim/module.html | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index baab085ce..3ffc33e71 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -6,6 +6,7 @@ * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view +* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view --- diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 130cd046f..f2dac38f2 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -18,25 +18,25 @@ From 8a4c808be5d99447e7bafbbdbf54001a188f881d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 11:00:14 -0400 Subject: [PATCH 120/124] Closes #8882: Support filtering IP addresses by multiple parent prefixes --- docs/release-notes/version-3.2.md | 4 ++++ netbox/ipam/filtersets.py | 16 +++++++++------- netbox/ipam/tests/test_filtersets.py | 6 ++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3ffc33e71..4534c35c1 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,10 @@ ## v3.2.5 (FUTURE) +### Enhancements + +* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes + ### Bug Fixes * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index a445022ca..d9cf6eefc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): field_name='address', lookup_expr='family' ) - parent = django_filters.CharFilter( + parent = MultiValueCharFilter( method='search_by_parent', label='Parent prefix', ) @@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): - value = value.strip() if not value: return queryset - try: - query = str(netaddr.IPNetwork(value.strip()).cidr) - return queryset.filter(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return queryset.none() + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) def filter_address(self, queryset, name, value): try: diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 198f9d62d..d98fe889e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask From 36c65b7b22566517b8d3abb7856f0b1d18402a3a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 7 Jun 2022 11:12:40 -0400 Subject: [PATCH 121/124] Closes #8893: Include count of IP ranges under tenant view --- docs/release-notes/version-3.2.md | 1 + netbox/templates/tenancy/tenant.html | 4 ++++ netbox/tenancy/views.py | 5 +++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 4534c35c1..3d1f86bec 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -5,6 +5,7 @@ ### Enhancements * [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes +* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view ### Bug Fixes diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e4c1db006..52c13e1aa 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -77,6 +77,10 @@

      {{ stats.prefix_count }}

      Prefixes

      +
      +

      {{ stats.iprange_count }}

      +

      IP Ranges

      +

      {{ stats.ipaddress_count }}

      IP addresses

      diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 58ad98e8f..f6f95b123 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -104,8 +104,9 @@ class TenantView(generic.ObjectView): 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), From c81c3d11eda8fc0659acfabab042bc6065b43051 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 10:20:44 -0400 Subject: [PATCH 122/124] Fixes #9495: Correct link to contacts in contact groups table column --- docs/release-notes/version-3.2.md | 1 + netbox/tenancy/tables/contacts.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 3d1f86bec..339081902 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -12,6 +12,7 @@ * [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers * [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view * [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view +* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column --- diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 17abc5a5b..234dc2ad7 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable): ) contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, + url_params={'group_id': 'pk'}, verbose_name='Contacts' ) tags = columns.TagColumn( From 87b3be26a0f471f59b70af9f25b314722c76c7c9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 11:48:32 -0400 Subject: [PATCH 123/124] Closes #9434: Enabled django-rich test runner for more user-friendly output --- base_requirements.txt | 6 +++++- docs/release-notes/version-3.3.md | 1 + netbox/netbox/settings.py | 2 ++ requirements.txt | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..10e8af3ba 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -30,10 +30,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 63fd9731f..514a92e88 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ ### Other Changes * [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output ### REST API Changes diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index fd3730e2c..f9f4728a9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -423,6 +423,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( diff --git a/requirements.txt b/requirements.txt index 293a33542..d6ea22e7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 +django-rich-1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 From 9b51c2a0b2189c10c4ecfd10505a7f11558f5219 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 12:45:48 -0400 Subject: [PATCH 124/124] Fix typo --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d6ea22e7d..1def8e23e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 -django-rich-1.4.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0
      Site + {{ object.site|linkify|placeholder }} +
      Cluster {% if object.cluster.group %} {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster|linkify }} + {{ object.cluster|linkify|placeholder }}
  • Thank you to our sponsors!

    @@ -71,7 +71,7 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne ### Installation -Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for +Please see [the documentation](https://docs.netbox.dev/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) and run `upgrade.sh`. diff --git a/contrib/netbox-rq.service b/contrib/netbox-rq.service index 5b03777ed..6d2f42743 100644 --- a/contrib/netbox-rq.service +++ b/contrib/netbox-rq.service @@ -1,6 +1,6 @@ [Unit] Description=NetBox Request Queue Worker -Documentation=https://netbox.readthedocs.io/en/stable/ +Documentation=https://docs.netbox.dev/ After=network-online.target Wants=network-online.target diff --git a/contrib/netbox.service b/contrib/netbox.service index 18eb0457c..3cd02d988 100644 --- a/contrib/netbox.service +++ b/contrib/netbox.service @@ -1,6 +1,6 @@ [Unit] Description=NetBox WSGI Service -Documentation=https://netbox.readthedocs.io/en/stable/ +Documentation=https://docs.netbox.dev/ After=network-online.target Wants=network-online.target diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md index 4fc73a58b..21d1f1211 100644 --- a/docs/installation/4-gunicorn.md +++ b/docs/installation/4-gunicorn.md @@ -40,7 +40,7 @@ You should see output similar to the following: ● netbox.service - NetBox WSGI Service Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago - Docs: https://netbox.readthedocs.io/en/stable/ + Docs: https://docs.netbox.dev/ Main PID: 1140492 (gunicorn) Tasks: 19 (limit: 4683) Memory: 666.2M diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index 51508392f..a71b748fd 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -39,7 +39,7 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic ● netbox.service - NetBox WSGI Service Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago - Docs: https://netbox.readthedocs.io/en/stable/ + Docs: https://docs.netbox.dev/ Main PID: 11993 (gunicorn) Tasks: 6 (limit: 2362) CGroup: /system.slice/netbox.service diff --git a/docs/release-notes/version-2.1.md b/docs/release-notes/version-2.1.md index d4804661f..7ec172b1f 100644 --- a/docs/release-notes/version-2.1.md +++ b/docs/release-notes/version-2.1.md @@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A #### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) -The [NAPALM automation](https://github.com/napalm-automation/napalm) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. +The [NAPALM automation](https://github.com/napalm-automation/napalm) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://docs.netbox.dev/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. ### Enhancements diff --git a/docs/release-notes/version-2.2.md b/docs/release-notes/version-2.2.md index e13c4fe69..4f75fb25a 100644 --- a/docs/release-notes/version-2.2.md +++ b/docs/release-notes/version-2.2.md @@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre #### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511)) -Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. +Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://docs.netbox.dev/en/stable/miscellaneous/reports/) for more info. ### Enhancements diff --git a/docs/release-notes/version-2.5.md b/docs/release-notes/version-2.5.md index 666ecb6f1..01c5bf57c 100644 --- a/docs/release-notes/version-2.5.md +++ b/docs/release-notes/version-2.5.md @@ -295,7 +295,7 @@ This release upgrades the Django framework to version 2.2. #### Python 3 Required -As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://netbox.readthedocs.io/en/stable/installation/migrating-to-python3/) for assistance with upgrading. +As promised, Python 2 support has been completed removed. Python 3.5 or higher is now required to run NetBox. Please see [our Python 3 migration guide](https://docs.netbox.dev/en/stable/installation/migrating-to-python3/) for assistance with upgrading. #### Removed Deprecated User Activity Log diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index 7e9e8fea3..1f56ea889 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -218,7 +218,7 @@ #### Custom Scripts ([#3415](https://github.com/netbox-community/netbox/issues/3415)) -Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://netbox.readthedocs.io/en/stable/customization/custom-scripts/) for more detail. +Custom scripts allow for the execution of arbitrary code via the NetBox UI. They can be used to automatically create, manipulate, or clean up objects or perform other tasks within NetBox. Scripts are defined as Python files which contain one or more subclasses of `extras.scripts.Script`. Variable fields can be defined within scripts, which render as form fields within the web UI to prompt the user for input data. Scripts are executed and information is logged via the web UI. Please see [the docs](https://docs.netbox.dev/en/stable/customization/custom-scripts/) for more detail. Note: There are currently no API endpoints for this feature. These are planned for the upcoming v2.7 release. diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e0297a692..ebc14d63c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -67,7 +67,7 @@ ## v2.7.9 (2020-03-06) -**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://netbox.readthedocs.io/en/stable/installation/upgrading/). +**Note:** This release will deploy a Python virtual environment on upgrade in the `venv/` directory. This will require modifying the paths to your Python and gunicorn executables in the systemd service files. For more detail, please see the [upgrade instructions](https://docs.netbox.dev/en/stable/installation/upgrading/). ### Enhancements @@ -418,7 +418,7 @@ to another source before upgrading NetBox to v2.7, as any existing topology maps #### Supervisor Replaced with systemd ([#2902](https://github.com/netbox-community/netbox/issues/2902)) -The NetBox [installation documentation](https://netbox.readthedocs.io/en/stable/installation/) has been updated to +The NetBox [installation documentation](https://docs.netbox.dev/en/stable/installation/) has been updated to provide instructions for managing the WSGI and RQ services using systemd instead of supervisor. This removes the need to install supervisor and simplifies administration of the processes. diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index af758f928..ba395793a 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -235,14 +235,14 @@ This release introduces support for custom plugins, which can be used to extend * Introduce new API endpoints * Add custom request/response middleware -For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://netbox.readthedocs.io/en/stable/plugins/). +For NetBox plugins to be recognized, they must be installed and added by name to the `PLUGINS` configuration parameter. (Plugin support is disabled by default.) Plugins can be configured under the `PLUGINS_CONFIG` parameter. More information can be found the in the [plugins documentation](https://docs.netbox.dev/en/stable/plugins/). ### Enhancements * [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups * [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups * [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models -* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging)) +* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://docs.netbox.dev/en/stable/configuration/optional-settings/#logging)) ### Bug Fixes diff --git a/mkdocs.yml b/mkdocs.yml index f66700095..225c6d4bf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: NetBox Documentation site_dir: netbox/project-static/docs -site_url: https://netbox.readthedocs.io/ +site_url: https://docs.netbox.dev/ repo_name: netbox-community/netbox repo_url: https://github.com/netbox-community/netbox theme: diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 25ccbf181..a12ec9277 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -13,7 +13,7 @@
    NetBox v{{ new_release.version }} is available.
    - Upgrade Instructions + Upgrade Instructions