From f1d5e28f13d3c8f7fe694860004b7b1eb169a717 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 10 Jan 2020 14:26:39 +0000 Subject: [PATCH 01/99] CSV import/export custom fields --- netbox/circuits/forms.py | 6 ++--- netbox/dcim/forms.py | 28 ++++++++++++------------ netbox/ipam/forms.py | 16 +++++++------- netbox/secrets/forms.py | 4 ++-- netbox/tenancy/forms.py | 4 ++-- netbox/utilities/templatetags/helpers.py | 3 +++ netbox/utilities/views.py | 17 ++++++++++++-- netbox/virtualization/forms.py | 8 +++---- 8 files changed, 51 insertions(+), 35 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4a5c06a6e..c5decc9e7 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -46,7 +46,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderCSVForm(forms.ModelForm): +class ProviderCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -144,7 +144,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): ] -class CircuitTypeCSVForm(forms.ModelForm): +class CircuitTypeCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -187,7 +187,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class CircuitCSVForm(forms.ModelForm): +class CircuitCSVForm(CustomFieldForm): provider = forms.ModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6086491d0..5f10752bc 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -151,7 +151,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): } -class RegionCSVForm(forms.ModelForm): +class RegionCSVForm(CustomFieldForm): parent = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, @@ -231,7 +231,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class SiteCSVForm(forms.ModelForm): +class SiteCSVForm(CustomFieldForm): status = CSVChoiceField( choices=SITE_STATUS_CHOICES, required=False, @@ -355,7 +355,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): } -class RackGroupCSVForm(forms.ModelForm): +class RackGroupCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -411,7 +411,7 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(forms.ModelForm): +class RackRoleCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -472,7 +472,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class RackCSVForm(forms.ModelForm): +class RackCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -852,7 +852,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): ] -class ManufacturerCSVForm(forms.ModelForm): +class ManufacturerCSVForm(CustomFieldForm): class Meta: model = Manufacturer @@ -890,7 +890,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm): } -class DeviceTypeCSVForm(forms.ModelForm): +class DeviceTypeCSVForm(CustomFieldForm): manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), required=True, @@ -1308,7 +1308,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): ] -class DeviceRoleCSVForm(forms.ModelForm): +class DeviceRoleCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -1342,7 +1342,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): } -class PlatformCSVForm(forms.ModelForm): +class PlatformCSVForm(CustomFieldForm): slug = SlugField() manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), @@ -1564,7 +1564,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceCSVForm(forms.ModelForm): +class BaseDeviceCSVForm(CustomFieldForm): device_role = forms.ModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -2919,7 +2919,7 @@ class CableForm(BootstrapMixin, forms.ModelForm): ] -class CableCSVForm(forms.ModelForm): +class CableCSVForm(CustomFieldForm): # Termination A side_a_device = FlexibleModelChoiceField( @@ -3294,7 +3294,7 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): } -class InventoryItemCSVForm(forms.ModelForm): +class InventoryItemCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -3623,7 +3623,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): } -class PowerPanelCSVForm(forms.ModelForm): +class PowerPanelCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -3747,7 +3747,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm): self.initial['site'] = self.instance.power_panel.site -class PowerFeedCSVForm(forms.ModelForm): +class PowerFeedCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 413e72eaf..46346cf9c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -48,7 +48,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VRFCSVForm(forms.ModelForm): +class VRFCSVForm(CustomFieldForm): tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -118,7 +118,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm): ] -class RIRCSVForm(forms.ModelForm): +class RIRCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -165,7 +165,7 @@ class AggregateForm(BootstrapMixin, CustomFieldForm): } -class AggregateCSVForm(forms.ModelForm): +class AggregateCSVForm(CustomFieldForm): rir = forms.ModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -247,7 +247,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm): ] -class RoleCSVForm(forms.ModelForm): +class RoleCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -340,7 +340,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class PrefixCSVForm(forms.ModelForm): +class PrefixCSVForm(CustomFieldForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -759,7 +759,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class IPAddressCSVForm(forms.ModelForm): +class IPAddressCSVForm(CustomFieldForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -1025,7 +1025,7 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): } -class VLANGroupCSVForm(forms.ModelForm): +class VLANGroupCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1122,7 +1122,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VLANCSVForm(forms.ModelForm): +class VLANCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index ed0f455c1..47fb27bbb 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -50,7 +50,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): } -class SecretRoleCSVForm(forms.ModelForm): +class SecretRoleCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -113,7 +113,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm): }) -class SecretCSVForm(forms.ModelForm): +class SecretCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index f8aaa45e5..77f8305f3 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -23,7 +23,7 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): ] -class TenantGroupCSVForm(forms.ModelForm): +class TenantGroupCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -57,7 +57,7 @@ class TenantForm(BootstrapMixin, CustomFieldForm): } -class TenantCSVForm(forms.ModelForm): +class TenantCSVForm(CustomFieldForm): slug = SlugField() group = forms.ModelChoiceField( queryset=TenantGroup.objects.all(), diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 7b1e059a6..a13a5f2b0 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -149,6 +149,9 @@ def example_choices(field, arg=3): break if not value or not label: continue + # Handling for custom fields + if hasattr(label, 'value'): + label = label.value examples.append(label) return ', '.join(examples) or 'None' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1aa358fba..96be35130 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -78,15 +78,28 @@ class ObjectListView(View): Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method. """ csv_data = [] + custom_fields = [] # Start with the column headers headers = ','.join(self.queryset.model.csv_headers) + + # Add custom field headers + content_type = ContentType.objects.get_for_model(self.queryset.model) + + for custom_field in CustomField.objects.filter(obj_type=content_type): + headers += ',cf_{}'.format(custom_field.name) + custom_fields.append(custom_field.name) + csv_data.append(headers) # Iterate through the queryset appending each object for obj in self.queryset: - data = csv_format(obj.to_csv()) - csv_data.append(data) + data = obj.to_csv() + + for custom_field in custom_fields: + data += (obj.cf.get(custom_field, ''),) + + csv_data.append(csv_format(data)) return csv_data diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 427e676f6..90ba2e123 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -37,7 +37,7 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): ] -class ClusterTypeCSVForm(forms.ModelForm): +class ClusterTypeCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -62,7 +62,7 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): ] -class ClusterGroupCSVForm(forms.ModelForm): +class ClusterGroupCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -101,7 +101,7 @@ class ClusterForm(BootstrapMixin, CustomFieldForm): } -class ClusterCSVForm(forms.ModelForm): +class ClusterCSVForm(CustomFieldForm): type = forms.ModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -416,7 +416,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VirtualMachineCSVForm(forms.ModelForm): +class VirtualMachineCSVForm(CustomFieldForm): status = CSVChoiceField( choices=VM_STATUS_CHOICES, required=False, From 37322fc1006e0f267c592408c2f2445068617cfb Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 10 Jan 2020 14:58:15 +0000 Subject: [PATCH 02/99] Fixed import choice name --- netbox/extras/forms.py | 6 +++--- netbox/utilities/forms.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 4f7f57fff..efb33905c 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, - SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CSVCustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, + LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from .constants import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -71,7 +71,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F default_choice = cf.choices.get(value=initial).pk except ObjectDoesNotExist: pass - field = forms.TypedChoiceField( + field = CSVCustomFieldChoiceField( choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index eeee719ae..a98f29a8e 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -469,6 +469,23 @@ class CSVChoiceField(forms.ChoiceField): return self.choice_values[value] +class CSVCustomFieldChoiceField(forms.TypedChoiceField): + """ + Invert the provided set of choices to take the human-friendly label as input, and return the database value. + """ + + def __init__(self, choices, *args, **kwargs): + super().__init__(choices=choices, *args, **kwargs) + self.choice_values = {str(label): value for value, label in unpack_grouped_choices(choices)} + + def clean(self, value): + if not value: + return None + if value in self.choice_values: + return self.choice_values[value] + return super().clean(value) + + class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion From de1355e6bc6c42f10dd135c89478322a924073a4 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 10 Jan 2020 15:00:57 +0000 Subject: [PATCH 03/99] Changelog #568 --- docs/release-notes/version-2.6.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index 52b682746..6f4019460 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -2,6 +2,7 @@ ## Enhancements +* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV * [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger * [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering the link * [#2113](https://github.com/netbox-community/netbox/issues/2113) - Allow NAPALM driver settings to be changed with request headers From a2d5aca1d9a9a3ff717c0eacda279464a6a9d7ea Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 16 Jan 2020 16:05:45 +0000 Subject: [PATCH 04/99] Moved changelog to v2.7 --- docs/release-notes/version-2.6.md | 8 -------- docs/release-notes/version-2.7.md | 1 + 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index 51a397b3c..9fd258b0f 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -1,11 +1,3 @@ -# v2.6.13 (FUTURE) - -## Enhancements - -* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV - ---- - # v2.6.12 (2020-01-13) ## Enhancements diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ac9d81e2c..800389228 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -221,6 +221,7 @@ PATCH) to maintain backward compatibility. This behavior will be discontinued be ## Enhancements * [#33](https://github.com/netbox-community/netbox/issues/33) - Add ability to clone objects (pre-populate form fields) +* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV * [#648](https://github.com/netbox-community/netbox/issues/648) - Pre-populate forms when selecting "create and add another" * [#792](https://github.com/netbox-community/netbox/issues/792) - Add power port and power outlet types * [#1865](https://github.com/netbox-community/netbox/issues/1865) - Add console port and console server port types From 9f68f8d1a615b210a8c33c76a68802a6c6bec1ea Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 16 Jan 2020 16:07:24 +0000 Subject: [PATCH 05/99] Update component CSV forms --- netbox/dcim/forms.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index a768039cf..9c3332ff3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2175,7 +2175,7 @@ class ConsolePortCreateForm(ComponentForm): ) -class ConsolePortCSVForm(forms.ModelForm): +class ConsolePortCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2267,7 +2267,7 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): ) -class ConsoleServerPortCSVForm(forms.ModelForm): +class ConsoleServerPortCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2334,7 +2334,7 @@ class PowerPortCreateForm(ComponentForm): ) -class PowerPortCSVForm(forms.ModelForm): +class PowerPortCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2419,7 +2419,7 @@ class PowerOutletCreateForm(ComponentForm): self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent) -class PowerOutletCSVForm(forms.ModelForm): +class PowerOutletCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2668,7 +2668,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceCSVForm(forms.ModelForm): +class InterfaceCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), required=False, @@ -2925,7 +2925,7 @@ class FrontPortCreateForm(ComponentForm): } -class FrontPortCSVForm(forms.ModelForm): +class FrontPortCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -3051,7 +3051,7 @@ class RearPortCreateForm(ComponentForm): ) -class RearPortCSVForm(forms.ModelForm): +class RearPortCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -3655,7 +3655,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class DeviceBayCSVForm(forms.ModelForm): +class DeviceBayCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', From 1b26afdfbb5edc39c4a6a09e6862c855e89cb461 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 14:26:04 +0000 Subject: [PATCH 06/99] Fixes #3935: Swagger DEFAULT_INFO --- netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e5925184d..f747c3b1d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -503,6 +503,7 @@ SWAGGER_SETTINGS = { 'utilities.custom_inspectors.IdInFilterInspector', 'drf_yasg.inspectors.CoreAPICompatInspector', ], + 'DEFAULT_INFO': 'netbox.urls.openapi_info', 'DEFAULT_MODEL_DEPTH': 1, 'DEFAULT_PAGINATOR_INSPECTORS': [ 'utilities.custom_inspectors.NullablePaginatorInspector', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 6b6dfe22d..66ab982eb 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -9,14 +9,16 @@ from netbox.views import APIRootView, HomeView, SearchView from users.views import LoginView, LogoutView from .admin import admin_site +openapi_info = openapi.Info( + title="NetBox API", + default_version='v2', + description="API to access NetBox", + terms_of_service="https://github.com/netbox-community/netbox", + license=openapi.License(name="Apache v2 License"), +) + schema_view = get_schema_view( - openapi.Info( - title="NetBox API", - default_version='v2', - description="API to access NetBox", - terms_of_service="https://github.com/netbox-community/netbox", - license=openapi.License(name="Apache v2 License"), - ), + openapi_info, validators=['flex', 'ssv'], public=True, ) From 9128435113e35bc6941a71fd9c4ee93d8e973f87 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 17:03:14 +0000 Subject: [PATCH 07/99] Removed CustomFieldForm class from models without custom fields --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 34 +++++++++++++++++----------------- netbox/ipam/forms.py | 6 +++--- netbox/secrets/forms.py | 2 +- netbox/tenancy/forms.py | 2 +- netbox/virtualization/forms.py | 4 ++-- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 5416a055f..d14403815 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -144,7 +144,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): ] -class CircuitTypeCSVForm(CustomFieldForm): +class CircuitTypeCSVForm(forms.ModelForm): slug = SlugField() class Meta: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 75a0992ec..a5e8a782f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -184,7 +184,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): } -class RegionCSVForm(CustomFieldForm): +class RegionCSVForm(forms.ModelForm): parent = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, @@ -388,7 +388,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): } -class RackGroupCSVForm(CustomFieldForm): +class RackGroupCSVForm(forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -444,7 +444,7 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(CustomFieldForm): +class RackRoleCSVForm(forms.ModelForm): slug = SlugField() class Meta: @@ -882,7 +882,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): ] -class ManufacturerCSVForm(CustomFieldForm): +class ManufacturerCSVForm(forms.ModelForm): class Meta: model = Manufacturer @@ -1458,7 +1458,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): ] -class DeviceRoleCSVForm(CustomFieldForm): +class DeviceRoleCSVForm(forms.ModelForm): slug = SlugField() class Meta: @@ -1492,7 +1492,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): } -class PlatformCSVForm(CustomFieldForm): +class PlatformCSVForm(forms.ModelForm): slug = SlugField() manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), @@ -2179,7 +2179,7 @@ class ConsolePortCreateForm(ComponentForm): ) -class ConsolePortCSVForm(CustomFieldForm): +class ConsolePortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2271,7 +2271,7 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): ) -class ConsoleServerPortCSVForm(CustomFieldForm): +class ConsoleServerPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2338,7 +2338,7 @@ class PowerPortCreateForm(ComponentForm): ) -class PowerPortCSVForm(CustomFieldForm): +class PowerPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2423,7 +2423,7 @@ class PowerOutletCreateForm(ComponentForm): self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent) -class PowerOutletCSVForm(CustomFieldForm): +class PowerOutletCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -2672,7 +2672,7 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceCSVForm(CustomFieldForm): +class InterfaceCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), required=False, @@ -2929,7 +2929,7 @@ class FrontPortCreateForm(ComponentForm): } -class FrontPortCSVForm(CustomFieldForm): +class FrontPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -3055,7 +3055,7 @@ class RearPortCreateForm(ComponentForm): ) -class RearPortCSVForm(CustomFieldForm): +class RearPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -3365,7 +3365,7 @@ class CableForm(BootstrapMixin, forms.ModelForm): ] -class CableCSVForm(CustomFieldForm): +class CableCSVForm(forms.ModelForm): # Termination A side_a_device = FlexibleModelChoiceField( @@ -3659,7 +3659,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class DeviceBayCSVForm(CustomFieldForm): +class DeviceBayCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -3801,7 +3801,7 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): } -class InventoryItemCSVForm(CustomFieldForm): +class InventoryItemCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -4130,7 +4130,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): } -class PowerPanelCSVForm(CustomFieldForm): +class PowerPanelCSVForm(forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index d2efc2bcc..3bafad51b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -118,7 +118,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm): ] -class RIRCSVForm(CustomFieldForm): +class RIRCSVForm(forms.ModelForm): slug = SlugField() class Meta: @@ -247,7 +247,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm): ] -class RoleCSVForm(CustomFieldForm): +class RoleCSVForm(forms.ModelForm): slug = SlugField() class Meta: @@ -1026,7 +1026,7 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): } -class VLANGroupCSVForm(CustomFieldForm): +class VLANGroupCSVForm(forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 0b2cb3cb4..bddb1c109 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -52,7 +52,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): } -class SecretRoleCSVForm(CustomFieldForm): +class SecretRoleCSVForm(forms.ModelForm): slug = SlugField() class Meta: diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 77f8305f3..22deae434 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -23,7 +23,7 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): ] -class TenantGroupCSVForm(CustomFieldForm): +class TenantGroupCSVForm(forms.ModelForm): slug = SlugField() class Meta: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 457369851..27c22fa71 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -33,7 +33,7 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): ] -class ClusterTypeCSVForm(CustomFieldForm): +class ClusterTypeCSVForm(forms.ModelForm): slug = SlugField() class Meta: @@ -58,7 +58,7 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): ] -class ClusterGroupCSVForm(CustomFieldForm): +class ClusterGroupCSVForm(forms.ModelForm): slug = SlugField() class Meta: From 0ab19d723dc75975c1c5840d9d91abfbbaece950 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 17:18:58 +0000 Subject: [PATCH 08/99] Moved the header join logic after the custom fields are added --- netbox/utilities/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 67af4371f..4801d61a3 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -91,16 +91,16 @@ class ObjectListView(View): custom_fields = [] # Start with the column headers - headers = ','.join(self.queryset.model.csv_headers) + headers = self.queryset.model.csv_headers.copy() # Add custom field headers content_type = ContentType.objects.get_for_model(self.queryset.model) for custom_field in CustomField.objects.filter(obj_type=content_type): - headers += ',cf_{}'.format(custom_field.name) + headers.append(custom_field.name) custom_fields.append(custom_field.name) - csv_data.append(headers) + csv_data.append(','.join(headers)) # Iterate through the queryset appending each object for obj in self.queryset: From 0a5eecd0e3114043f4939a4e473c65eaf762b88a Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 17:37:51 +0000 Subject: [PATCH 09/99] Explicitly use the value of the choice, instead of relying on __str__ --- netbox/extras/forms.py | 2 +- netbox/utilities/templatetags/helpers.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 17ae6c907..6aa0e3552 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -61,7 +61,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F # Select elif cf.type == CustomFieldTypeChoices.TYPE_SELECT: - choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] + choices = [(cfc.pk, cfc.value) for cfc in cf.choices.all()] if not cf.required or bulk_edit or filterable_only: choices = [(None, '---------')] + choices # Check for a default choice diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 19a26b3c9..c4b3bb6ea 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -149,9 +149,6 @@ def example_choices(field, arg=3): break if not value or not label: continue - # Handling for custom fields - if hasattr(label, 'value'): - label = label.value examples.append(label) return ', '.join(examples) or 'None' From 8f86244b4fe0c38134022a90f80151f6e446fcd9 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 18:54:37 +0000 Subject: [PATCH 10/99] Cleaned the CustomField choice field --- netbox/extras/forms.py | 4 ++-- netbox/utilities/forms.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 6aa0e3552..314455b83 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -10,7 +10,7 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CSVCustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, + CustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * @@ -71,7 +71,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F default_choice = cf.choices.get(value=initial).pk except ObjectDoesNotExist: pass - field = CSVCustomFieldChoiceField( + field = CustomFieldChoiceField( choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 453561303..1dd2c06a7 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -442,18 +442,18 @@ class CSVChoiceField(forms.ChoiceField): return self.choice_values[value] -class CSVCustomFieldChoiceField(forms.TypedChoiceField): +class CustomFieldChoiceField(forms.TypedChoiceField): """ - Invert the provided set of choices to take the human-friendly label as input, and return the database value. + Accept human-friendly label as input, and return the database value. If the label is not matched, the normal, + value-based input is assumed. """ def __init__(self, choices, *args, **kwargs): super().__init__(choices=choices, *args, **kwargs) - self.choice_values = {str(label): value for value, label in unpack_grouped_choices(choices)} + self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} def clean(self, value): - if not value: - return None + # Check if the value is actually a label if value in self.choice_values: return self.choice_values[value] return super().clean(value) From bed08a7b07eccbb6797cfe7ee29dbb38a1a1976e Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 23 Jan 2020 20:26:21 +0000 Subject: [PATCH 11/99] Use model's `get_custom_fields` --- netbox/utilities/views.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 4801d61a3..d900a8545 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -93,12 +93,11 @@ class ObjectListView(View): # Start with the column headers headers = self.queryset.model.csv_headers.copy() - # Add custom field headers - content_type = ContentType.objects.get_for_model(self.queryset.model) - - for custom_field in CustomField.objects.filter(obj_type=content_type): - headers.append(custom_field.name) - custom_fields.append(custom_field.name) + # Add custom field headers, if any + if hasattr(self.queryset.model, 'get_custom_fields'): + for custom_field in self.queryset.model().get_custom_fields(): + headers.append(custom_field.name) + custom_fields.append(custom_field.name) csv_data.append(','.join(headers)) From c22024b618b32928a98ee18398f10c7f9bfb27ca Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 24 Jan 2020 22:15:09 +0000 Subject: [PATCH 12/99] Added CSV import test --- netbox/extras/tests/test_customfields.py | 64 +++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 192958840..7871e031e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,14 +1,14 @@ from datetime import date from django.contrib.contenttypes.models import ContentType -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from rest_framework import status from dcim.models import Site from extras.choices import * from extras.models import CustomField, CustomFieldValue, CustomFieldChoice -from utilities.testing import APITestCase +from utilities.testing import APITestCase, create_test_user from virtualization.models import VirtualMachine @@ -364,3 +364,63 @@ class CustomFieldChoiceAPITest(APITestCase): self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value]) self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value]) self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value]) + + +class CustomFieldCSV(TestCase): + def setUp(self): + super().setUp() + + user = create_test_user( + permissions=[ + 'dcim.view_site', + 'dcim.add_site', + ] + ) + self.client = Client() + self.client.force_login(user) + + obj_type = ContentType.objects.get_for_model(Site) + + self.cf_text = CustomField.objects.create(name="text", type=CustomFieldTypeChoices.TYPE_TEXT) + self.cf_text.obj_type.set([obj_type]) + self.cf_text.save() + + self.cf_choice = CustomField.objects.create(name="choice", type=CustomFieldTypeChoices.TYPE_SELECT) + self.cf_choice.obj_type.set([obj_type]) + self.cf_choice.save() + + self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_1") + self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_2") + self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_3") + + def test_import(self): + """ + Import a site with custom fields + """ + csv_data = ( + "name,slug,cf_text,cf_choice", + "Site 1,site-1,something,cf_field_1", + ) + + response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) + self.assertEqual(response.status_code, 200) + + site1_custom_fields = Site.objects.get(name='Site 1').get_custom_fields() + self.assertEqual(len(site1_custom_fields), 2) + self.assertEqual(site1_custom_fields[self.cf_text], 'something') + self.assertEqual(site1_custom_fields[self.cf_choice], self.cf_choice_1) + + + def test_import_invalid_choice(self): + """ + Import a site with an invalid choice + """ + csv_data = ( + "name,slug,cf_choice", + "Site 2,site-2,cf_field_4", + ) + + response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) + self.assertEqual(response.status_code, 200) + + self.assertFalse(len(Site.objects.filter(name="Site 2")), 0) From 8ec0ad96bddbc98c724ce2072a96f0e5844cf30a Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Fri, 24 Jan 2020 22:20:41 +0000 Subject: [PATCH 13/99] Formatting --- netbox/extras/tests/test_customfields.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 7871e031e..005f049b5 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -410,7 +410,6 @@ class CustomFieldCSV(TestCase): self.assertEqual(site1_custom_fields[self.cf_text], 'something') self.assertEqual(site1_custom_fields[self.cf_choice], self.cf_choice_1) - def test_import_invalid_choice(self): """ Import a site with an invalid choice From 4abd3866abe7bcc2d21efd6679b47ceb13793174 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Sun, 26 Jan 2020 10:53:58 +0000 Subject: [PATCH 14/99] Fixes #3886: Config context cluster (group) --- netbox/extras/filters.py | 17 +++++++++++ netbox/extras/forms.py | 25 ++++++++++++++-- .../0037_configcontexts_clusters.py | 24 +++++++++++++++ netbox/extras/models.py | 10 +++++++ netbox/extras/querysets.py | 6 ++++ netbox/extras/tests/test_filters.py | 30 +++++++++++++++++++ netbox/templates/extras/configcontext.html | 28 +++++++++++++++++ .../templates/extras/configcontext_edit.html | 2 ++ 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 netbox/extras/migrations/0037_configcontexts_clusters.py diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 8a0d32b33..dcd4f3ede 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup +from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag @@ -170,6 +171,22 @@ class ConfigContextFilterSet(django_filters.FilterSet): to_field_name='slug', label='Platform (slug)', ) + cluster_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='cluster_groups', + queryset=ClusterGroup.objects.all(), + label='Cluster group', + ) + cluster_group = django_filters.ModelMultipleChoiceFilter( + field_name='cluster_groups__slug', + queryset=ClusterGroup.objects.all(), + to_field_name='slug', + label='Cluster group (slug)', + ) + cluster_id = django_filters.ModelMultipleChoiceFilter( + field_name='clusters', + queryset=Cluster.objects.all(), + label='Cluster', + ) tenant_group_id = django_filters.ModelMultipleChoiceFilter( field_name='tenant_groups', queryset=TenantGroup.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index edde6c6c5..5c33c7c98 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -254,8 +254,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = [ - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', - 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', + 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ] widgets = { 'regions': APISelectMultiple( @@ -270,6 +270,12 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): 'platforms': APISelectMultiple( api_url="/api/dcim/platforms/" ), + 'cluster_groups': APISelectMultiple( + api_url="/api/virtualization/cluster-groups/" + ), + 'clusters': APISelectMultiple( + api_url="/api/virtualization/clusters/" + ), 'tenant_groups': APISelectMultiple( api_url="/api/tenancy/tenant-groups/" ), @@ -340,6 +346,21 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): value_field="slug", ) ) + cluster_group = FilterChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/", + value_field="slug", + ) + ) + cluster_id = FilterChoiceField( + queryset=Tenant.objects.all(), + label='Cluster', + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/", + ) + ) tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', diff --git a/netbox/extras/migrations/0037_configcontexts_clusters.py b/netbox/extras/migrations/0037_configcontexts_clusters.py new file mode 100644 index 000000000..201aed94a --- /dev/null +++ b/netbox/extras/migrations/0037_configcontexts_clusters.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.8 on 2020-01-17 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0013_deterministic_ordering'), + ('extras', '0036_contenttype_filters_to_q_objects'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='cluster_groups', + field=models.ManyToManyField(blank=True, related_name='_configcontext_cluster_groups_+', to='virtualization.ClusterGroup'), + ), + migrations.AddField( + model_name='configcontext', + name='clusters', + field=models.ManyToManyField(blank=True, related_name='_configcontext_clusters_+', to='virtualization.Cluster'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a03494bb2..f247fe1c2 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -694,6 +694,16 @@ class ConfigContext(models.Model): related_name='+', blank=True ) + cluster_groups = models.ManyToManyField( + to='virtualization.ClusterGroup', + related_name='+', + blank=True + ) + clusters = models.ManyToManyField( + to='virtualization.Cluster', + related_name='+', + blank=True + ) tenant_groups = models.ManyToManyField( to='tenancy.TenantGroup', related_name='+', diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 22ab489bd..812c66714 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -29,6 +29,10 @@ class ConfigContextQuerySet(QuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role + # Virtualization cluster for VirtualMachine + cluster = getattr(obj, 'cluster', None) + cluster_group = getattr(cluster, 'group', None) + # Get the group of the assigned tenant, if any tenant_group = obj.tenant.group if obj.tenant else None @@ -44,6 +48,8 @@ class ConfigContextQuerySet(QuerySet): Q(sites=obj.site) | Q(sites=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), + Q(cluster_groups=cluster_group) | Q(cluster_groups=None), + Q(clusters=cluster) | Q(clusters=None), Q(tenant_groups=tenant_group) | Q(tenant_groups=None), Q(tenants=obj.tenant) | Q(tenants=None), Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 130f94298..5ef96faa2 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -7,6 +7,7 @@ from extras.constants import GRAPH_MODELS from extras.filters import * from extras.models import ConfigContext, ExportTemplate, Graph from tenancy.models import Tenant, TenantGroup +from virtualization.models import Cluster, ClusterGroup, ClusterType class GraphTestCase(TestCase): @@ -107,6 +108,21 @@ class ConfigContextTestCase(TestCase): ) Platform.objects.bulk_create(platforms) + cluster_groups = ( + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), + ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), + ) + ClusterGroup.objects.bulk_create(cluster_groups) + + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + clusters = ( + Cluster(name='Cluster 1', type=cluster_type), + Cluster(name='Cluster 2', type=cluster_type), + Cluster(name='Cluster 3', type=cluster_type), + ) + Cluster.objects.bulk_create(clusters) + tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), @@ -132,6 +148,8 @@ class ConfigContextTestCase(TestCase): c.sites.set([sites[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) + c.cluster_groups.set([cluster_groups[i]]) + c.clusters.set([clusters[i]]) c.tenant_groups.set([tenant_groups[i]]) c.tenants.set([tenants[i]]) @@ -173,6 +191,18 @@ class ConfigContextTestCase(TestCase): params = {'platform': [platforms[0].slug, platforms[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cluster_group(self): + cluster_groups = ClusterGroup.objects.all()[:2] + params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_cluster(self): + clusters = Cluster.objects.all()[:2] + params = {'cluster_id': [clusters[0].pk, clusters[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant_group(self): tenant_groups = TenantGroup.objects.all()[:2] params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 7cec3f403..f9ea26c2b 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -134,6 +134,34 @@ {% endif %} + + Cluster Groups + + {% if configcontext.cluster_groups.all %} +
    + {% for cluster_group in configcontext.cluster_groups.all %} +
  • {{ cluster_group }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} + + + + Clusters + + {% if configcontext.clusters.all %} +
    + {% for cluster in configcontext.clusters.all %} +
  • {{ cluster }}
  • + {% endfor %} +
+ {% else %} + None + {% endif %} + + Tenant Groups diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index d31aa5c57..9e922108c 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -18,6 +18,8 @@ {% render_field form.sites %} {% render_field form.roles %} {% render_field form.platforms %} + {% render_field form.cluster_groups %} + {% render_field form.clusters %} {% render_field form.tenant_groups %} {% render_field form.tenants %} {% render_field form.tags %} From 4e4a05d3b9fc1f5ac033934ccfdd525da06f5d19 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Sun, 26 Jan 2020 12:52:18 +0000 Subject: [PATCH 15/99] Fixes #4016: Removed duplicate tenant field for cluster edit form --- netbox/templates/virtualization/cluster_edit.html | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/templates/virtualization/cluster_edit.html b/netbox/templates/virtualization/cluster_edit.html index bf81ffd42..c4d39d12e 100644 --- a/netbox/templates/virtualization/cluster_edit.html +++ b/netbox/templates/virtualization/cluster_edit.html @@ -8,7 +8,6 @@ {% render_field form.name %} {% render_field form.type %} {% render_field form.group %} - {% render_field form.tenant %} {% render_field form.site %} From 011280b0bfced7d21d275392da174d5b0b784aec Mon Sep 17 00:00:00 2001 From: hellerve Date: Mon, 27 Jan 2020 13:13:07 +0100 Subject: [PATCH 16/99] dcim: add borders on the rear of devices as well --- netbox/dcim/models/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 37ee0a266..350330757 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -405,7 +405,7 @@ class RackElevationHelperMixin: @staticmethod def _draw_device_rear(drawing, device, start, end, text): - rect = drawing.rect(start, end, class_="blocked") + rect = drawing.rect(start, end, class_="slot blocked") rect.set_desc('{} — {} ({}U) {} {}'.format( device.device_role, device.device_type.display_name, device.device_type.u_height, device.asset_tag or '', device.serial or '' From 1901f63b4ce0562ad562406f41a6352d04edfa4e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Jan 2020 09:45:18 -0500 Subject: [PATCH 17/99] Update changelog --- docs/release-notes/version-2.7.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 5c489a96c..92fb7203b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -14,6 +14,8 @@ * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks * [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank * [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings +* [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form +* [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations --- From 46d0e88da3e145e34f49f0d78d1b8f141570bdb8 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Mon, 27 Jan 2020 15:49:15 +0000 Subject: [PATCH 18/99] Fixes #4010: Fixes IP addresses table when filtering interfaces --- netbox/project-static/js/interface_toggles.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/netbox/project-static/js/interface_toggles.js b/netbox/project-static/js/interface_toggles.js index a46d3185c..87b59b081 100644 --- a/netbox/project-static/js/interface_toggles.js +++ b/netbox/project-static/js/interface_toggles.js @@ -2,9 +2,9 @@ $('button.toggle-ips').click(function() { var selected = $(this).attr('selected'); if (selected) { - $('#interfaces_table tr.ipaddresses').hide(); + $('#interfaces_table tr.interface:visible + tr.ipaddresses').hide(); } else { - $('#interfaces_table tr.ipaddresses').show(); + $('#interfaces_table tr.interface:visible + tr.ipaddresses').show(); } $(this).attr('selected', !selected); $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); @@ -14,10 +14,11 @@ $('button.toggle-ips').click(function() { // Inteface filtering $('input.interface-filter').on('input', function() { var filter = new RegExp(this.value); + var interface; - for (interface of $(this).closest('div.panel').find('tbody > tr')) { + for (interface of $('#interfaces_table > tbody > tr')) { // Slice off 'interface_' at the start of the ID - if (filter && filter.test(interface.id.slice(10))) { + if (filter.test(interface.id.slice(10))) { // Match the toggle in case the filter now matches the interface $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked')); $(interface).show(); @@ -27,4 +28,9 @@ $('input.interface-filter').on('input', function() { $(interface).hide(); } } + + // Show the ip addresses table row for the visible (matched) interfaces, if checked + if ($('button.toggle-ips').attr('selected')) { + $('#interfaces_table > tbody > tr:visible').next('tr.ipaddresses').show(); + } }); From 00b50f9c65629c406f39e75c9695584766126a9f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Jan 2020 12:34:52 -0500 Subject: [PATCH 19/99] Remove obsolete constants --- netbox/utilities/middleware.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index a44273ab0..564771821 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -7,9 +7,6 @@ from django.urls import reverse from .views import server_error -BASE_PATH = getattr(settings, 'BASE_PATH', False) -LOGIN_REQUIRED = getattr(settings, 'LOGIN_REQUIRED', False) - class LoginRequiredMiddleware(object): """ @@ -19,7 +16,7 @@ class LoginRequiredMiddleware(object): self.get_response = get_response def __call__(self, request): - if LOGIN_REQUIRED and not request.user.is_authenticated: + if settings.LOGIN_REQUIRED and not request.user.is_authenticated: # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API # performs its own authentication. Also metrics can be read without login. api_path = reverse('api-root') From 73b35e72d8dbd5a4179c149cec13ec74107d70ba Mon Sep 17 00:00:00 2001 From: Samuel Mutel Date: Mon, 27 Jan 2020 21:09:49 +0100 Subject: [PATCH 20/99] Update nginx documentation --- docs/installation/3-http-daemon.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/3-http-daemon.md b/docs/installation/3-http-daemon.md index 4ca566aa3..5e19f54a2 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/3-http-daemon.md @@ -29,7 +29,7 @@ server { location / { proxy_pass http://127.0.0.1:8001; - proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } From d0f127e575ad7a7840a459d4a670545fde3afbe0 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Mon, 27 Jan 2020 21:53:10 +0000 Subject: [PATCH 21/99] Fixes #3338: Added termination A and Z to the circuit --- netbox/circuits/api/serializers.py | 18 +++++++++++++++--- netbox/circuits/api/views.py | 4 +++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index b22135b3f..6bac48a59 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -3,11 +3,11 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer from dcim.api.serializers import ConnectedEndpointSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer -from utilities.api import ChoiceField, ValidatedModelSerializer +from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from .nested_serializers import * @@ -39,18 +39,30 @@ class CircuitTypeSerializer(ValidatedModelSerializer): fields = ['id', 'name', 'slug', 'description', 'circuit_count'] +class CircuitCircuitTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') + site = NestedSiteSerializer() + connected_endpoint = NestedInterfaceSerializer() + + class Meta: + model = CircuitTermination + fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id'] + + class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) + termination_a = CircuitCircuitTerminationSerializer(read_only=True) + termination_z = CircuitCircuitTerminationSerializer(read_only=True) tags = TagListSerializerField(required=False) class Meta: model = Circuit fields = [ 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 98b7c9184..c98eb0c64 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -62,7 +62,9 @@ class CircuitTypeViewSet(ModelViewSet): # class CircuitViewSet(CustomFieldModelViewSet): - queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags') + queryset = Circuit.objects.prefetch_related( + 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint' + ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet From 5c5b9c95aaf0fc6b003fab7be6746cae408976c6 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Mon, 27 Jan 2020 22:07:42 +0000 Subject: [PATCH 22/99] Interface selector restricted to only interface --- netbox/project-static/js/interface_toggles.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/netbox/project-static/js/interface_toggles.js b/netbox/project-static/js/interface_toggles.js index 87b59b081..df8ac064b 100644 --- a/netbox/project-static/js/interface_toggles.js +++ b/netbox/project-static/js/interface_toggles.js @@ -16,21 +16,20 @@ $('input.interface-filter').on('input', function() { var filter = new RegExp(this.value); var interface; - for (interface of $('#interfaces_table > tbody > tr')) { + for (interface of $('#interfaces_table > tbody > tr.interface')) { // Slice off 'interface_' at the start of the ID if (filter.test(interface.id.slice(10))) { // Match the toggle in case the filter now matches the interface $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked')); $(interface).show(); + if ($('button.toggle-ips').attr('selected')) { + $(interface).next('tr.ipaddresses').show(); + } } else { // Uncheck to prevent actions from including it when it doesn't match $(interface).find('input:checkbox[name=pk]').prop('checked', false); $(interface).hide(); + $(interface).next('tr.ipaddresses').hide(); } } - - // Show the ip addresses table row for the visible (matched) interfaces, if checked - if ($('button.toggle-ips').attr('selected')) { - $('#interfaces_table > tbody > tr:visible').next('tr.ipaddresses').show(); - } }); From 93fa00b673db4e4d6118a8076ea0ba61bc2a4283 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Jan 2020 17:21:57 -0500 Subject: [PATCH 23/99] #3338: Prefetch termination devices to avoid extra database queries --- netbox/circuits/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index c98eb0c64..75f7e0e3e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -63,7 +63,7 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint' + 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device' ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet From 9c4f1d5795e5f1092048a037fdfcc82914f348b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 27 Jan 2020 17:24:00 -0500 Subject: [PATCH 24/99] Changelog for #3338 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 92fb7203b..b3adafab0 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable +* [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts * [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps From 3bce8e97160ef082d4c58cea93a23ab2ed230c14 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Mon, 27 Jan 2020 22:44:38 +0000 Subject: [PATCH 25/99] Fixes #4025: Cable status class --- netbox/templates/dcim/cable_trace.html | 2 +- netbox/templates/dcim/inc/consoleport.html | 2 +- netbox/templates/dcim/inc/consoleserverport.html | 2 +- netbox/templates/dcim/inc/frontport.html | 2 +- netbox/templates/dcim/inc/interface.html | 2 +- netbox/templates/dcim/inc/poweroutlet.html | 2 +- netbox/templates/dcim/inc/powerport.html | 2 +- netbox/templates/dcim/inc/rearport.html | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 4dd145058..8c7b69f26 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -32,7 +32,7 @@ {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} -

{{ cable.get_status_display }}

+

{{ cable.get_status_display }}

{{ cable.get_type_display|default:"" }}

{% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %} {% if cable.color %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index f9fc40fee..9089f19b4 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -1,4 +1,4 @@ - + {# Name #} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index f5b19ed75..0d649f812 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -1,6 +1,6 @@ {% load helpers %} - + {# Checkbox #} {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index 1b7f85e2c..12915f64d 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -1,5 +1,5 @@ {% load helpers %} - + {# Checkbox #} {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 6ec46824b..095930c92 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,5 +1,5 @@ {% load helpers %} - + {# Checkbox #} {% if perms.dcim.change_interface or perms.dcim.delete_interface %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 5691608b4..1c0630310 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -1,6 +1,6 @@ {% load helpers %} - + {# Checkbox #} {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 679082654..045b25dfd 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -1,4 +1,4 @@ - + {# Name #} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index 27609e726..73ccd6b70 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -1,5 +1,5 @@ {% load helpers %} - + {# Checkbox #} {% if perms.dcim.change_rearport or perms.dcim.delete_rearport %} From 8306976b3ee8734c2f3a2008a59f95ceda597ffb Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Mon, 27 Jan 2020 22:49:36 +0000 Subject: [PATCH 26/99] Removed erroneous double-space --- netbox/templates/dcim/inc/interface.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 095930c92..2fe970fd7 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,5 +1,5 @@ {% load helpers %} - + {# Checkbox #} {% if perms.dcim.change_interface or perms.dcim.delete_interface %} From 720bd872921f398fbff35d42dd5598bdaa1733ce Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Mon, 27 Jan 2020 22:56:25 +0000 Subject: [PATCH 27/99] Fixed interface mark connected/planned buttons --- netbox/templates/dcim/inc/cable_toggle_buttons.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/cable_toggle_buttons.html b/netbox/templates/dcim/inc/cable_toggle_buttons.html index 3e0209e01..507aab3be 100644 --- a/netbox/templates/dcim/inc/cable_toggle_buttons.html +++ b/netbox/templates/dcim/inc/cable_toggle_buttons.html @@ -1,5 +1,5 @@ {% if perms.dcim.change_cable %} - {% if cable.status %} + {% if cable.status == 'connected' %} From 8d547e99066782393e6725ea512ba71423607821 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 09:47:33 -0500 Subject: [PATCH 28/99] Fixes #4028: Correct URL patterns to match Unicode characters in tag slugs --- docs/release-notes/version-2.7.md | 1 + netbox/extras/urls.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index b3adafab0..255abbc85 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -17,6 +17,7 @@ * [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings * [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form * [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations +* [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs --- diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index edc3ffcad..653fe7c7f 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -11,10 +11,10 @@ urlpatterns = [ path(r'tags/', views.TagListView.as_view(), name='tag_list'), path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path(r'tags//', views.TagView.as_view(), name='tag'), - path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), - path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path(r'tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), + path(r'tags//', views.TagView.as_view(), name='tag'), + path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path(r'tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), From 3c5346f60ac93d50239a0058a267e2e1622bc693 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Tue, 28 Jan 2020 10:22:28 -0500 Subject: [PATCH 29/99] Fixes #3978: VRF filtering for NAT IP search --- docs/release-notes/version-2.7.md | 1 + netbox/ipam/forms.py | 11 +++++++++++ netbox/templates/ipam/ipaddress_edit.html | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index b3adafab0..85a9e87e3 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -5,6 +5,7 @@ * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable * [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts +* [#3978](https://github.com/netbox-community/netbox/issues/3978) - Add VRF filtering to search NAT IP * [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps ## Bug Fixes diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 35cf12dfb..183fcb717 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -635,6 +635,17 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) } ) ) + nat_vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/", + filter_for={ + 'nat_inside': 'vrf_id' + } + ) + ) nat_inside = ChainedModelChoiceField( queryset=IPAddress.objects.all(), chains=( diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index c24c94c87..e3f694fe3 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -61,7 +61,7 @@ {% render_field form.nat_device %} {% render_field form.nat_inside %} From 8849f4b0a5db2f53bb8831fe492505bd09bc1388 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Tue, 28 Jan 2020 17:30:26 +0000 Subject: [PATCH 30/99] Added cluster groups and clusters to serializers --- netbox/extras/api/serializers.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 0e27a8ee5..58433df25 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -20,6 +20,8 @@ from utilities.api import ( ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField, ValidatedModelSerializer, ) +from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer +from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * @@ -161,6 +163,18 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + cluster_groups = SerializedPKRelatedField( + queryset=ClusterGroup.objects.all(), + serializer=NestedClusterGroupSerializer, + required=False, + many=True + ) + clusters = SerializedPKRelatedField( + queryset=Cluster.objects.all(), + serializer=NestedClusterSerializer, + required=False, + many=True + ) tenant_groups = SerializedPKRelatedField( queryset=TenantGroup.objects.all(), serializer=NestedTenantGroupSerializer, @@ -184,7 +198,7 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', - 'tenant_groups', 'tenants', 'tags', 'data', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ] From 9b9e5684460efc68a851407befc7a58015747c32 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 12:49:00 -0500 Subject: [PATCH 31/99] Fixes #4027: Repair schema migration for #3569 to convert IP addresses with DHCP status --- docs/release-notes/version-2.7.md | 1 + .../migrations/0029_3569_ipaddress_fields.py | 2 +- .../0034_fix_ipaddress_status_dhcp.py | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 255abbc85..a7b6ec0e7 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -17,6 +17,7 @@ * [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings * [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form * [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations +* [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status * [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs --- diff --git a/netbox/ipam/migrations/0029_3569_ipaddress_fields.py b/netbox/ipam/migrations/0029_3569_ipaddress_fields.py index 528efb4fb..195b630db 100644 --- a/netbox/ipam/migrations/0029_3569_ipaddress_fields.py +++ b/netbox/ipam/migrations/0029_3569_ipaddress_fields.py @@ -2,10 +2,10 @@ from django.db import migrations, models IPADDRESS_STATUS_CHOICES = ( - (0, 'container'), (1, 'active'), (2, 'reserved'), (3, 'deprecated'), + (5, 'dhcp'), ) IPADDRESS_ROLE_CHOICES = ( diff --git a/netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py b/netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py new file mode 100644 index 000000000..9e496153e --- /dev/null +++ b/netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +def ipaddress_status_dhcp_to_slug(apps, schema_editor): + IPAddress = apps.get_model('ipam', 'IPAddress') + IPAddress.objects.filter(status='5').update(status='dhcp') + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0033_deterministic_ordering'), + ] + + operations = [ + # Fixes a missed integer substitution from #3569; see bug #4027. The original migration has also been fixed, + # so this can be omitted when squashing in the future. + migrations.RunPython( + code=ipaddress_status_dhcp_to_slug + ), + ] From 77292050d4d0ffa1be7320bbe82f04f13e0d786b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 13:38:03 -0500 Subject: [PATCH 32/99] Changelog for #4025 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index a7b6ec0e7..e6becd4c9 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -17,6 +17,7 @@ * [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings * [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form * [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations +* [#4025](https://github.com/netbox-community/netbox/issues/4025) - Correct display of cable status (various places) * [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status * [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs From ede576a2ae250b404ea65404cf11d8da69855aef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 13:55:44 -0500 Subject: [PATCH 33/99] Changelog for #4022 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ef04aa19c..25aad4283 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -18,6 +18,7 @@ * [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings * [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form * [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations +* [#4022](https://github.com/netbox-community/netbox/issues/4022) - Fix display of assigned IPs when filtering device interfaces * [#4025](https://github.com/netbox-community/netbox/issues/4025) - Correct display of cable status (various places) * [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status * [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs From 0a11fc1221075569b47557398e9e0e52effb7ef8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 14:19:29 -0500 Subject: [PATCH 34/99] Fixes #4030: Fix exception when setting interfaces to tagged mode in bulk --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 25aad4283..ae4be060a 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -22,6 +22,7 @@ * [#4025](https://github.com/netbox-community/netbox/issues/4025) - Correct display of cable status (various places) * [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status * [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs +* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when setting interfaces to tagged mode in bulk --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f0667c143..e713d2c7c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2748,7 +2748,7 @@ class InterfaceCSVForm(forms.ModelForm): return self.cleaned_data['enabled'] -class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() @@ -2829,6 +2829,18 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo else: self.fields['lag'].choices = [] + def clean(self): + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + class InterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( From 8de9f521519c21c529c79b053cf261d9bac53d84 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 16:09:10 -0500 Subject: [PATCH 35/99] Fixes #4033: Restore missing comments field label of various bulk edit forms --- docs/release-notes/version-2.7.md | 1 + netbox/circuits/forms.py | 3 ++- netbox/dcim/forms.py | 8 +++++--- netbox/virtualization/forms.py | 6 ++++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ae4be060a..ff8391585 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -23,6 +23,7 @@ * [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status * [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs * [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when setting interfaces to tagged mode in bulk +* [#4033](https://github.com/netbox-community/netbox/issues/4033) - Restore missing comments field label of various bulk edit forms --- diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index d5d78e7bd..165e32eb3 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -89,7 +89,8 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdi label='Admin contact' ) comments = CommentField( - widget=SmallTextarea() + widget=SmallTextarea, + label='Comments' ) class Meta: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e713d2c7c..ca5d25389 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -676,7 +676,8 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor widget=StaticSelect2() ) comments = CommentField( - widget=SmallTextarea + widget=SmallTextarea, + label='Comments' ) class Meta: @@ -4418,8 +4419,9 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_utilization = forms.IntegerField( required=False ) - comments = forms.CharField( - required=False + comments = CommentField( + widget=SmallTextarea, + label='Comments' ) class Meta: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index ae516fcb3..cbabc20c4 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -171,7 +171,8 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ) ) comments = CommentField( - widget=SmallTextarea() + widget=SmallTextarea, + label='Comments' ) class Meta: @@ -535,7 +536,8 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB label='Disk (GB)' ) comments = CommentField( - widget=SmallTextarea() + widget=SmallTextarea, + label='Comments' ) class Meta: From be716a33458d4f35b7d537a90709adfe13e37e61 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 16:33:55 -0500 Subject: [PATCH 36/99] Release v2.7.3 --- docs/release-notes/version-2.7.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ff8391585..a6a6b65cf 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,4 +1,4 @@ -# v2.7.3 (FUTURE) +# v2.7.3 (2020-01-28) ## Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f747c3b1d..3a780f55a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.3-dev' +VERSION = '2.7.3' # Hostname HOSTNAME = platform.node() From 4629cda9ad8bbd59075cfcf7323e5fd03efcdb30 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 28 Jan 2020 16:42:33 -0500 Subject: [PATCH 37/99] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3a780f55a..8cdbb60a3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured # Environment setup # -VERSION = '2.7.3' +VERSION = '2.7.4-dev' # Hostname HOSTNAME = platform.node() From 6fefa3c7dd10d931d801ebd7e735813734a7ac7e Mon Sep 17 00:00:00 2001 From: Kevin Newland Date: Tue, 28 Jan 2020 18:34:26 -0600 Subject: [PATCH 38/99] update ldap documentation use new ldap cache configuration in documentation https://github.com/netbox-community/netbox/blob/develop/netbox/netbox/settings.py#L360 --- docs/installation/4-ldap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/4-ldap.md b/docs/installation/4-ldap.md index a41400808..953d3cb28 100644 --- a/docs/installation/4-ldap.md +++ b/docs/installation/4-ldap.md @@ -110,8 +110,8 @@ AUTH_LDAP_USER_FLAGS_BY_GROUP = { AUTH_LDAP_FIND_GROUP_PERMS = True # Cache groups for one hour to reduce LDAP traffic -AUTH_LDAP_CACHE_GROUPS = True -AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 +AUTH_LDAP_CACHE_TIMEOUT = 3600 + ``` * `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in. From e0d538ad310265a97b5f89c980d522cdd174ef2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 09:40:17 -0500 Subject: [PATCH 39/99] Fixes #4043: Fix toggling of required fields in custom scripts --- docs/release-notes/version-2.7.md | 8 ++++++++ netbox/extras/scripts.py | 9 +++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index a6a6b65cf..ab1e2de0d 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.4 (FUTURE) + +## Bug Fixes + +* [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts + +--- + # v2.7.3 (2020-01-28) ## Enhancements diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index bd7e864e1..6567fe707 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -53,14 +53,15 @@ class ScriptVariable: # Initialize field attributes if not hasattr(self, 'field_attrs'): self.field_attrs = {} - if description: - self.field_attrs['help_text'] = description if label: self.field_attrs['label'] = label + if description: + self.field_attrs['help_text'] = description if default: self.field_attrs['initial'] = default - if required: - self.field_attrs['required'] = True + self.field_attrs['required'] = required + + # Initialize the list of optional validators if none have already been defined if 'validators' not in self.field_attrs: self.field_attrs['validators'] = [] From bc7cf63958b9f625c2e05b5aaae2944d957df1bc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 10:49:02 -0500 Subject: [PATCH 40/99] Rename and refactor CustomFieldForm --- netbox/circuits/forms.py | 10 +++++----- netbox/dcim/forms.py | 20 ++++++++++---------- netbox/extras/forms.py | 29 ++++++++++++++++------------- netbox/ipam/forms.py | 26 +++++++++++++------------- netbox/secrets/forms.py | 6 +++--- netbox/tenancy/forms.py | 6 +++--- netbox/virtualization/forms.py | 10 +++++----- 7 files changed, 55 insertions(+), 52 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 6a528e5d6..b97f531b5 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,7 +2,7 @@ from django import forms from taggit.forms import TagField from dcim.models import Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -17,7 +17,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # Providers # -class ProviderForm(BootstrapMixin, CustomFieldForm): +class ProviderForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() comments = CommentField() tags = TagField( @@ -46,7 +46,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderCSVForm(CustomFieldForm): +class ProviderCSVForm(CustomFieldModelForm): slug = SlugField() class Meta: @@ -160,7 +160,7 @@ class CircuitTypeCSVForm(forms.ModelForm): # Circuits # -class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): comments = CommentField() tags = TagField( required=False @@ -188,7 +188,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class CircuitCSVForm(CustomFieldForm): +class CircuitCSVForm(CustomFieldModelForm): provider = forms.ModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 18779217a..216af8b63 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -13,7 +13,7 @@ from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( - AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm ) from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN @@ -215,7 +215,7 @@ class RegionFilterForm(BootstrapMixin, forms.Form): # Sites # -class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, @@ -263,7 +263,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class SiteCSVForm(CustomFieldForm): +class SiteCSVForm(CustomFieldModelForm): status = CSVChoiceField( choices=SiteStatusChoices, required=False, @@ -459,7 +459,7 @@ class RackRoleCSVForm(forms.ModelForm): # Racks # -class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): group = ChainedModelChoiceField( queryset=RackGroup.objects.all(), chains=( @@ -504,7 +504,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class RackCSVForm(CustomFieldForm): +class RackCSVForm(CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -897,7 +897,7 @@ class ManufacturerCSVForm(forms.ModelForm): # Device types # -class DeviceTypeForm(BootstrapMixin, CustomFieldForm): +class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField( slug_source='model' ) @@ -1516,7 +1516,7 @@ class PlatformCSVForm(forms.ModelForm): # Devices # -class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=APISelect( @@ -1724,7 +1724,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceCSVForm(CustomFieldForm): +class BaseDeviceCSVForm(CustomFieldModelForm): device_role = forms.ModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -4241,7 +4241,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # Power feeds # -class PowerFeedForm(BootstrapMixin, CustomFieldForm): +class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): site = ChainedModelChoiceField( queryset=Site.objects.all(), required=False, @@ -4286,7 +4286,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm): self.initial['site'] = self.instance.power_panel.site -class PowerFeedCSVForm(CustomFieldForm): +class PowerFeedCSVForm(CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 314455b83..bb1015638 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, - LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, SlugField, + StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -71,7 +71,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F default_choice = cf.choices.get(value=initial).pk except ObjectDoesNotExist: pass - field = CustomFieldChoiceField( + field = forms.TypedChoiceField( choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() ) @@ -93,21 +93,15 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F return field_dict -class CustomFieldForm(forms.ModelForm): +class CustomFieldModelForm(forms.ModelForm): def __init__(self, *args, **kwargs): - self.custom_fields = [] - self.obj_type = ContentType.objects.get_for_model(self._meta.model) - super().__init__(*args, **kwargs) - # Add all applicable CustomFields to the form - custom_fields = [] - for name, field in get_custom_fields_for_model(self.obj_type).items(): - self.fields[name] = field - custom_fields.append(name) - self.custom_fields = custom_fields + # Append form fields for CustomFields + self.obj_type = ContentType.objects.get_for_model(self._meta.model) + self._append_customfield_fields() # If editing an existing object, initialize values for all custom fields if self.instance.pk: @@ -118,6 +112,15 @@ class CustomFieldForm(forms.ModelForm): for cfv in existing_values: self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value + def _append_customfield_fields(self): + """ + Append form fields for all applicable CustomFields. + """ + self.custom_fields = [] + for name, field in get_custom_fields_for_model(self.obj_type).items(): + self.fields[name] = field + self.custom_fields.append(name) + def _save_custom_fields(self): for field_name in self.custom_fields: diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3dda6aba5..594e95f58 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from taggit.forms import TagField from dcim.models import Device, Interface, Rack, Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -31,7 +31,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ # VRFs # -class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): tags = TagField( required=False ) @@ -49,7 +49,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VRFCSVForm(CustomFieldForm): +class VRFCSVForm(CustomFieldModelForm): tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -144,7 +144,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # Aggregates # -class AggregateForm(BootstrapMixin, CustomFieldForm): +class AggregateForm(BootstrapMixin, CustomFieldModelForm): tags = TagField( required=False ) @@ -166,7 +166,7 @@ class AggregateForm(BootstrapMixin, CustomFieldForm): } -class AggregateCSVForm(CustomFieldForm): +class AggregateCSVForm(CustomFieldModelForm): rir = forms.ModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -263,7 +263,7 @@ class RoleCSVForm(forms.ModelForm): # Prefixes # -class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -341,7 +341,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class PrefixCSVForm(CustomFieldForm): +class PrefixCSVForm(CustomFieldModelForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -584,7 +584,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) # IP addresses # -class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): +class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): interface = forms.ModelChoiceField( queryset=Interface.objects.all(), required=False @@ -751,7 +751,7 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): ) -class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = IPAddress @@ -771,7 +771,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class IPAddressCSVForm(CustomFieldForm): +class IPAddressCSVForm(CustomFieldModelForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -1087,7 +1087,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # VLANs # -class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1135,7 +1135,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VLANCSVForm(CustomFieldForm): +class VLANCSVForm(CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1310,7 +1310,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # Services # -class ServiceForm(BootstrapMixin, CustomFieldForm): +class ServiceForm(BootstrapMixin, CustomFieldModelForm): port = forms.IntegerField( min_value=SERVICE_PORT_MIN, max_value=SERVICE_PORT_MAX diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index ace977b3a..e8300a4cc 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -4,7 +4,7 @@ from django import forms from taggit.forms import TagField from dcim.models import Device -from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm +from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, StaticSelect2Multiple @@ -68,7 +68,7 @@ class SecretRoleCSVForm(forms.ModelForm): # Secrets # -class SecretForm(BootstrapMixin, CustomFieldForm): +class SecretForm(BootstrapMixin, CustomFieldModelForm): plaintext = forms.CharField( max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, @@ -116,7 +116,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm): }) -class SecretCSVForm(CustomFieldForm): +class SecretCSVForm(CustomFieldModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 22deae434..c713f37f5 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,7 +1,7 @@ from django import forms from taggit.forms import TagField -from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, @@ -38,7 +38,7 @@ class TenantGroupCSVForm(forms.ModelForm): # Tenants # -class TenantForm(BootstrapMixin, CustomFieldForm): +class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() comments = CommentField() tags = TagField( @@ -57,7 +57,7 @@ class TenantForm(BootstrapMixin, CustomFieldForm): } -class TenantCSVForm(CustomFieldForm): +class TenantCSVForm(CustomFieldModelForm): slug = SlugField() group = forms.ModelChoiceField( queryset=TenantGroup.objects.all(), diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index a6bda262f..fb6a633b8 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm +from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelForm, CustomFieldFilterForm from ipam.models import IPAddress, VLANGroup, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant @@ -74,7 +74,7 @@ class ClusterGroupCSVForm(forms.ModelForm): # Clusters # -class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): comments = CommentField() tags = TagField( required=False @@ -98,7 +98,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class ClusterCSVForm(CustomFieldForm): +class ClusterCSVForm(CustomFieldModelForm): type = forms.ModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -327,7 +327,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): # Virtual Machines # -class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): +class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): cluster_group = forms.ModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -430,7 +430,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VirtualMachineCSVForm(CustomFieldForm): +class VirtualMachineCSVForm(CustomFieldModelForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, required=False, From f12199dcb589faef4ee192f3c586a99c21a08420 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 11:00:03 -0500 Subject: [PATCH 41/99] Rename and simplify CustomFieldChoiceField --- netbox/utilities/forms.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1dd2c06a7..c175df3cd 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -442,21 +442,18 @@ class CSVChoiceField(forms.ChoiceField): return self.choice_values[value] -class CustomFieldChoiceField(forms.TypedChoiceField): +class CSVCustomFieldChoiceField(forms.TypedChoiceField): """ - Accept human-friendly label as input, and return the database value. If the label is not matched, the normal, - value-based input is assumed. + Invert the choice tuples: CSV import takes the human-friendly label as input rather than the database value """ + def __init__(self, *args, **kwargs): - def __init__(self, choices, *args, **kwargs): - super().__init__(choices=choices, *args, **kwargs) - self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} + if 'choices' in kwargs: + kwargs['choices'] = { + label: value for value, label in kwargs['choices'] + } - def clean(self, value): - # Check if the value is actually a label - if value in self.choice_values: - return self.choice_values[value] - return super().clean(value) + super().__init__(*args, **kwargs) class ExpandableNameField(forms.CharField): From 9929a05bfe2be621e8d9e53cae6eba322662f28b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 11:00:46 -0500 Subject: [PATCH 42/99] Update release notes --- docs/release-notes/version-2.7.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ada79c9fa..163a9c55c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,9 @@ # v2.7.4 (FUTURE) +## Enhancements + +* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV + ## Bug Fixes * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts @@ -10,7 +14,6 @@ ## Enhancements -* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable * [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts From 585ea71d1a5deed877ea42ad63faa4e940013e36 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 11:25:51 -0500 Subject: [PATCH 43/99] Move form field generation logic to CustomField class --- netbox/extras/forms.py | 63 ++------------------------------------- netbox/extras/models.py | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index bb1015638..674c3ff4c 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -3,15 +3,14 @@ from collections import OrderedDict from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from taggit.forms import TagField from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, SlugField, - StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2, + BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -32,63 +31,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F for cf in custom_fields: field_name = 'cf_{}'.format(str(cf.name)) - initial = cf.default if not bulk_edit else None - - # Integer - if cf.type == CustomFieldTypeChoices.TYPE_INTEGER: - field = forms.IntegerField(required=cf.required, initial=initial) - - # Boolean - elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN: - choices = ( - (None, '---------'), - (1, 'True'), - (0, 'False'), - ) - if initial is not None and initial.lower() in ['true', 'yes', '1']: - initial = 1 - elif initial is not None and initial.lower() in ['false', 'no', '0']: - initial = 0 - else: - initial = None - field = forms.NullBooleanField( - required=cf.required, initial=initial, widget=StaticSelect2(choices=choices) - ) - - # Date - elif cf.type == CustomFieldTypeChoices.TYPE_DATE: - field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker()) - - # Select - elif cf.type == CustomFieldTypeChoices.TYPE_SELECT: - choices = [(cfc.pk, cfc.value) for cfc in cf.choices.all()] - if not cf.required or bulk_edit or filterable_only: - choices = [(None, '---------')] + choices - # Check for a default choice - default_choice = None - if initial: - try: - default_choice = cf.choices.get(value=initial).pk - except ObjectDoesNotExist: - pass - field = forms.TypedChoiceField( - choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() - ) - - # URL - elif cf.type == CustomFieldTypeChoices.TYPE_URL: - field = LaxURLField(required=cf.required, initial=initial) - - # Text - else: - field = forms.CharField(max_length=255, required=cf.required, initial=initial) - - field.model = cf - field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() - if cf.description: - field.help_text = cf.description - - field_dict[field_name] = field + field_dict[field_name] = cf.to_form_field(set_initial=not bulk_edit) return field_dict diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a03494bb2..be8feda7b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,6 +1,7 @@ from collections import OrderedDict from datetime import date +from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -14,6 +15,7 @@ from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase from utilities.fields import ColorField +from utilities.forms import DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * @@ -280,6 +282,70 @@ class CustomField(models.Model): return self.choices.get(pk=int(serialized_value)) return serialized_value + def to_form_field(self, set_initial=True): + """ + Return a form field suitable for setting a CustomField's value for an object. + + set_initial: Set initial date for the field. This should be false when generating a field for bulk editing. + """ + initial = self.default if set_initial else None + + # Integer + if self.type == CustomFieldTypeChoices.TYPE_INTEGER: + field = forms.IntegerField(required=self.required, initial=initial) + + # Boolean + elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + choices = ( + (None, '---------'), + (1, 'True'), + (0, 'False'), + ) + if initial is not None and initial.lower() in ['true', 'yes', '1']: + initial = 1 + elif initial is not None and initial.lower() in ['false', 'no', '0']: + initial = 0 + else: + initial = None + field = forms.NullBooleanField( + required=self.required, initial=initial, widget=StaticSelect2(choices=choices) + ) + + # Date + elif self.type == CustomFieldTypeChoices.TYPE_DATE: + field = forms.DateField(required=self.required, initial=initial, widget=DatePicker()) + + # Select + elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] + # TODO: Accommodate bulk edit/filtering + # if not self.required or bulk_edit or filterable_only: + if not self.required: + choices = add_blank_choice(choices) + # Set the initial value to the PK of the default choice, if any + if set_initial: + default_choice = self.choices.filter(value=self.default).first() + if default_choice: + initial = default_choice.pk + field = forms.TypedChoiceField( + choices=choices, coerce=int, required=self.required, initial=initial, widget=StaticSelect2() + ) + + # URL + elif self.type == CustomFieldTypeChoices.TYPE_URL: + field = LaxURLField(required=self.required, initial=initial) + + # Text + else: + field = forms.CharField(max_length=255, required=self.required, initial=initial) + + field.model = self + field.label = self.label if self.label else self.name.replace('_', ' ').capitalize() + if self.description: + field.help_text = self.description + + return field + class CustomFieldValue(models.Model): field = models.ForeignKey( From c3f86456d669d989661f4e3451ad7eb4a862aa0a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 12:12:47 -0500 Subject: [PATCH 44/99] Remove get_custom_fields_for_model() --- netbox/extras/forms.py | 45 ++++++++++++++--------------------------- netbox/extras/models.py | 20 +++++++++--------- 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 674c3ff4c..f5d331ac4 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -20,22 +20,6 @@ from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachmen # Custom fields # -def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False): - """ - Retrieve all CustomFields applicable to the given ContentType - """ - field_dict = OrderedDict() - custom_fields = CustomField.objects.filter(obj_type=content_type) - if filterable_only: - custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) - - for cf in custom_fields: - field_name = 'cf_{}'.format(str(cf.name)) - field_dict[field_name] = cf.to_form_field(set_initial=not bulk_edit) - - return field_dict - - class CustomFieldModelForm(forms.ModelForm): def __init__(self, *args, **kwargs): @@ -60,9 +44,10 @@ class CustomFieldModelForm(forms.ModelForm): Append form fields for all applicable CustomFields. """ self.custom_fields = [] - for name, field in get_custom_fields_for_model(self.obj_type).items(): - self.fields[name] = field - self.custom_fields.append(name) + custom_fields = CustomField.objects.filter(obj_type=self.obj_type) + for cf in custom_fields: + self.fields[cf.name] = cf.to_form_field() + self.custom_fields.append(cf.name) def _save_custom_fields(self): @@ -106,15 +91,14 @@ class CustomFieldBulkEditForm(BulkEditForm): self.obj_type = ContentType.objects.get_for_model(self.model) # Add all applicable CustomFields to the form - custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items() - for name, field in custom_fields: + custom_fields = CustomField.objects.filter(obj_type=self.obj_type) + for cf in custom_fields: # Annotate non-required custom fields as nullable - if not field.required: - self.nullable_fields.append(name) - field.required = False - self.fields[name] = field + if not cf.required: + self.nullable_fields.append(cf.name) + self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) # Annotate this as a custom field - self.custom_fields.append(name) + self.custom_fields.append(cf.name) class CustomFieldFilterForm(forms.Form): @@ -126,10 +110,11 @@ class CustomFieldFilterForm(forms.Form): super().__init__(*args, **kwargs) # Add all applicable CustomFields to the form - custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items() - for name, field in custom_fields: - field.required = False - self.fields[name] = field + custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude( + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED + ) + for cf in custom_fields: + self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False) # diff --git a/netbox/extras/models.py b/netbox/extras/models.py index be8feda7b..db42fc845 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -282,17 +282,19 @@ class CustomField(models.Model): return self.choices.get(pk=int(serialized_value)) return serialized_value - def to_form_field(self, set_initial=True): + def to_form_field(self, set_initial=True, enforce_required=True): """ Return a form field suitable for setting a CustomField's value for an object. - set_initial: Set initial date for the field. This should be false when generating a field for bulk editing. + set_initial: Set initial date for the field. This should be False when generating a field for bulk editing. + enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. """ initial = self.default if set_initial else None + required = self.required if enforce_required else False # Integer if self.type == CustomFieldTypeChoices.TYPE_INTEGER: - field = forms.IntegerField(required=self.required, initial=initial) + field = forms.IntegerField(required=required, initial=initial) # Boolean elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: @@ -308,19 +310,19 @@ class CustomField(models.Model): else: initial = None field = forms.NullBooleanField( - required=self.required, initial=initial, widget=StaticSelect2(choices=choices) + required=required, initial=initial, widget=StaticSelect2(choices=choices) ) # Date elif self.type == CustomFieldTypeChoices.TYPE_DATE: - field = forms.DateField(required=self.required, initial=initial, widget=DatePicker()) + field = forms.DateField(required=required, initial=initial, widget=DatePicker()) # Select elif self.type == CustomFieldTypeChoices.TYPE_SELECT: choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] # TODO: Accommodate bulk edit/filtering # if not self.required or bulk_edit or filterable_only: - if not self.required: + if not required: choices = add_blank_choice(choices) # Set the initial value to the PK of the default choice, if any if set_initial: @@ -328,16 +330,16 @@ class CustomField(models.Model): if default_choice: initial = default_choice.pk field = forms.TypedChoiceField( - choices=choices, coerce=int, required=self.required, initial=initial, widget=StaticSelect2() + choices=choices, coerce=int, required=required, initial=initial, widget=StaticSelect2() ) # URL elif self.type == CustomFieldTypeChoices.TYPE_URL: - field = LaxURLField(required=self.required, initial=initial) + field = LaxURLField(required=required, initial=initial) # Text else: - field = forms.CharField(max_length=255, required=self.required, initial=initial) + field = forms.CharField(max_length=255, required=required, initial=initial) field.model = self field.label = self.label if self.label else self.name.replace('_', ' ').capitalize() From 35f2291edc43265864b6cf6aece3467d4ce4e120 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 13:31:36 -0500 Subject: [PATCH 45/99] Fix assignment of initial CustomField values when editing an object --- netbox/extras/forms.py | 43 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index f5d331ac4..0123fead9 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -24,30 +22,37 @@ class CustomFieldModelForm(forms.ModelForm): def __init__(self, *args, **kwargs): + self.obj_type = ContentType.objects.get_for_model(self._meta.model) + self.custom_fields = [] + self.custom_field_values = {} + super().__init__(*args, **kwargs) - # Append form fields for CustomFields - self.obj_type = ContentType.objects.get_for_model(self._meta.model) self._append_customfield_fields() - # If editing an existing object, initialize values for all custom fields - if self.instance.pk: - existing_values = CustomFieldValue.objects.filter( - obj_type=self.obj_type, - obj_id=self.instance.pk - ).prefetch_related('field') - for cfv in existing_values: - self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value - def _append_customfield_fields(self): """ - Append form fields for all applicable CustomFields. + Append form fields for all CustomFields assigned to this model. """ - self.custom_fields = [] - custom_fields = CustomField.objects.filter(obj_type=self.obj_type) - for cf in custom_fields: - self.fields[cf.name] = cf.to_form_field() - self.custom_fields.append(cf.name) + # Retrieve initial CustomField values for the instance + if self.instance.pk: + for cfv in CustomFieldValue.objects.filter( + obj_type=self.obj_type, + obj_id=self.instance.pk + ).prefetch_related('field'): + self.custom_field_values[cfv.field.name] = cfv.serialized_value + + # Append form fields; assign initial values if modifying and existing object + for cf in CustomField.objects.filter(obj_type=self.obj_type): + field_name = 'cf_{}'.format(cf.name) + if self.instance.pk: + self.fields[field_name] = cf.to_form_field(set_initial=False) + self.fields[field_name].initial = self.custom_field_values.get(cf.name) + else: + self.fields[field_name] = cf.to_form_field() + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) def _save_custom_fields(self): From e6b018909db8a2eb8ca650c439953547adee1b80 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 13:53:26 -0500 Subject: [PATCH 46/99] Introduced CustomFieldModelCSVForm --- netbox/circuits/forms.py | 8 +++++--- netbox/dcim/forms.py | 11 ++++++----- netbox/extras/forms.py | 13 +++++++++++++ netbox/ipam/forms.py | 14 ++++++++------ netbox/secrets/forms.py | 6 ++++-- netbox/tenancy/forms.py | 4 +++- netbox/virtualization/forms.py | 8 +++++--- 7 files changed, 44 insertions(+), 20 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index b97f531b5..643359b74 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -2,7 +2,9 @@ from django import forms from taggit.forms import TagField from dcim.models import Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, +) from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -46,7 +48,7 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm): } -class ProviderCSVForm(CustomFieldModelForm): +class ProviderCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -188,7 +190,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class CircuitCSVForm(CustomFieldModelForm): +class CircuitCSVForm(CustomFieldModelCSVForm): provider = forms.ModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 216af8b63..f57f942e0 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -13,7 +13,8 @@ from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, + LocalConfigContextFilterForm, ) from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN @@ -263,7 +264,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class SiteCSVForm(CustomFieldModelForm): +class SiteCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, required=False, @@ -504,7 +505,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class RackCSVForm(CustomFieldModelForm): +class RackCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -1724,7 +1725,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceCSVForm(CustomFieldModelForm): +class BaseDeviceCSVForm(CustomFieldModelCSVForm): device_role = forms.ModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -4286,7 +4287,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): self.initial['site'] = self.instance.power_panel.site -class PowerFeedCSVForm(CustomFieldModelForm): +class PowerFeedCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 0123fead9..2c6b6cb65 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -87,6 +87,19 @@ class CustomFieldModelForm(forms.ModelForm): return obj +class CustomFieldModelCSVForm(CustomFieldModelForm): + + def _append_customfield_fields(self): + + # Append form fields + for cf in CustomField.objects.filter(obj_type=self.obj_type): + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field() + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) + + class CustomFieldBulkEditForm(BulkEditForm): def __init__(self, *args, **kwargs): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 594e95f58..237ee2238 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -4,7 +4,9 @@ from django.core.validators import MaxValueValidator, MinValueValidator from taggit.forms import TagField from dcim.models import Device, Interface, Rack, Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, +) from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( @@ -49,7 +51,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class VRFCSVForm(CustomFieldModelForm): +class VRFCSVForm(CustomFieldModelCSVForm): tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -166,7 +168,7 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): } -class AggregateCSVForm(CustomFieldModelForm): +class AggregateCSVForm(CustomFieldModelCSVForm): rir = forms.ModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -341,7 +343,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['vrf'].empty_label = 'Global' -class PrefixCSVForm(CustomFieldModelForm): +class PrefixCSVForm(CustomFieldModelCSVForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -771,7 +773,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['vrf'].empty_label = 'Global' -class IPAddressCSVForm(CustomFieldModelForm): +class IPAddressCSVForm(CustomFieldModelCSVForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -1135,7 +1137,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class VLANCSVForm(CustomFieldModelForm): +class VLANCSVForm(CustomFieldModelCSVForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index e8300a4cc..46b2c12f4 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -4,7 +4,9 @@ from django import forms from taggit.forms import TagField from dcim.models import Device -from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, +) from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, StaticSelect2Multiple @@ -116,7 +118,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): }) -class SecretCSVForm(CustomFieldModelForm): +class SecretCSVForm(CustomFieldModelCSVForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index c713f37f5..22a4809de 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,7 +1,9 @@ from django import forms from taggit.forms import TagField -from extras.forms import AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, +) from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index fb6a633b8..066e162d3 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -6,7 +6,9 @@ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site -from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelForm, CustomFieldFilterForm +from extras.forms import ( + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, +) from ipam.models import IPAddress, VLANGroup, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant @@ -98,7 +100,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } -class ClusterCSVForm(CustomFieldModelForm): +class ClusterCSVForm(CustomFieldModelCSVForm): type = forms.ModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -430,7 +432,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VirtualMachineCSVForm(CustomFieldModelForm): +class VirtualMachineCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, required=False, From 193435b554c59df0dcc188f769ee7f452d466960 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 14:29:47 -0500 Subject: [PATCH 47/99] Enable CSV import for custom fields --- netbox/extras/forms.py | 2 +- netbox/extras/models.py | 15 +++++++++------ netbox/utilities/forms.py | 14 -------------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 2c6b6cb65..751b2ddb0 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -94,7 +94,7 @@ class CustomFieldModelCSVForm(CustomFieldModelForm): # Append form fields for cf in CustomField.objects.filter(obj_type=self.obj_type): field_name = 'cf_{}'.format(cf.name) - self.fields[field_name] = cf.to_form_field() + self.fields[field_name] = cf.to_form_field(for_csv_import=True) # Annotate the field in the list of CustomField form fields self.custom_fields.append(field_name) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index db42fc845..70f7661c8 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -15,7 +15,7 @@ from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase from utilities.fields import ColorField -from utilities.forms import DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice from utilities.utils import deepmerge, render_jinja2 from .choices import * from .constants import * @@ -282,12 +282,13 @@ class CustomField(models.Model): return self.choices.get(pk=int(serialized_value)) return serialized_value - def to_form_field(self, set_initial=True, enforce_required=True): + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. set_initial: Set initial date for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. + for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ initial = self.default if set_initial else None required = self.required if enforce_required else False @@ -320,17 +321,19 @@ class CustomField(models.Model): # Select elif self.type == CustomFieldTypeChoices.TYPE_SELECT: choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()] - # TODO: Accommodate bulk edit/filtering - # if not self.required or bulk_edit or filterable_only: + if not required: choices = add_blank_choice(choices) + # Set the initial value to the PK of the default choice, if any if set_initial: default_choice = self.choices.filter(value=self.default).first() if default_choice: initial = default_choice.pk - field = forms.TypedChoiceField( - choices=choices, coerce=int, required=required, initial=initial, widget=StaticSelect2() + + field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2() ) # URL diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index c175df3cd..a14ec9305 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -442,20 +442,6 @@ class CSVChoiceField(forms.ChoiceField): return self.choice_values[value] -class CSVCustomFieldChoiceField(forms.TypedChoiceField): - """ - Invert the choice tuples: CSV import takes the human-friendly label as input rather than the database value - """ - def __init__(self, *args, **kwargs): - - if 'choices' in kwargs: - kwargs['choices'] = { - label: value for value, label in kwargs['choices'] - } - - super().__init__(*args, **kwargs) - - class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion From c75315fda63a6b9809344bccea99b08ecfb3a365 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 15:34:55 -0500 Subject: [PATCH 48/99] Extend CSV import test --- netbox/extras/tests/test_customfields.py | 89 +++++++++++++++--------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 005f049b5..703b0b0d0 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -366,9 +366,9 @@ class CustomFieldChoiceAPITest(APITestCase): self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value]) -class CustomFieldCSV(TestCase): +class CustomFieldImportTest(TestCase): + def setUp(self): - super().setUp() user = create_test_user( permissions=[ @@ -379,47 +379,68 @@ class CustomFieldCSV(TestCase): self.client = Client() self.client.force_login(user) - obj_type = ContentType.objects.get_for_model(Site) + @classmethod + def setUpTestData(cls): - self.cf_text = CustomField.objects.create(name="text", type=CustomFieldTypeChoices.TYPE_TEXT) - self.cf_text.obj_type.set([obj_type]) - self.cf_text.save() + custom_fields = ( + CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), + CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), + CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), + CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), + CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), + CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT), + ) + for cf in custom_fields: + cf.save() + cf.obj_type.set([ContentType.objects.get_for_model(Site)]) - self.cf_choice = CustomField.objects.create(name="choice", type=CustomFieldTypeChoices.TYPE_SELECT) - self.cf_choice.obj_type.set([obj_type]) - self.cf_choice.save() - - self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_1") - self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_2") - self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_3") + CustomFieldChoice.objects.bulk_create(( + CustomFieldChoice(field=custom_fields[5], value='Choice A'), + CustomFieldChoice(field=custom_fields[5], value='Choice B'), + CustomFieldChoice(field=custom_fields[5], value='Choice C'), + )) def test_import(self): """ - Import a site with custom fields + Import a Site in CSV format, including a value for each CustomField. """ - csv_data = ( - "name,slug,cf_text,cf_choice", - "Site 1,site-1,something,cf_field_1", + data = ( + ('name', 'slug', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'), + ('Site 1', 'site-1', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'), + ('Site 2', 'site-2', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'), + ('Site 3', 'site-3', '', '', '', '', '', ''), ) + csv_data = '\n'.join(','.join(row) for row in data) - response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) + response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) self.assertEqual(response.status_code, 200) - site1_custom_fields = Site.objects.get(name='Site 1').get_custom_fields() - self.assertEqual(len(site1_custom_fields), 2) - self.assertEqual(site1_custom_fields[self.cf_text], 'something') - self.assertEqual(site1_custom_fields[self.cf_choice], self.cf_choice_1) + # Validate data for site 1 + custom_field_values = { + cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items() + } + self.assertEqual(len(custom_field_values), 6) + self.assertEqual(custom_field_values['text'], 'ABC') + self.assertEqual(custom_field_values['integer'], 123) + self.assertEqual(custom_field_values['boolean'], True) + self.assertEqual(custom_field_values['date'], date(2020, 1, 1)) + self.assertEqual(custom_field_values['url'], 'http://example.com/1') + self.assertEqual(custom_field_values['select'].value, 'Choice A') - def test_import_invalid_choice(self): - """ - Import a site with an invalid choice - """ - csv_data = ( - "name,slug,cf_choice", - "Site 2,site-2,cf_field_4", - ) + # Validate data for site 2 + custom_field_values = { + cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items() + } + self.assertEqual(len(custom_field_values), 6) + self.assertEqual(custom_field_values['text'], 'DEF') + self.assertEqual(custom_field_values['integer'], 456) + self.assertEqual(custom_field_values['boolean'], False) + self.assertEqual(custom_field_values['date'], date(2020, 1, 2)) + self.assertEqual(custom_field_values['url'], 'http://example.com/2') + self.assertEqual(custom_field_values['select'].value, 'Choice B') - response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) - - self.assertFalse(len(Site.objects.filter(name="Site 2")), 0) + # No CustomFieldValues should be created for site 3 + obj_type = ContentType.objects.get_for_model(Site) + site3 = Site.objects.get(name='Site 3') + self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists()) + self.assertEqual(CustomFieldValue.objects.count(), 12) # Sanity check From eafeaab014ce31e1703d0d634986dea4d00e8318 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 16:07:32 -0500 Subject: [PATCH 49/99] Add tests for invalid import data --- netbox/extras/tests/test_customfields.py | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 703b0b0d0..a6e2bfcec 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -5,6 +5,7 @@ from django.test import Client, TestCase from django.urls import reverse from rest_framework import status +from dcim.forms import SiteCSVForm from dcim.models import Site from extras.choices import * from extras.models import CustomField, CustomFieldValue, CustomFieldChoice @@ -444,3 +445,33 @@ class CustomFieldImportTest(TestCase): site3 = Site.objects.get(name='Site 3') self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists()) self.assertEqual(CustomFieldValue.objects.count(), 12) # Sanity check + + def test_import_missing_required(self): + """ + Attempt to import an object missing a required custom field. + """ + # Set one of our CustomFields to required + CustomField.objects.filter(name='text').update(required=True) + + form_data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + form = SiteCSVForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('cf_text', form.errors) + + def test_import_invalid_choice(self): + """ + Attempt to import an object with an invalid choice selection. + """ + form_data = { + 'name': 'Site 1', + 'slug': 'site-1', + 'cf_select': 'Choice X' + } + + form = SiteCSVForm(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn('cf_select', form.errors) From 03087e9d01c678600beb70a7eca8fca5506cd8b9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 29 Jan 2020 16:22:06 -0500 Subject: [PATCH 50/99] Fixes #4049: Restore missing tags field in IPAM service serializer --- docs/release-notes/version-2.7.md | 1 + netbox/ipam/api/serializers.py | 5 +++-- netbox/ipam/tests/test_api.py | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index ab1e2de0d..68487ebb8 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Bug Fixes * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts +* [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer --- diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 44f67d538..e52c172e5 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -237,7 +237,7 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(CustomFieldModelSerializer): +class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) protocol = ChoiceField(choices=ServiceProtocolChoices) @@ -247,10 +247,11 @@ class ServiceSerializer(CustomFieldModelSerializer): required=False, many=True ) + tags = TagListSerializerField(required=False) class Meta: model = Service fields = [ - 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', + 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 983787b0c..99a7eaca4 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1064,6 +1064,7 @@ class ServiceTest(APITestCase): 'name': 'Test Service 4', 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'port': 4, + 'tags': ['Foo', 'Bar'], } url = reverse('ipam-api:service-list') @@ -1076,6 +1077,8 @@ class ServiceTest(APITestCase): self.assertEqual(service4.name, data['name']) self.assertEqual(service4.protocol, data['protocol']) self.assertEqual(service4.port, data['port']) + tags = [tag.name for tag in service4.tags.all()] + self.assertEqual(sorted(tags), sorted(data['tags'])) def test_create_service_bulk(self): From 4ba25799368e9edfc14ad14f2ba892600a049466 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 10:12:53 -0500 Subject: [PATCH 51/99] Closes #4051: Disable the makemigrations management command --- docs/release-notes/version-2.7.md | 4 ++++ netbox/netbox/settings.py | 1 + .../management/commands/makemigrations.py | 23 ++++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 68487ebb8..5ee902558 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,9 @@ # v2.7.4 (FUTURE) +## Enhancements + +* [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command + ## Bug Fixes * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8cdbb60a3..483c21121 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -74,6 +74,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) +DEVELOPER = getattr(configuration, 'DEVELOPER', False) EMAIL = getattr(configuration, 'EMAIL', {}) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) diff --git a/netbox/utilities/management/commands/makemigrations.py b/netbox/utilities/management/commands/makemigrations.py index fbcf82eaf..69f699796 100644 --- a/netbox/utilities/management/commands/makemigrations.py +++ b/netbox/utilities/management/commands/makemigrations.py @@ -1,7 +1,28 @@ # noinspection PyUnresolvedReferences -from django.core.management.commands.makemigrations import Command +from django.conf import settings +from django.core.management.base import CommandError +from django.core.management.commands.makemigrations import Command as _Command from django.db import models from . import custom_deconstruct models.Field.deconstruct = custom_deconstruct + + +class Command(_Command): + + def handle(self, *args, **kwargs): + """ + This built-in management command enables the creation of new database schema migration files, which should + never be required by and ordinary user. We prevent this command from executing unless the configuration + indicates that the user is a developer (i.e. configuration.DEVELOPER == True). + """ + if not settings.DEVELOPER: + raise CommandError( + "This command is available for development purposes only. It will\n" + "NOT resolve any issues with missing or unapplied migrations. For assistance,\n" + "please post to the NetBox mailing list:\n" + " https://groups.google.com/forum/#!forum/netbox-discuss" + ) + + super().handle(*args, **kwargs) From 923c2728b38407335dec892386aa41ddc980204d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 12:08:40 -0500 Subject: [PATCH 52/99] Fixes #4056: Repair schema migration for Rack.outer_unit (from #3569) --- docs/release-notes/version-2.7.md | 1 + .../dcim/migrations/0079_3569_rack_fields.py | 2 +- .../migrations/0092_fix_rack_outer_unit.py | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 netbox/dcim/migrations/0092_fix_rack_outer_unit.py diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 68487ebb8..9fc1c1c91 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -4,6 +4,7 @@ * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer +* [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569) --- diff --git a/netbox/dcim/migrations/0079_3569_rack_fields.py b/netbox/dcim/migrations/0079_3569_rack_fields.py index 4e76a270f..da544bb7a 100644 --- a/netbox/dcim/migrations/0079_3569_rack_fields.py +++ b/netbox/dcim/migrations/0079_3569_rack_fields.py @@ -37,7 +37,7 @@ def rack_status_to_slug(apps, schema_editor): def rack_outer_unit_to_slug(apps, schema_editor): Rack = apps.get_model('dcim', 'Rack') for id, slug in RACK_DIMENSION_CHOICES: - Rack.objects.filter(status=str(id)).update(status=slug) + Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug) class Migration(migrations.Migration): diff --git a/netbox/dcim/migrations/0092_fix_rack_outer_unit.py b/netbox/dcim/migrations/0092_fix_rack_outer_unit.py new file mode 100644 index 000000000..2a8cbf4e5 --- /dev/null +++ b/netbox/dcim/migrations/0092_fix_rack_outer_unit.py @@ -0,0 +1,27 @@ +from django.db import migrations + +RACK_DIMENSION_CHOICES = ( + (1000, 'mm'), + (2000, 'in'), +) + + +def rack_outer_unit_to_slug(apps, schema_editor): + Rack = apps.get_model('dcim', 'Rack') + for id, slug in RACK_DIMENSION_CHOICES: + Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0091_interface_type_other'), + ] + + operations = [ + # Fixes a missed field migration from #3569; see bug #4056. The original migration has also been fixed, + # so this can be omitted when squashing in the future. + migrations.RunPython( + code=rack_outer_unit_to_slug + ), + ] From d9b8bc0422144780846c7ce2b977f123dc861ef4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 13:39:50 -0500 Subject: [PATCH 53/99] Fix VM interfaces table header alignment --- netbox/templates/virtualization/virtualmachine.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 8cf1fd490..982ef0e5e 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -265,7 +265,9 @@ Name LAG Description + MTU Mode + Cable Connection From 4b02d294ceafc6d5fa7fa955992b88d1e3b42a07 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 13:55:39 -0500 Subject: [PATCH 54/99] Fixes #4052: Fix error when bulk importing interfaces to virtual machines --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 9fc1c1c91..1f6917730 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -4,6 +4,7 @@ * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer +* [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines * [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569) --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ca5d25389..70fe7afaf 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2726,7 +2726,7 @@ class InterfaceCSVForm(forms.ModelForm): super().__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device (or VC master) - if self.is_bound: + if self.is_bound and 'device' in self.data: try: device = self.fields['device'].to_python(self.data['device']) except forms.ValidationError: From d0d2af4cab9426a2e67767b708b2de0beedf4de1 Mon Sep 17 00:00:00 2001 From: agrrajag <57500846+agrrajag@users.noreply.github.com> Date: Thu, 30 Jan 2020 13:00:37 -0600 Subject: [PATCH 55/99] Update 3-http-daemon.md (#4055) There was no documentation to move back into the netbox folder after installing/configuring nginx. You would move into nginx on line 42 then try and figure out why you couldn't copy gunicorn on line 113. --- docs/installation/3-http-daemon.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/installation/3-http-daemon.md b/docs/installation/3-http-daemon.md index 5e19f54a2..cc1065fef 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/3-http-daemon.md @@ -107,9 +107,10 @@ Install gunicorn: # pip3 install gunicorn ``` -Copy `contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade. +Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade. ```no-highlight +# cd /opt/netbox # cp contrib/gunicorn.py /opt/netbox/gunicorn.py ``` From 1a25f5a7f255ad332a6e4bfa43cb97294565c215 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 15:12:10 -0500 Subject: [PATCH 56/99] Fixes #4030: Fix exception when bulk editing interfaces (revised) --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/device_components.py | 2 +- netbox/utilities/views.py | 28 ++++++++++++++++++------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 196c041c5..ba33e062c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -6,6 +6,7 @@ ## Bug Fixes +* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised) * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer * [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 68bab8037..e37569f79 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -676,7 +676,7 @@ class Interface(CableTermination, ComponentModel): self.untagged_vlan = None # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) - if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED: + if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED: self.tagged_vlans.clear() return super().save(*args, **kwargs) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d900a8545..88e5005bc 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -6,7 +6,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError -from django.db.models import Count, ProtectedError +from django.db.models import Count, ManyToManyField, ProtectedError from django.db.models.query import QuerySet from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse, HttpResponseServerError @@ -650,7 +650,9 @@ class BulkEditView(GetReturnURLMixin, View): if form.is_valid(): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] - standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk'] + standard_fields = [ + field for field in form.fields if field not in custom_fields + ['pk', 'add_tags', 'remove_tags'] + ] nullified_fields = request.POST.getlist('_nullify') try: @@ -662,14 +664,24 @@ class BulkEditView(GetReturnURLMixin, View): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: - if name in form.nullable_fields and name in nullified_fields and isinstance(form.cleaned_data[name], QuerySet): - getattr(obj, name).set([]) - elif name in form.nullable_fields and name in nullified_fields: - setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None) - elif isinstance(form.cleaned_data[name], QuerySet) and form.cleaned_data[name]: + + model_field = model._meta.get_field(name) + + # Handle nullification + if name in form.nullable_fields and name in nullified_fields: + if isinstance(model_field, ManyToManyField): + getattr(obj, name).set([]) + else: + setattr(obj, name, None if model_field.null else '') + + # ManyToManyFields + elif isinstance(model_field, ManyToManyField): getattr(obj, name).set(form.cleaned_data[name]) - elif form.cleaned_data[name] not in (None, '') and not isinstance(form.cleaned_data[name], QuerySet): + + # Normal fields + elif form.cleaned_data[name] not in (None, ''): setattr(obj, name, form.cleaned_data[name]) + obj.full_clean() obj.save() From 43b2c36066e9ab232cf3ca66dfe09f945498aef7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 16:03:52 -0500 Subject: [PATCH 57/99] Introduced a custom TestCase --- netbox/utilities/testing.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index 791eb64cb..39b43ab83 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -2,11 +2,44 @@ import logging from contextlib import contextmanager from django.contrib.auth.models import Permission, User +from django.test import Client, TestCase as _TestCase from rest_framework.test import APITestCase as _APITestCase from users.models import Token +class TestCase(_TestCase): + user_permissions = () + + def setUp(self): + + # Create the test user and assign permissions + self.user = User.objects.create_user(username='testuser') + self.add_permissions(*self.user_permissions) + + # Initialize the test client + self.client = Client() + self.client.force_login(self.user) + + def add_permissions(self, *names): + """ + Assign a set of permissions to the test user. Accepts permission names in the form ._. + """ + for name in names: + app, codename = name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + self.user.user_permissions.add(perm) + + def remove_permissions(self, *names): + """ + Remove a set of permissions from the test user, if assigned. + """ + for name in names: + app, codename = name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + self.user.user_permissions.remove(perm) + + class APITestCase(_APITestCase): def setUp(self): From 61ac7c44ba2cfb55b484dc4d0e36bf775c2e59f6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 16:37:40 -0500 Subject: [PATCH 58/99] Migrate view tests to use new TestCase class --- netbox/circuits/tests/test_views.py | 48 ++-- netbox/dcim/tests/test_views.py | 326 +++++++++------------- netbox/ipam/tests/test_views.py | 132 ++++----- netbox/secrets/tests/test_views.py | 52 ++-- netbox/tenancy/tests/test_views.py | 33 +-- netbox/virtualization/tests/test_views.py | 63 ++--- 6 files changed, 271 insertions(+), 383 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 576437ef1..d10d2df85 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,23 +1,18 @@ import urllib.parse -from django.test import Client, TestCase from django.urls import reverse from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import create_test_user +from utilities.testing import TestCase class ProviderTestCase(TestCase): + user_permissions = ( + 'circuits.view_provider', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_provider', - 'circuits.add_provider', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Provider.objects.bulk_create([ Provider(name='Provider 1', slug='provider-1', asn=65001), @@ -42,6 +37,7 @@ class ProviderTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_provider_import(self): + self.add_permissions('circuits.add_provider') csv_data = ( "name,slug", @@ -57,16 +53,12 @@ class ProviderTestCase(TestCase): class CircuitTypeTestCase(TestCase): + user_permissions = ( + 'circuits.view_circuittype', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_circuittype', - 'circuits.add_circuittype', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): CircuitType.objects.bulk_create([ CircuitType(name='Circuit Type 1', slug='circuit-type-1'), @@ -82,6 +74,7 @@ class CircuitTypeTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_circuittype_import(self): + self.add_permissions('circuits.add_circuittype') csv_data = ( "name,slug", @@ -97,16 +90,12 @@ class CircuitTypeTestCase(TestCase): class CircuitTestCase(TestCase): + user_permissions = ( + 'circuits.view_circuit', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'circuits.view_circuit', - 'circuits.add_circuit', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): provider = Provider(name='Provider 1', slug='provider-1', asn=65001) provider.save() @@ -138,6 +127,7 @@ class CircuitTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_circuit_import(self): + self.add_permissions('circuits.add_circuit') csv_data = ( "cid,provider,type", diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 856862a3e..45887f171 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,26 +1,22 @@ import urllib.parse import yaml -from django.test import Client, TestCase +from django.contrib.auth.models import User from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.models import * -from utilities.testing import create_test_user +from utilities.testing import TestCase class RegionTestCase(TestCase): + user_permissions = ( + 'dcim.view_region', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_region', - 'dcim.add_region', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): # Create three Regions for i in range(1, 4): @@ -34,6 +30,7 @@ class RegionTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_region_import(self): + self.add_permissions('dcim.add_region') csv_data = ( "name,slug", @@ -49,16 +46,12 @@ class RegionTestCase(TestCase): class SiteTestCase(TestCase): + user_permissions = ( + 'dcim.view_site', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_site', - 'dcim.add_site', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): region = Region(name='Region 1', slug='region-1') region.save() @@ -86,6 +79,7 @@ class SiteTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_site_import(self): + self.add_permissions('dcim.add_site') csv_data = ( "name,slug", @@ -101,16 +95,12 @@ class SiteTestCase(TestCase): class RackGroupTestCase(TestCase): + user_permissions = ( + 'dcim.view_rackgroup', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rackgroup', - 'dcim.add_rackgroup', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -129,6 +119,7 @@ class RackGroupTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_rackgroup_import(self): + self.add_permissions('dcim.add_rackgroup') csv_data = ( "site,name,slug", @@ -144,16 +135,12 @@ class RackGroupTestCase(TestCase): class RackRoleTestCase(TestCase): + user_permissions = ( + 'dcim.view_rackrole', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rackrole', - 'dcim.add_rackrole', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): RackRole.objects.bulk_create([ RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -169,6 +156,7 @@ class RackRoleTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_rackrole_import(self): + self.add_permissions('dcim.add_rackrole') csv_data = ( "name,slug,color", @@ -184,11 +172,14 @@ class RackRoleTestCase(TestCase): class RackReservationTestCase(TestCase): + user_permissions = ( + 'dcim.view_rackreservation', + ) - def setUp(self): - user = create_test_user(permissions=['dcim.view_rackreservation']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): + + user = User.objects.create_user(username='testuser2') site = Site(name='Site 1', slug='site-1') site.save() @@ -211,16 +202,12 @@ class RackReservationTestCase(TestCase): class RackTestCase(TestCase): + user_permissions = ( + 'dcim.view_rack', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rack', - 'dcim.add_rack', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -248,6 +235,7 @@ class RackTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_rack_import(self): + self.add_permissions('dcim.add_rack') csv_data = ( "site,name,width,u_height", @@ -263,16 +251,12 @@ class RackTestCase(TestCase): class ManufacturerTypeTestCase(TestCase): + user_permissions = ( + 'dcim.view_manufacturer', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_manufacturer', - 'dcim.add_manufacturer', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Manufacturer.objects.bulk_create([ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -288,6 +272,7 @@ class ManufacturerTypeTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_manufacturer_import(self): + self.add_permissions('dcim.add_manufacturer') csv_data = ( "name,slug", @@ -303,11 +288,12 @@ class ManufacturerTypeTestCase(TestCase): class DeviceTypeTestCase(TestCase): + user_permissions = ( + 'dcim.view_devicetype', + ) - def setUp(self): - user = create_test_user(permissions=['dcim.view_devicetype']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') manufacturer.save() @@ -420,9 +406,8 @@ device-bays: # Create the manufacturer Manufacturer(name='Generic', slug='generic').save() - # Authenticate as user with necessary permissions - user = create_test_user(username='testuser2', permissions=[ - 'dcim.view_devicetype', + # Add all required permissions to the test user + self.add_permissions( 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', 'dcim.add_consoleserverporttemplate', @@ -432,8 +417,7 @@ device-bays: 'dcim.add_frontporttemplate', 'dcim.add_rearporttemplate', 'dcim.add_devicebaytemplate', - ]) - self.client.force_login(user) + ) form_data = { 'data': IMPORT_DATA, @@ -489,16 +473,12 @@ device-bays: class DeviceRoleTestCase(TestCase): + user_permissions = ( + 'dcim.view_devicerole', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_devicerole', - 'dcim.add_devicerole', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): DeviceRole.objects.bulk_create([ DeviceRole(name='Device Role 1', slug='device-role-1'), @@ -514,6 +494,7 @@ class DeviceRoleTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_devicerole_import(self): + self.add_permissions('dcim.add_devicerole') csv_data = ( "name,slug,color", @@ -529,16 +510,12 @@ class DeviceRoleTestCase(TestCase): class PlatformTestCase(TestCase): + user_permissions = ( + 'dcim.view_platform', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_platform', - 'dcim.add_platform', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Platform.objects.bulk_create([ Platform(name='Platform 1', slug='platform-1'), @@ -554,6 +531,7 @@ class PlatformTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_platform_import(self): + self.add_permissions('dcim.add_platform') csv_data = ( "name,slug", @@ -569,16 +547,12 @@ class PlatformTestCase(TestCase): class DeviceTestCase(TestCase): + user_permissions = ( + 'dcim.view_device', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_device', - 'dcim.add_device', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -616,6 +590,7 @@ class DeviceTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_device_import(self): + self.add_permissions('dcim.add_device') csv_data = ( "device_role,manufacturer,model_name,status,site,name", @@ -631,16 +606,12 @@ class DeviceTestCase(TestCase): class ConsolePortTestCase(TestCase): + user_permissions = ( + 'dcim.view_consoleport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_consoleport', - 'dcim.add_consoleport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -671,6 +642,7 @@ class ConsolePortTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_consoleport_import(self): + self.add_permissions('dcim.add_consoleport') csv_data = ( "device,name", @@ -686,16 +658,12 @@ class ConsolePortTestCase(TestCase): class ConsoleServerPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_consoleserverport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_consoleserverport', - 'dcim.add_consoleserverport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -726,6 +694,7 @@ class ConsoleServerPortTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_consoleserverport_import(self): + self.add_permissions('dcim.add_consoleserverport') csv_data = ( "device,name", @@ -741,16 +710,12 @@ class ConsoleServerPortTestCase(TestCase): class PowerPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_powerport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_powerport', - 'dcim.add_powerport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -781,6 +746,7 @@ class PowerPortTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_powerport_import(self): + self.add_permissions('dcim.add_powerport') csv_data = ( "device,name", @@ -796,16 +762,12 @@ class PowerPortTestCase(TestCase): class PowerOutletTestCase(TestCase): + user_permissions = ( + 'dcim.view_poweroutlet', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_poweroutlet', - 'dcim.add_poweroutlet', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -836,6 +798,7 @@ class PowerOutletTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_poweroutlet_import(self): + self.add_permissions('dcim.add_poweroutlet') csv_data = ( "device,name", @@ -851,16 +814,12 @@ class PowerOutletTestCase(TestCase): class InterfaceTestCase(TestCase): + user_permissions = ( + 'dcim.view_interface', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_interface', - 'dcim.add_interface', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -891,6 +850,7 @@ class InterfaceTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_interface_import(self): + self.add_permissions('dcim.add_interface') csv_data = ( "device,name,type", @@ -906,16 +866,12 @@ class InterfaceTestCase(TestCase): class FrontPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_frontport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_frontport', - 'dcim.add_frontport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -958,6 +914,7 @@ class FrontPortTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_frontport_import(self): + self.add_permissions('dcim.add_frontport') csv_data = ( "device,name,type,rear_port,rear_port_position", @@ -973,16 +930,12 @@ class FrontPortTestCase(TestCase): class RearPortTestCase(TestCase): + user_permissions = ( + 'dcim.view_rearport', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_rearport', - 'dcim.add_rearport', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1013,6 +966,7 @@ class RearPortTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_rearport_import(self): + self.add_permissions('dcim.add_rearport') csv_data = ( "device,name,type,positions", @@ -1028,16 +982,12 @@ class RearPortTestCase(TestCase): class DeviceBayTestCase(TestCase): + user_permissions = ( + 'dcim.view_devicebay', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_devicebay', - 'dcim.add_devicebay', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1072,6 +1022,7 @@ class DeviceBayTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_devicebay_import(self): + self.add_permissions('dcim.add_devicebay') csv_data = ( "device,name", @@ -1087,16 +1038,12 @@ class DeviceBayTestCase(TestCase): class InventoryItemTestCase(TestCase): + user_permissions = ( + 'dcim.view_inventoryitem', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_inventoryitem', - 'dcim.add_inventoryitem', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1130,6 +1077,7 @@ class InventoryItemTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_inventoryitem_import(self): + self.add_permissions('dcim.add_inventoryitem') csv_data = ( "device,name", @@ -1145,16 +1093,12 @@ class InventoryItemTestCase(TestCase): class CableTestCase(TestCase): + user_permissions = ( + 'dcim.view_cable', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'dcim.view_cable', - 'dcim.add_cable', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -1219,6 +1163,7 @@ class CableTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_cable_import(self): + self.add_permissions('dcim.add_cable') csv_data = ( "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", @@ -1234,11 +1179,12 @@ class CableTestCase(TestCase): class VirtualChassisTestCase(TestCase): + user_permissions = ( + 'dcim.view_virtualchassis', + ) - def setUp(self): - user = create_test_user(permissions=['dcim.view_virtualchassis']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 6f08f2d47..66742e1a9 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,26 +1,21 @@ from netaddr import IPNetwork import urllib.parse -from django.test import Client, TestCase from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import ServiceProtocolChoices from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import create_test_user +from utilities.testing import TestCase class VRFTestCase(TestCase): + user_permissions = ( + 'ipam.view_vrf', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vrf', - 'ipam.add_vrf', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): VRF.objects.bulk_create([ VRF(name='VRF 1', rd='65000:1'), @@ -45,6 +40,7 @@ class VRFTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_vrf_import(self): + self.add_permissions('ipam.add_vrf') csv_data = ( "name", @@ -60,16 +56,12 @@ class VRFTestCase(TestCase): class RIRTestCase(TestCase): + user_permissions = ( + 'ipam.view_rir', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_rir', - 'ipam.add_rir', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): RIR.objects.bulk_create([ RIR(name='RIR 1', slug='rir-1'), @@ -85,6 +77,7 @@ class RIRTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_rir_import(self): + self.add_permissions('ipam.add_rir') csv_data = ( "name,slug", @@ -100,16 +93,12 @@ class RIRTestCase(TestCase): class AggregateTestCase(TestCase): + user_permissions = ( + 'ipam.view_aggregate', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_aggregate', - 'ipam.add_aggregate', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): rir = RIR(name='RIR 1', slug='rir-1') rir.save() @@ -137,6 +126,7 @@ class AggregateTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_aggregate_import(self): + self.add_permissions('ipam.add_aggregate') csv_data = ( "prefix,rir", @@ -152,16 +142,12 @@ class AggregateTestCase(TestCase): class RoleTestCase(TestCase): + user_permissions = ( + 'ipam.view_role', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_role', - 'ipam.add_role', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Role.objects.bulk_create([ Role(name='Role 1', slug='role-1'), @@ -177,6 +163,7 @@ class RoleTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_role_import(self): + self.add_permissions('ipam.add_role') csv_data = ( "name,slug,weight", @@ -192,16 +179,12 @@ class RoleTestCase(TestCase): class PrefixTestCase(TestCase): + user_permissions = ( + 'ipam.view_prefix', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_prefix', - 'ipam.add_prefix', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -229,6 +212,7 @@ class PrefixTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_prefix_import(self): + self.add_permissions('ipam.add_prefix') csv_data = ( "prefix,status", @@ -244,16 +228,12 @@ class PrefixTestCase(TestCase): class IPAddressTestCase(TestCase): + user_permissions = ( + 'ipam.view_ipaddress', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_ipaddress', - 'ipam.add_ipaddress', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): vrf = VRF(name='VRF 1', rd='65000:1') vrf.save() @@ -281,6 +261,7 @@ class IPAddressTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_ipaddress_import(self): + self.add_permissions('ipam.add_ipaddress') csv_data = ( "address,status", @@ -296,16 +277,12 @@ class IPAddressTestCase(TestCase): class VLANGroupTestCase(TestCase): + user_permissions = ( + 'ipam.view_vlangroup', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vlangroup', - 'ipam.add_vlangroup', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -327,6 +304,7 @@ class VLANGroupTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_vlangroup_import(self): + self.add_permissions('ipam.add_vlangroup') csv_data = ( "name,slug", @@ -342,16 +320,12 @@ class VLANGroupTestCase(TestCase): class VLANTestCase(TestCase): + user_permissions = ( + 'ipam.view_vlan', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'ipam.view_vlan', - 'ipam.add_vlan', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1') vlangroup.save() @@ -379,6 +353,7 @@ class VLANTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_vlan_import(self): + self.add_permissions('ipam.add_vlan') csv_data = ( "vid,name,status", @@ -394,11 +369,12 @@ class VLANTestCase(TestCase): class ServiceTestCase(TestCase): + user_permissions = ( + 'ipam.view_service', + ) - def setUp(self): - user = create_test_user(permissions=['ipam.view_service']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 43ae10dc6..14074630b 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,26 +1,21 @@ import base64 import urllib.parse -from django.test import Client, TestCase from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import create_test_user +from utilities.testing import TestCase from .constants import PRIVATE_KEY, PUBLIC_KEY class SecretRoleTestCase(TestCase): + user_permissions = ( + 'secrets.view_secretrole', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'secrets.view_secretrole', - 'secrets.add_secretrole', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): SecretRole.objects.bulk_create([ SecretRole(name='Secret Role 1', slug='secret-role-1'), @@ -36,6 +31,7 @@ class SecretRoleTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_secretrole_import(self): + self.add_permissions('secrets.add_secretrole') csv_data = ( "name,slug", @@ -51,24 +47,12 @@ class SecretRoleTestCase(TestCase): class SecretTestCase(TestCase): + user_permissions = ( + 'secrets.view_secret', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'secrets.view_secret', - 'secrets.add_secret', - ] - ) - - # Set up a master key - userkey = UserKey(user=user, public_key=PUBLIC_KEY) - userkey.save() - master_key = userkey.get_master_key(PRIVATE_KEY) - self.session_key = SessionKey(userkey=userkey) - self.session_key.save(master_key) - - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -94,6 +78,17 @@ class SecretTestCase(TestCase): Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'), ]) + def setUp(self): + + super().setUp() + + # Set up a master key for the test user + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) + userkey.save() + master_key = userkey.get_master_key(PRIVATE_KEY) + self.session_key = SessionKey(userkey=userkey) + self.session_key.save(master_key) + def test_secret_list(self): url = reverse('secrets:secret_list') @@ -111,6 +106,7 @@ class SecretTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_secret_import(self): + self.add_permissions('secrets.add_secret') csv_data = ( "device,role,name,plaintext", diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 10ee354d4..3cb04d6b2 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,23 +1,18 @@ import urllib.parse -from django.test import Client, TestCase from django.urls import reverse from tenancy.models import Tenant, TenantGroup -from utilities.testing import create_test_user +from utilities.testing import TestCase class TenantGroupTestCase(TestCase): + user_permissions = ( + 'tenancy.view_tenantgroup', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'tenancy.view_tenantgroup', - 'tenancy.add_tenantgroup', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): TenantGroup.objects.bulk_create([ TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), @@ -33,6 +28,7 @@ class TenantGroupTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_tenantgroup_import(self): + self.add_permissions('tenancy.add_tenantgroup') csv_data = ( "name,slug", @@ -48,16 +44,12 @@ class TenantGroupTestCase(TestCase): class TenantTestCase(TestCase): + user_permissions = ( + 'tenancy.view_tenant', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'tenancy.view_tenant', - 'tenancy.add_tenant', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1') tenantgroup.save() @@ -85,6 +77,7 @@ class TenantTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_tenant_import(self): + self.add_permissions('tenancy.add_tenant') csv_data = ( "name,slug", diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 57af2ffc8..67df8fe1e 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,23 +1,18 @@ import urllib.parse -from django.test import Client, TestCase from django.urls import reverse -from utilities.testing import create_test_user +from utilities.testing import TestCase from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine class ClusterGroupTestCase(TestCase): + user_permissions = ( + 'virtualization.view_clustergroup', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'virtualization.view_clustergroup', - 'virtualization.add_clustergroup', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): ClusterGroup.objects.bulk_create([ ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), @@ -33,6 +28,7 @@ class ClusterGroupTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_clustergroup_import(self): + self.add_permissions('virtualization.add_clustergroup') csv_data = ( "name,slug", @@ -48,16 +44,12 @@ class ClusterGroupTestCase(TestCase): class ClusterTypeTestCase(TestCase): + user_permissions = ( + 'virtualization.view_clustertype', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'virtualization.view_clustertype', - 'virtualization.add_clustertype', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): ClusterType.objects.bulk_create([ ClusterType(name='Cluster Type 1', slug='cluster-type-1'), @@ -73,6 +65,7 @@ class ClusterTypeTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_clustertype_import(self): + self.add_permissions('virtualization.add_clustertype') csv_data = ( "name,slug", @@ -88,16 +81,12 @@ class ClusterTypeTestCase(TestCase): class ClusterTestCase(TestCase): + user_permissions = ( + 'virtualization.view_cluster', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'virtualization.view_cluster', - 'virtualization.add_cluster', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1') clustergroup.save() @@ -129,6 +118,7 @@ class ClusterTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_cluster_import(self): + self.add_permissions('virtualization.add_cluster') csv_data = ( "name,type", @@ -144,16 +134,12 @@ class ClusterTestCase(TestCase): class VirtualMachineTestCase(TestCase): + user_permissions = ( + 'virtualization.view_virtualmachine', + ) - def setUp(self): - user = create_test_user( - permissions=[ - 'virtualization.view_virtualmachine', - 'virtualization.add_virtualmachine', - ] - ) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1') clustertype.save() @@ -184,6 +170,7 @@ class VirtualMachineTestCase(TestCase): self.assertEqual(response.status_code, 200) def test_virtualmachine_import(self): + self.add_permissions('virtualization.add_virtualmachine') csv_data = ( "name,cluster", From c8c9f78829c24e798d9e29b7431fea3c38155db6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 16:47:44 -0500 Subject: [PATCH 59/99] Documented the new DEVELOPER configuration parameter --- docs/configuration/optional-settings.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index bc79e90ab..03dcb1264 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -90,6 +90,14 @@ This setting enables debugging. This should be done only during development or t --- +# DEVELOPER + +Default: False + +This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. + +--- + ## EMAIL In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting: From 179abcc79de43893482a7e6adc6e6a8f8f7adc01 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 17:57:34 -0500 Subject: [PATCH 60/99] Refactor APITestCase to subclass TestCase --- netbox/utilities/testing.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index 39b43ab83..f86a32ab0 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from django.contrib.auth.models import Permission, User from django.test import Client, TestCase as _TestCase -from rest_framework.test import APITestCase as _APITestCase +from rest_framework.test import APIClient from users.models import Token @@ -21,6 +21,10 @@ class TestCase(_TestCase): self.client = Client() self.client.force_login(self.user) + # + # Permissions management + # + def add_permissions(self, *names): """ Assign a set of permissions to the test user. Accepts permission names in the form ._. @@ -39,8 +43,22 @@ class TestCase(_TestCase): perm = Permission.objects.get(content_type__app_label=app, codename=codename) self.user.user_permissions.remove(perm) + # + # Convenience methods + # -class APITestCase(_APITestCase): + def assertHttpStatus(self, response, expected_status): + """ + TestCase method. Provide more detail in the event of an unexpected HTTP response. + """ + err_message = "Expected HTTP status {}; received {}: {}" + self.assertEqual(response.status_code, expected_status, err_message.format( + expected_status, response.status_code, getattr(response, 'data', 'No data') + )) + + +class APITestCase(TestCase): + client_class = APIClient def setUp(self): """ @@ -50,15 +68,6 @@ class APITestCase(_APITestCase): self.token = Token.objects.create(user=self.user) self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} - def assertHttpStatus(self, response, expected_status): - """ - Provide more detail in the event of an unexpected HTTP response. - """ - err_message = "Expected HTTP status {}; received {}: {}" - self.assertEqual(response.status_code, expected_status, err_message.format( - expected_status, response.status_code, getattr(response, 'data', 'No data') - )) - def create_test_user(username='testuser', permissions=list()): """ From 67fafb2b9dc700a5d91dd6ea243bd82bba73638d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 18:08:25 -0500 Subject: [PATCH 61/99] Use assertHttpStatus for evaluating HTTP response codes --- netbox/circuits/tests/test_views.py | 20 ++--- netbox/dcim/tests/test_views.py | 96 +++++++++++------------ netbox/ipam/tests/test_views.py | 46 +++++------ netbox/netbox/tests/test_views.py | 6 +- netbox/secrets/tests/test_views.py | 10 +-- netbox/tenancy/tests/test_views.py | 10 +-- netbox/virtualization/tests/test_views.py | 20 ++--- 7 files changed, 102 insertions(+), 106 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index d10d2df85..6ecc88e5c 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -13,7 +13,6 @@ class ProviderTestCase(TestCase): @classmethod def setUpTestData(cls): - Provider.objects.bulk_create([ Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 2', slug='provider-2', asn=65002), @@ -21,24 +20,21 @@ class ProviderTestCase(TestCase): ]) def test_provider_list(self): - url = reverse('circuits:provider_list') params = { "q": "test", } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_provider(self): - provider = Provider.objects.first() response = self.client.get(provider.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_provider_import(self): self.add_permissions('circuits.add_provider') - csv_data = ( "name,slug", "Provider 4,provider-4", @@ -48,7 +44,7 @@ class ProviderTestCase(TestCase): response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Provider.objects.count(), 6) @@ -71,7 +67,7 @@ class CircuitTypeTestCase(TestCase): url = reverse('circuits:circuittype_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_circuittype_import(self): self.add_permissions('circuits.add_circuittype') @@ -85,7 +81,7 @@ class CircuitTypeTestCase(TestCase): response = self.client.post(reverse('circuits:circuittype_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(CircuitType.objects.count(), 6) @@ -118,13 +114,13 @@ class CircuitTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_circuit(self): circuit = Circuit.objects.first() response = self.client.get(circuit.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_circuit_import(self): self.add_permissions('circuits.add_circuit') @@ -138,5 +134,5 @@ class CircuitTestCase(TestCase): response = self.client.post(reverse('circuits:circuit_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Circuit.objects.count(), 6) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 45887f171..6a07e0153 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -27,7 +27,7 @@ class RegionTestCase(TestCase): url = reverse('dcim:region_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_region_import(self): self.add_permissions('dcim.add_region') @@ -41,7 +41,7 @@ class RegionTestCase(TestCase): response = self.client.post(reverse('dcim:region_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Region.objects.count(), 6) @@ -70,13 +70,13 @@ class SiteTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_site(self): site = Site.objects.first() response = self.client.get(site.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_site_import(self): self.add_permissions('dcim.add_site') @@ -90,7 +90,7 @@ class SiteTestCase(TestCase): response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Site.objects.count(), 6) @@ -116,7 +116,7 @@ class RackGroupTestCase(TestCase): url = reverse('dcim:rackgroup_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rackgroup_import(self): self.add_permissions('dcim.add_rackgroup') @@ -130,7 +130,7 @@ class RackGroupTestCase(TestCase): response = self.client.post(reverse('dcim:rackgroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(RackGroup.objects.count(), 6) @@ -153,7 +153,7 @@ class RackRoleTestCase(TestCase): url = reverse('dcim:rackrole_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rackrole_import(self): self.add_permissions('dcim.add_rackrole') @@ -167,7 +167,7 @@ class RackRoleTestCase(TestCase): response = self.client.post(reverse('dcim:rackrole_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(RackRole.objects.count(), 6) @@ -198,7 +198,7 @@ class RackReservationTestCase(TestCase): url = reverse('dcim:rackreservation_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) class RackTestCase(TestCase): @@ -226,13 +226,13 @@ class RackTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rack(self): rack = Rack.objects.first() response = self.client.get(rack.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rack_import(self): self.add_permissions('dcim.add_rack') @@ -246,7 +246,7 @@ class RackTestCase(TestCase): response = self.client.post(reverse('dcim:rack_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Rack.objects.count(), 6) @@ -269,7 +269,7 @@ class ManufacturerTypeTestCase(TestCase): url = reverse('dcim:manufacturer_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_manufacturer_import(self): self.add_permissions('dcim.add_manufacturer') @@ -283,7 +283,7 @@ class ManufacturerTypeTestCase(TestCase): response = self.client.post(reverse('dcim:manufacturer_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Manufacturer.objects.count(), 6) @@ -312,14 +312,14 @@ class DeviceTypeTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_devicetype_export(self): url = reverse('dcim:devicetype_list') response = self.client.get('{}?export'.format(url)) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) self.assertEqual(len(data), 3) self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1') @@ -329,7 +329,7 @@ class DeviceTypeTestCase(TestCase): devicetype = DeviceType.objects.first() response = self.client.get(devicetype.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_devicetype_import(self): @@ -424,7 +424,7 @@ device-bays: 'format': 'yaml' } response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) dt = DeviceType.objects.get(model='TEST-1000') @@ -491,7 +491,7 @@ class DeviceRoleTestCase(TestCase): url = reverse('dcim:devicerole_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_devicerole_import(self): self.add_permissions('dcim.add_devicerole') @@ -505,7 +505,7 @@ class DeviceRoleTestCase(TestCase): response = self.client.post(reverse('dcim:devicerole_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(DeviceRole.objects.count(), 6) @@ -528,7 +528,7 @@ class PlatformTestCase(TestCase): url = reverse('dcim:platform_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_platform_import(self): self.add_permissions('dcim.add_platform') @@ -542,7 +542,7 @@ class PlatformTestCase(TestCase): response = self.client.post(reverse('dcim:platform_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Platform.objects.count(), 6) @@ -581,13 +581,13 @@ class DeviceTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_device(self): device = Device.objects.first() response = self.client.get(device.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_device_import(self): self.add_permissions('dcim.add_device') @@ -601,7 +601,7 @@ class DeviceTestCase(TestCase): response = self.client.post(reverse('dcim:device_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Device.objects.count(), 6) @@ -639,7 +639,7 @@ class ConsolePortTestCase(TestCase): url = reverse('dcim:consoleport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_consoleport_import(self): self.add_permissions('dcim.add_consoleport') @@ -653,7 +653,7 @@ class ConsolePortTestCase(TestCase): response = self.client.post(reverse('dcim:consoleport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(ConsolePort.objects.count(), 6) @@ -691,7 +691,7 @@ class ConsoleServerPortTestCase(TestCase): url = reverse('dcim:consoleserverport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_consoleserverport_import(self): self.add_permissions('dcim.add_consoleserverport') @@ -705,7 +705,7 @@ class ConsoleServerPortTestCase(TestCase): response = self.client.post(reverse('dcim:consoleserverport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(ConsoleServerPort.objects.count(), 6) @@ -743,7 +743,7 @@ class PowerPortTestCase(TestCase): url = reverse('dcim:powerport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_powerport_import(self): self.add_permissions('dcim.add_powerport') @@ -757,7 +757,7 @@ class PowerPortTestCase(TestCase): response = self.client.post(reverse('dcim:powerport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(PowerPort.objects.count(), 6) @@ -795,7 +795,7 @@ class PowerOutletTestCase(TestCase): url = reverse('dcim:poweroutlet_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_poweroutlet_import(self): self.add_permissions('dcim.add_poweroutlet') @@ -809,7 +809,7 @@ class PowerOutletTestCase(TestCase): response = self.client.post(reverse('dcim:poweroutlet_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(PowerOutlet.objects.count(), 6) @@ -847,7 +847,7 @@ class InterfaceTestCase(TestCase): url = reverse('dcim:interface_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_interface_import(self): self.add_permissions('dcim.add_interface') @@ -861,7 +861,7 @@ class InterfaceTestCase(TestCase): response = self.client.post(reverse('dcim:interface_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Interface.objects.count(), 6) @@ -911,7 +911,7 @@ class FrontPortTestCase(TestCase): url = reverse('dcim:frontport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_frontport_import(self): self.add_permissions('dcim.add_frontport') @@ -925,7 +925,7 @@ class FrontPortTestCase(TestCase): response = self.client.post(reverse('dcim:frontport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(FrontPort.objects.count(), 6) @@ -963,7 +963,7 @@ class RearPortTestCase(TestCase): url = reverse('dcim:rearport_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rearport_import(self): self.add_permissions('dcim.add_rearport') @@ -977,7 +977,7 @@ class RearPortTestCase(TestCase): response = self.client.post(reverse('dcim:rearport_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(RearPort.objects.count(), 6) @@ -1019,7 +1019,7 @@ class DeviceBayTestCase(TestCase): url = reverse('dcim:devicebay_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_devicebay_import(self): self.add_permissions('dcim.add_devicebay') @@ -1033,7 +1033,7 @@ class DeviceBayTestCase(TestCase): response = self.client.post(reverse('dcim:devicebay_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(DeviceBay.objects.count(), 6) @@ -1074,7 +1074,7 @@ class InventoryItemTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_inventoryitem_import(self): self.add_permissions('dcim.add_inventoryitem') @@ -1088,7 +1088,7 @@ class InventoryItemTestCase(TestCase): response = self.client.post(reverse('dcim:inventoryitem_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(InventoryItem.objects.count(), 6) @@ -1154,13 +1154,13 @@ class CableTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_cable(self): cable = Cable.objects.first() response = self.client.get(cable.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_cable_import(self): self.add_permissions('dcim.add_cable') @@ -1174,7 +1174,7 @@ class CableTestCase(TestCase): response = self.client.post(reverse('dcim:cable_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Cable.objects.count(), 6) @@ -1228,4 +1228,4 @@ class VirtualChassisTestCase(TestCase): url = reverse('dcim:virtualchassis_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 66742e1a9..1ea5f2e2b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -31,13 +31,13 @@ class VRFTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_vrf(self): vrf = VRF.objects.first() response = self.client.get(vrf.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_vrf_import(self): self.add_permissions('ipam.add_vrf') @@ -51,7 +51,7 @@ class VRFTestCase(TestCase): response = self.client.post(reverse('ipam:vrf_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(VRF.objects.count(), 6) @@ -74,7 +74,7 @@ class RIRTestCase(TestCase): url = reverse('ipam:rir_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_rir_import(self): self.add_permissions('ipam.add_rir') @@ -88,7 +88,7 @@ class RIRTestCase(TestCase): response = self.client.post(reverse('ipam:rir_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(RIR.objects.count(), 6) @@ -117,13 +117,13 @@ class AggregateTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_aggregate(self): aggregate = Aggregate.objects.first() response = self.client.get(aggregate.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_aggregate_import(self): self.add_permissions('ipam.add_aggregate') @@ -137,7 +137,7 @@ class AggregateTestCase(TestCase): response = self.client.post(reverse('ipam:aggregate_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Aggregate.objects.count(), 6) @@ -160,7 +160,7 @@ class RoleTestCase(TestCase): url = reverse('ipam:role_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_role_import(self): self.add_permissions('ipam.add_role') @@ -174,7 +174,7 @@ class RoleTestCase(TestCase): response = self.client.post(reverse('ipam:role_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Role.objects.count(), 6) @@ -203,13 +203,13 @@ class PrefixTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_prefix(self): prefix = Prefix.objects.first() response = self.client.get(prefix.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_prefix_import(self): self.add_permissions('ipam.add_prefix') @@ -223,7 +223,7 @@ class PrefixTestCase(TestCase): response = self.client.post(reverse('ipam:prefix_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Prefix.objects.count(), 6) @@ -252,13 +252,13 @@ class IPAddressTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_ipaddress(self): ipaddress = IPAddress.objects.first() response = self.client.get(ipaddress.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_ipaddress_import(self): self.add_permissions('ipam.add_ipaddress') @@ -272,7 +272,7 @@ class IPAddressTestCase(TestCase): response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(IPAddress.objects.count(), 6) @@ -301,7 +301,7 @@ class VLANGroupTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_vlangroup_import(self): self.add_permissions('ipam.add_vlangroup') @@ -315,7 +315,7 @@ class VLANGroupTestCase(TestCase): response = self.client.post(reverse('ipam:vlangroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(VLANGroup.objects.count(), 6) @@ -344,13 +344,13 @@ class VLANTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_vlan(self): vlan = VLAN.objects.first() response = self.client.get(vlan.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_vlan_import(self): self.add_permissions('ipam.add_vlan') @@ -364,7 +364,7 @@ class VLANTestCase(TestCase): response = self.client.post(reverse('ipam:vlan_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(VLAN.objects.count(), 6) @@ -405,10 +405,10 @@ class ServiceTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_service(self): service = Service.objects.first() response = self.client.get(service.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) diff --git a/netbox/netbox/tests/test_views.py b/netbox/netbox/tests/test_views.py index db84dcd1a..1942471b0 100644 --- a/netbox/netbox/tests/test_views.py +++ b/netbox/netbox/tests/test_views.py @@ -1,6 +1,6 @@ import urllib.parse -from django.test import TestCase +from utilities.testing import TestCase from django.urls import reverse @@ -11,7 +11,7 @@ class HomeViewTestCase(TestCase): url = reverse('home') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_search(self): @@ -21,4 +21,4 @@ class HomeViewTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 14074630b..336a33320 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -28,7 +28,7 @@ class SecretRoleTestCase(TestCase): url = reverse('secrets:secretrole_list') response = self.client.get(url, follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_secretrole_import(self): self.add_permissions('secrets.add_secretrole') @@ -42,7 +42,7 @@ class SecretRoleTestCase(TestCase): response = self.client.post(reverse('secrets:secretrole_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(SecretRole.objects.count(), 6) @@ -97,13 +97,13 @@ class SecretTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_secret(self): secret = Secret.objects.first() response = self.client.get(secret.get_absolute_url(), follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_secret_import(self): self.add_permissions('secrets.add_secret') @@ -121,5 +121,5 @@ class SecretTestCase(TestCase): response = self.client.post(reverse('secrets:secret_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Secret.objects.count(), 6) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 3cb04d6b2..8646abe38 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -25,7 +25,7 @@ class TenantGroupTestCase(TestCase): url = reverse('tenancy:tenantgroup_list') response = self.client.get(url, follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_tenantgroup_import(self): self.add_permissions('tenancy.add_tenantgroup') @@ -39,7 +39,7 @@ class TenantGroupTestCase(TestCase): response = self.client.post(reverse('tenancy:tenantgroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(TenantGroup.objects.count(), 6) @@ -68,13 +68,13 @@ class TenantTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_tenant(self): tenant = Tenant.objects.first() response = self.client.get(tenant.get_absolute_url(), follow=True) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_tenant_import(self): self.add_permissions('tenancy.add_tenant') @@ -88,5 +88,5 @@ class TenantTestCase(TestCase): response = self.client.post(reverse('tenancy:tenant_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Tenant.objects.count(), 6) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 67df8fe1e..df346d11e 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -25,7 +25,7 @@ class ClusterGroupTestCase(TestCase): url = reverse('virtualization:clustergroup_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_clustergroup_import(self): self.add_permissions('virtualization.add_clustergroup') @@ -39,7 +39,7 @@ class ClusterGroupTestCase(TestCase): response = self.client.post(reverse('virtualization:clustergroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(ClusterGroup.objects.count(), 6) @@ -62,7 +62,7 @@ class ClusterTypeTestCase(TestCase): url = reverse('virtualization:clustertype_list') response = self.client.get(url) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_clustertype_import(self): self.add_permissions('virtualization.add_clustertype') @@ -76,7 +76,7 @@ class ClusterTypeTestCase(TestCase): response = self.client.post(reverse('virtualization:clustertype_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(ClusterType.objects.count(), 6) @@ -109,13 +109,13 @@ class ClusterTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_cluster(self): cluster = Cluster.objects.first() response = self.client.get(cluster.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_cluster_import(self): self.add_permissions('virtualization.add_cluster') @@ -129,7 +129,7 @@ class ClusterTestCase(TestCase): response = self.client.post(reverse('virtualization:cluster_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(Cluster.objects.count(), 6) @@ -161,13 +161,13 @@ class VirtualMachineTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_virtualmachine(self): virtualmachine = VirtualMachine.objects.first() response = self.client.get(virtualmachine.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_virtualmachine_import(self): self.add_permissions('virtualization.add_virtualmachine') @@ -181,5 +181,5 @@ class VirtualMachineTestCase(TestCase): response = self.client.post(reverse('virtualization:virtualmachine_import'), {'csv': '\n'.join(csv_data)}) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) self.assertEqual(VirtualMachine.objects.count(), 6) From a44c4d14e4dd4ad3cf71e4d41cf2cd0096418ee6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 18:13:02 -0500 Subject: [PATCH 62/99] Convert view tests under extras to the new TestCase --- netbox/extras/tests/test_views.py | 41 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 792390121..fc77a81f5 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -2,21 +2,21 @@ import urllib.parse import uuid from django.contrib.auth.models import User -from django.test import Client, TestCase from django.urls import reverse from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import ConfigContext, ObjectChange, Tag -from utilities.testing import create_test_user +from utilities.testing import TestCase class TagTestCase(TestCase): + user_permissions = ( + 'extras.view_tag', + ) - def setUp(self): - user = create_test_user(permissions=['extras.view_tag']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): Tag.objects.bulk_create([ Tag(name='Tag 1', slug='tag-1'), @@ -32,15 +32,16 @@ class TagTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) class ConfigContextTestCase(TestCase): + user_permissions = ( + 'extras.view_configcontext', + ) - def setUp(self): - user = create_test_user(permissions=['extras.view_configcontext']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() @@ -62,26 +63,28 @@ class ConfigContextTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_configcontext(self): configcontext = ConfigContext.objects.first() response = self.client.get(configcontext.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) class ObjectChangeTestCase(TestCase): + user_permissions = ( + 'extras.view_objectchange', + ) - def setUp(self): - user = create_test_user(permissions=['extras.view_objectchange']) - self.client = Client() - self.client.force_login(user) + @classmethod + def setUpTestData(cls): site = Site(name='Site 1', slug='site-1') site.save() # Create three ObjectChanges + user = User.objects.create_user(username='testuser2') for i in range(1, 4): oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE) oc.user = user @@ -96,10 +99,10 @@ class ObjectChangeTestCase(TestCase): } response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) def test_objectchange(self): objectchange = ObjectChange.objects.first() response = self.client.get(objectchange.get_absolute_url()) - self.assertEqual(response.status_code, 200) + self.assertHttpStatus(response, 200) From 4522a285e0e1a23ff220b9828fe13710a3c5d3e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 20:05:27 -0500 Subject: [PATCH 63/99] Fix headings --- docs/configuration/optional-settings.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 03dcb1264..8cadddeb5 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -90,7 +90,7 @@ This setting enables debugging. This should be done only during development or t --- -# DEVELOPER +## DEVELOPER Default: False @@ -135,7 +135,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- -# ENFORCE_GLOBAL_UNIQUE +## ENFORCE_GLOBAL_UNIQUE Default: False From e01c984c01fcd45a9976067f5bd043d661038739 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 20:48:26 -0500 Subject: [PATCH 64/99] Introduced a custom model_to_dict() --- netbox/utilities/testing.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index f86a32ab0..629810995 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -2,6 +2,7 @@ import logging from contextlib import contextmanager from django.contrib.auth.models import Permission, User +from django.forms.models import model_to_dict as _model_to_dict from django.test import Client, TestCase as _TestCase from rest_framework.test import APIClient @@ -69,6 +70,24 @@ class APITestCase(TestCase): self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} +def model_to_dict(instance, fields=None, exclude=None): + """ + Customized wrapper for Django's built-in model_to_dict(). Does the following: + - Excludes the instance ID field + - Convert any assigned tags to a comma-separated string + """ + _exclude = ['id'] + if exclude is not None: + _exclude += exclude + + model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude) + + if 'tags' in model_dict: + model_dict['tags'] = ','.join(sorted([tag.name for tag in model_dict['tags']])) + + return model_dict + + def create_test_user(username='testuser', permissions=list()): """ Create a User with the given permissions. From 98cce7eee4aa4fe2642ad45b3eea0e982de050da Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 30 Jan 2020 21:56:29 -0500 Subject: [PATCH 65/99] Added ViewTestCase (WIP) --- netbox/circuits/tests/test_views.py | 153 +++++++++++----------------- netbox/utilities/testing.py | 146 ++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 95 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 6ecc88e5c..bebc3b287 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,58 +1,59 @@ -import urllib.parse - -from django.urls import reverse +import datetime +from circuits.choices import * from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import TestCase +from utilities.testing import ViewTestCase -class ProviderTestCase(TestCase): - user_permissions = ( - 'circuits.view_provider', +class ProviderTestCase(ViewTestCase): + model = Provider + form_data = { + 'name': 'Provider X', + 'slug': 'provider-x', + 'asn': 65123, + 'account': '1234', + 'portal_url': 'http://example.com/portal', + 'noc_contact': 'noc@example.com', + 'admin_contact': 'admin@example.com', + 'comments': 'Another provider', + 'tags': 'Alpha,Bravo,Charlie', + } + csv_data = ( + "name,slug", + "Provider 4,provider-4", + "Provider 5,provider-5", + "Provider 6,provider-6", ) @classmethod def setUpTestData(cls): + Provider.objects.bulk_create([ Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 3', slug='provider-3', asn=65003), ]) - def test_provider_list(self): - url = reverse('circuits:provider_list') - params = { - "q": "test", - } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_provider(self): - provider = Provider.objects.first() - response = self.client.get(provider.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_provider_import(self): - self.add_permissions('circuits.add_provider') - csv_data = ( - "name,slug", - "Provider 4,provider-4", - "Provider 5,provider-5", - "Provider 6,provider-6", - ) - - response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)}) - - self.assertHttpStatus(response, 200) - self.assertEqual(Provider.objects.count(), 6) - - -class CircuitTypeTestCase(TestCase): - user_permissions = ( - 'circuits.view_circuittype', +class CircuitTypeTestCase(ViewTestCase): + model = CircuitType + views = ('list', 'add', 'edit', 'import') + form_data = { + 'name': 'Circuit Type X', + 'slug': 'circuit-type-x', + 'description': 'A new circuit type', + } + csv_data = ( + "name,slug", + "Circuit Type 4,circuit-type-4", + "Circuit Type 5,circuit-type-5", + "Circuit Type 6,circuit-type-6", ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None + @classmethod def setUpTestData(cls): @@ -62,32 +63,26 @@ class CircuitTypeTestCase(TestCase): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ]) - def test_circuittype_list(self): - url = reverse('circuits:circuittype_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_circuittype_import(self): - self.add_permissions('circuits.add_circuittype') - - csv_data = ( - "name,slug", - "Circuit Type 4,circuit-type-4", - "Circuit Type 5,circuit-type-5", - "Circuit Type 6,circuit-type-6", - ) - - response = self.client.post(reverse('circuits:circuittype_import'), {'csv': '\n'.join(csv_data)}) - - self.assertHttpStatus(response, 200) - self.assertEqual(CircuitType.objects.count(), 6) - - -class CircuitTestCase(TestCase): - user_permissions = ( - 'circuits.view_circuit', +class CircuitTestCase(ViewTestCase): + model = Circuit + # TODO: Determine how to lazily resolve related objects + form_data = { + 'cid': 'Circuit X', + 'provider': Provider.objects.first(), + 'type': CircuitType.objects.first(), + 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'tenant': None, + 'install_date': datetime.date(2020, 1, 1), + 'commit_rate': 1000, + 'description': 'A new circuit', + 'comments': 'Some comments', + } + csv_data = ( + "cid,provider,type", + "Circuit 4,Provider 1,Circuit Type 1", + "Circuit 5,Provider 1,Circuit Type 1", + "Circuit 6,Provider 1,Circuit Type 1", ) @classmethod @@ -104,35 +99,3 @@ class CircuitTestCase(TestCase): Circuit(cid='Circuit 2', provider=provider, type=circuittype), Circuit(cid='Circuit 3', provider=provider, type=circuittype), ]) - - def test_circuit_list(self): - - url = reverse('circuits:circuit_list') - params = { - "provider": Provider.objects.first().slug, - "type": CircuitType.objects.first().slug, - } - - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_circuit(self): - - circuit = Circuit.objects.first() - response = self.client.get(circuit.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_circuit_import(self): - self.add_permissions('circuits.add_circuit') - - csv_data = ( - "cid,provider,type", - "Circuit 4,Provider 1,Circuit Type 1", - "Circuit 5,Provider 1,Circuit Type 1", - "Circuit 6,Provider 1,Circuit Type 1", - ) - - response = self.client.post(reverse('circuits:circuit_import'), {'csv': '\n'.join(csv_data)}) - - self.assertHttpStatus(response, 200) - self.assertEqual(Circuit.objects.count(), 6) diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index 629810995..d0004c8dd 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -2,8 +2,10 @@ import logging from contextlib import contextmanager from django.contrib.auth.models import Permission, User +from django.core.exceptions import ObjectDoesNotExist from django.forms.models import model_to_dict as _model_to_dict from django.test import Client, TestCase as _TestCase +from django.urls import reverse from rest_framework.test import APIClient from users.models import Token @@ -70,6 +72,133 @@ class APITestCase(TestCase): self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} +# TODO: Omit this from tests +class ViewTestCase(TestCase): + """ + Stock TestCase suitable for testing all standard View functions: + - List objects + - View single object + - Create new object + - Modify existing object + - Delete existing object + - Import multiple new objects + """ + model = None + form_data = {} + csv_data = {} + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.base_url_name = '{}:{}_{{}}'.format(self.model._meta.app_label, self.model._meta.model_name) + + def test_list_objects(self): + response = self.client.get(reverse(self.base_url_name.format('list'))) + self.assertHttpStatus(response, 200) + + def test_get_object(self): + instance = self.model.objects.first() + response = self.client.get(instance.get_absolute_url()) + self.assertHttpStatus(response, 200) + + def test_create_object(self): + initial_count = self.model.objects.count() + request = { + 'path': reverse(self.base_url_name.format('add')), + 'data': post_data(self.form_data), + 'follow': True, + } + print(request['data']) + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + self.assertEqual(initial_count, self.model.objects.count() + 1) + instance = self.model.objects.order_by('-pk').first() + self.assertDictEqual(model_to_dict(instance), self.form_data) + + def test_edit_object(self): + instance = self.model.objects.first() + + # Determine the proper kwargs to pass to the edit URL + if hasattr(instance, 'slug'): + kwargs = {'slug': instance.slug} + else: + kwargs = {'pk': instance.pk} + + request = { + 'path': reverse(self.base_url_name.format('edit'), kwargs=kwargs), + 'data': post_data(self.form_data), + 'follow': True, + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + instance = self.model.objects.get(pk=instance.pk) + self.assertDictEqual(model_to_dict(instance), self.form_data) + + def test_delete_object(self): + instance = self.model.objects.first() + + # Determine the proper kwargs to pass to the deletion URL + if hasattr(instance, 'slug'): + kwargs = {'slug': instance.slug} + else: + kwargs = {'pk': instance.pk} + + request = { + 'path': reverse(self.base_url_name.format('delete'), kwargs=kwargs), + 'data': {'confirm': True}, + 'follow': True, + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + with self.assertRaises(ObjectDoesNotExist): + self.model.objects.get(pk=instance.pk) + + def test_import_objects(self): + request = { + 'path': reverse(self.base_url_name.format('import')), + 'data': { + 'csv': '\n'.join(self.csv_data) + } + } + initial_count = self.model.objects.count() + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + + def model_to_dict(instance, fields=None, exclude=None): """ Customized wrapper for Django's built-in model_to_dict(). Does the following: @@ -88,6 +217,23 @@ def model_to_dict(instance, fields=None, exclude=None): return model_dict +def post_data(data): + """ + Take a dictionary of test data (suitable for comparison to an instance) and return a dict suitable for POSTing. + """ + ret = {} + + for key, value in data.items(): + if value is None: + ret[key] = '' + elif hasattr(value, 'pk'): + ret[key] = getattr(value, 'pk') + else: + ret[key] = str(value) + + return ret + + def create_test_user(username='testuser', permissions=list()): """ Create a User with the given permissions. From 939b5f2e292f6f885c8f1d49068ffe36a8caec8f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 09:00:01 -0500 Subject: [PATCH 66/99] Reorganize test classes to prevent unittest from running the base TestCases --- netbox/circuits/tests/test_views.py | 8 +- netbox/utilities/testing.py | 286 -------------------------- netbox/utilities/testing/__init__.py | 2 + netbox/utilities/testing/testcases.py | 200 ++++++++++++++++++ netbox/utilities/testing/utils.py | 92 +++++++++ 5 files changed, 298 insertions(+), 290 deletions(-) delete mode 100644 netbox/utilities/testing.py create mode 100644 netbox/utilities/testing/__init__.py create mode 100644 netbox/utilities/testing/testcases.py create mode 100644 netbox/utilities/testing/utils.py diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index bebc3b287..18f40dfbd 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -2,10 +2,10 @@ import datetime from circuits.choices import * from circuits.models import Circuit, CircuitType, Provider -from utilities.testing import ViewTestCase +from utilities.testing import StandardTestCases -class ProviderTestCase(ViewTestCase): +class ProviderTestCase(StandardTestCases.Views): model = Provider form_data = { 'name': 'Provider X', @@ -35,7 +35,7 @@ class ProviderTestCase(ViewTestCase): ]) -class CircuitTypeTestCase(ViewTestCase): +class CircuitTypeTestCase(StandardTestCases.Views): model = CircuitType views = ('list', 'add', 'edit', 'import') form_data = { @@ -64,7 +64,7 @@ class CircuitTypeTestCase(ViewTestCase): ]) -class CircuitTestCase(ViewTestCase): +class CircuitTestCase(StandardTestCases.Views): model = Circuit # TODO: Determine how to lazily resolve related objects form_data = { diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py deleted file mode 100644 index d0004c8dd..000000000 --- a/netbox/utilities/testing.py +++ /dev/null @@ -1,286 +0,0 @@ -import logging -from contextlib import contextmanager - -from django.contrib.auth.models import Permission, User -from django.core.exceptions import ObjectDoesNotExist -from django.forms.models import model_to_dict as _model_to_dict -from django.test import Client, TestCase as _TestCase -from django.urls import reverse -from rest_framework.test import APIClient - -from users.models import Token - - -class TestCase(_TestCase): - user_permissions = () - - def setUp(self): - - # Create the test user and assign permissions - self.user = User.objects.create_user(username='testuser') - self.add_permissions(*self.user_permissions) - - # Initialize the test client - self.client = Client() - self.client.force_login(self.user) - - # - # Permissions management - # - - def add_permissions(self, *names): - """ - Assign a set of permissions to the test user. Accepts permission names in the form ._. - """ - for name in names: - app, codename = name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - self.user.user_permissions.add(perm) - - def remove_permissions(self, *names): - """ - Remove a set of permissions from the test user, if assigned. - """ - for name in names: - app, codename = name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - self.user.user_permissions.remove(perm) - - # - # Convenience methods - # - - def assertHttpStatus(self, response, expected_status): - """ - TestCase method. Provide more detail in the event of an unexpected HTTP response. - """ - err_message = "Expected HTTP status {}; received {}: {}" - self.assertEqual(response.status_code, expected_status, err_message.format( - expected_status, response.status_code, getattr(response, 'data', 'No data') - )) - - -class APITestCase(TestCase): - client_class = APIClient - - def setUp(self): - """ - Create a superuser and token for API calls. - """ - self.user = User.objects.create(username='testuser', is_superuser=True) - self.token = Token.objects.create(user=self.user) - self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} - - -# TODO: Omit this from tests -class ViewTestCase(TestCase): - """ - Stock TestCase suitable for testing all standard View functions: - - List objects - - View single object - - Create new object - - Modify existing object - - Delete existing object - - Import multiple new objects - """ - model = None - form_data = {} - csv_data = {} - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - self.base_url_name = '{}:{}_{{}}'.format(self.model._meta.app_label, self.model._meta.model_name) - - def test_list_objects(self): - response = self.client.get(reverse(self.base_url_name.format('list'))) - self.assertHttpStatus(response, 200) - - def test_get_object(self): - instance = self.model.objects.first() - response = self.client.get(instance.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_create_object(self): - initial_count = self.model.objects.count() - request = { - 'path': reverse(self.base_url_name.format('add')), - 'data': post_data(self.form_data), - 'follow': True, - } - print(request['data']) - - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) - - # Assign the required permission and submit again - self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - - self.assertEqual(initial_count, self.model.objects.count() + 1) - instance = self.model.objects.order_by('-pk').first() - self.assertDictEqual(model_to_dict(instance), self.form_data) - - def test_edit_object(self): - instance = self.model.objects.first() - - # Determine the proper kwargs to pass to the edit URL - if hasattr(instance, 'slug'): - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - - request = { - 'path': reverse(self.base_url_name.format('edit'), kwargs=kwargs), - 'data': post_data(self.form_data), - 'follow': True, - } - - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) - - # Assign the required permission and submit again - self.add_permissions('{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - - instance = self.model.objects.get(pk=instance.pk) - self.assertDictEqual(model_to_dict(instance), self.form_data) - - def test_delete_object(self): - instance = self.model.objects.first() - - # Determine the proper kwargs to pass to the deletion URL - if hasattr(instance, 'slug'): - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - - request = { - 'path': reverse(self.base_url_name.format('delete'), kwargs=kwargs), - 'data': {'confirm': True}, - 'follow': True, - } - - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) - - # Assign the required permission and submit again - self.add_permissions('{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - - with self.assertRaises(ObjectDoesNotExist): - self.model.objects.get(pk=instance.pk) - - def test_import_objects(self): - request = { - 'path': reverse(self.base_url_name.format('import')), - 'data': { - 'csv': '\n'.join(self.csv_data) - } - } - initial_count = self.model.objects.count() - - # Attempt to make the request without required permissions - with disable_warnings('django.request'): - self.assertHttpStatus(self.client.post(**request), 403) - - # Assign the required permission and submit again - self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) - response = self.client.post(**request) - self.assertHttpStatus(response, 200) - - self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) - - -def model_to_dict(instance, fields=None, exclude=None): - """ - Customized wrapper for Django's built-in model_to_dict(). Does the following: - - Excludes the instance ID field - - Convert any assigned tags to a comma-separated string - """ - _exclude = ['id'] - if exclude is not None: - _exclude += exclude - - model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude) - - if 'tags' in model_dict: - model_dict['tags'] = ','.join(sorted([tag.name for tag in model_dict['tags']])) - - return model_dict - - -def post_data(data): - """ - Take a dictionary of test data (suitable for comparison to an instance) and return a dict suitable for POSTing. - """ - ret = {} - - for key, value in data.items(): - if value is None: - ret[key] = '' - elif hasattr(value, 'pk'): - ret[key] = getattr(value, 'pk') - else: - ret[key] = str(value) - - return ret - - -def create_test_user(username='testuser', permissions=list()): - """ - Create a User with the given permissions. - """ - user = User.objects.create_user(username=username) - for perm_name in permissions: - app, codename = perm_name.split('.') - perm = Permission.objects.get(content_type__app_label=app, codename=codename) - user.user_permissions.add(perm) - - return user - - -def choices_to_dict(choices_list): - """ - Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example: - - [ - { - "value": "choice-1", - "label": "First Choice" - }, - { - "value": "choice-2", - "label": "Second Choice" - } - ] - - Becomes: - - { - "choice-1": "First Choice", - "choice-2": "Second Choice - } - """ - return { - choice['value']: choice['label'] for choice in choices_list - } - - -@contextmanager -def disable_warnings(logger_name): - """ - Temporarily suppress expected warning messages to keep the test output clean. - """ - logger = logging.getLogger(logger_name) - current_level = logger.level - logger.setLevel(logging.ERROR) - yield - logger.setLevel(current_level) diff --git a/netbox/utilities/testing/__init__.py b/netbox/utilities/testing/__init__.py new file mode 100644 index 000000000..30e452215 --- /dev/null +++ b/netbox/utilities/testing/__init__.py @@ -0,0 +1,2 @@ +from .testcases import * +from .utils import * diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py new file mode 100644 index 000000000..ef9660fa7 --- /dev/null +++ b/netbox/utilities/testing/testcases.py @@ -0,0 +1,200 @@ +from django.contrib.auth.models import Permission, User +from django.core.exceptions import ObjectDoesNotExist +from django.test import Client, TestCase as _TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from users.models import Token +from .utils import disable_warnings, model_to_dict, post_data + + +class TestCase(_TestCase): + user_permissions = () + + def setUp(self): + + # Create the test user and assign permissions + self.user = User.objects.create_user(username='testuser') + self.add_permissions(*self.user_permissions) + + # Initialize the test client + self.client = Client() + self.client.force_login(self.user) + + # + # Permissions management + # + + def add_permissions(self, *names): + """ + Assign a set of permissions to the test user. Accepts permission names in the form ._. + """ + for name in names: + app, codename = name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + self.user.user_permissions.add(perm) + + def remove_permissions(self, *names): + """ + Remove a set of permissions from the test user, if assigned. + """ + for name in names: + app, codename = name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + self.user.user_permissions.remove(perm) + + # + # Convenience methods + # + + def assertHttpStatus(self, response, expected_status): + """ + TestCase method. Provide more detail in the event of an unexpected HTTP response. + """ + err_message = "Expected HTTP status {}; received {}: {}" + self.assertEqual(response.status_code, expected_status, err_message.format( + expected_status, response.status_code, getattr(response, 'data', 'No data') + )) + + +class APITestCase(TestCase): + client_class = APIClient + + def setUp(self): + """ + Create a superuser and token for API calls. + """ + self.user = User.objects.create(username='testuser', is_superuser=True) + self.token = Token.objects.create(user=self.user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)} + + +class StandardTestCases: + """ + We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them. + """ + + class Views(TestCase): + """ + Stock TestCase suitable for testing all standard View functions: + - List objects + - View single object + - Create new object + - Modify existing object + - Delete existing object + - Import multiple new objects + """ + model = None + form_data = {} + csv_data = {} + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + if self.model is not None: + self.base_url_name = '{}:{}_{{}}'.format(self.model._meta.app_label, self.model._meta.model_name) + + def test_list_objects(self): + response = self.client.get(reverse(self.base_url_name.format('list'))) + self.assertHttpStatus(response, 200) + + def test_get_object(self): + instance = self.model.objects.first() + response = self.client.get(instance.get_absolute_url()) + self.assertHttpStatus(response, 200) + + def test_create_object(self): + initial_count = self.model.objects.count() + request = { + 'path': reverse(self.base_url_name.format('add')), + 'data': post_data(self.form_data), + 'follow': True, + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + self.assertEqual(initial_count + 1, self.model.objects.count()) + instance = self.model.objects.order_by('-pk').first() + self.assertDictEqual(model_to_dict(instance), self.form_data) + + def test_edit_object(self): + instance = self.model.objects.first() + + # Determine the proper kwargs to pass to the edit URL + if hasattr(instance, 'slug'): + kwargs = {'slug': instance.slug} + else: + kwargs = {'pk': instance.pk} + + request = { + 'path': reverse(self.base_url_name.format('edit'), kwargs=kwargs), + 'data': post_data(self.form_data), + 'follow': True, + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + instance = self.model.objects.get(pk=instance.pk) + self.assertDictEqual(model_to_dict(instance), self.form_data) + + def test_delete_object(self): + instance = self.model.objects.first() + + # Determine the proper kwargs to pass to the deletion URL + if hasattr(instance, 'slug'): + kwargs = {'slug': instance.slug} + else: + kwargs = {'pk': instance.pk} + + request = { + 'path': reverse(self.base_url_name.format('delete'), kwargs=kwargs), + 'data': {'confirm': True}, + 'follow': True, + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + with self.assertRaises(ObjectDoesNotExist): + self.model.objects.get(pk=instance.pk) + + def test_import_objects(self): + initial_count = self.model.objects.count() + request = { + 'path': reverse(self.base_url_name.format('import')), + 'data': { + 'csv': '\n'.join(self.csv_data) + } + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + response = self.client.post(**request) + self.assertHttpStatus(response, 200) + + self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py new file mode 100644 index 000000000..8066cc710 --- /dev/null +++ b/netbox/utilities/testing/utils.py @@ -0,0 +1,92 @@ +import logging +from contextlib import contextmanager + +from django.contrib.auth.models import Permission, User +from django.forms.models import model_to_dict as _model_to_dict + + +def model_to_dict(instance, fields=None, exclude=None): + """ + Customized wrapper for Django's built-in model_to_dict(). Does the following: + - Excludes the instance ID field + - Convert any assigned tags to a comma-separated string + """ + _exclude = ['id'] + if exclude is not None: + _exclude += exclude + + model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude) + + if 'tags' in model_dict: + model_dict['tags'] = ','.join(sorted([tag.name for tag in model_dict['tags']])) + + return model_dict + + +def post_data(data): + """ + Take a dictionary of test data (suitable for comparison to an instance) and return a dict suitable for POSTing. + """ + ret = {} + + for key, value in data.items(): + if value is None: + ret[key] = '' + elif hasattr(value, 'pk'): + ret[key] = getattr(value, 'pk') + else: + ret[key] = str(value) + + return ret + + +def create_test_user(username='testuser', permissions=list()): + """ + Create a User with the given permissions. + """ + user = User.objects.create_user(username=username) + for perm_name in permissions: + app, codename = perm_name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + user.user_permissions.add(perm) + + return user + + +def choices_to_dict(choices_list): + """ + Convert a list of field choices to a dictionary suitable for direct comparison with a ChoiceSet. For example: + + [ + { + "value": "choice-1", + "label": "First Choice" + }, + { + "value": "choice-2", + "label": "Second Choice" + } + ] + + Becomes: + + { + "choice-1": "First Choice", + "choice-2": "Second Choice + } + """ + return { + choice['value']: choice['label'] for choice in choices_list + } + + +@contextmanager +def disable_warnings(logger_name): + """ + Temporarily suppress expected warning messages to keep the test output clean. + """ + logger = logging.getLogger(logger_name) + current_level = logger.level + logger.setLevel(logging.ERROR) + yield + logger.setLevel(current_level) From 78d43a5d66bedbb5e41443082949106d528e586a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 09:27:41 -0500 Subject: [PATCH 67/99] Move form/CSV data declaration under setUpTestData --- netbox/circuits/tests/test_views.py | 99 +++++++++++++++-------------- netbox/utilities/testing/utils.py | 2 - 2 files changed, 52 insertions(+), 49 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 18f40dfbd..1e065f458 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -7,23 +7,6 @@ from utilities.testing import StandardTestCases class ProviderTestCase(StandardTestCases.Views): model = Provider - form_data = { - 'name': 'Provider X', - 'slug': 'provider-x', - 'asn': 65123, - 'account': '1234', - 'portal_url': 'http://example.com/portal', - 'noc_contact': 'noc@example.com', - 'admin_contact': 'admin@example.com', - 'comments': 'Another provider', - 'tags': 'Alpha,Bravo,Charlie', - } - csv_data = ( - "name,slug", - "Provider 4,provider-4", - "Provider 5,provider-5", - "Provider 6,provider-6", - ) @classmethod def setUpTestData(cls): @@ -34,21 +17,28 @@ class ProviderTestCase(StandardTestCases.Views): Provider(name='Provider 3', slug='provider-3', asn=65003), ]) + cls.form_data = { + 'name': 'Provider X', + 'slug': 'provider-x', + 'asn': 65123, + 'account': '1234', + 'portal_url': 'http://example.com/portal', + 'noc_contact': 'noc@example.com', + 'admin_contact': 'admin@example.com', + 'comments': 'Another provider', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.csv_data = ( + "name,slug", + "Provider 4,provider-4", + "Provider 5,provider-5", + "Provider 6,provider-6", + ) + class CircuitTypeTestCase(StandardTestCases.Views): model = CircuitType - views = ('list', 'add', 'edit', 'import') - form_data = { - 'name': 'Circuit Type X', - 'slug': 'circuit-type-x', - 'description': 'A new circuit type', - } - csv_data = ( - "name,slug", - "Circuit Type 4,circuit-type-4", - "Circuit Type 5,circuit-type-5", - "Circuit Type 6,circuit-type-6", - ) # Disable inapplicable tests test_get_object = None @@ -63,27 +53,22 @@ class CircuitTypeTestCase(StandardTestCases.Views): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ]) + cls.form_data = { + 'name': 'Circuit Type X', + 'slug': 'circuit-type-x', + 'description': 'A new circuit type', + } + + cls.csv_data = ( + "name,slug", + "Circuit Type 4,circuit-type-4", + "Circuit Type 5,circuit-type-5", + "Circuit Type 6,circuit-type-6", + ) + class CircuitTestCase(StandardTestCases.Views): model = Circuit - # TODO: Determine how to lazily resolve related objects - form_data = { - 'cid': 'Circuit X', - 'provider': Provider.objects.first(), - 'type': CircuitType.objects.first(), - 'status': CircuitStatusChoices.STATUS_ACTIVE, - 'tenant': None, - 'install_date': datetime.date(2020, 1, 1), - 'commit_rate': 1000, - 'description': 'A new circuit', - 'comments': 'Some comments', - } - csv_data = ( - "cid,provider,type", - "Circuit 4,Provider 1,Circuit Type 1", - "Circuit 5,Provider 1,Circuit Type 1", - "Circuit 6,Provider 1,Circuit Type 1", - ) @classmethod def setUpTestData(cls): @@ -99,3 +84,23 @@ class CircuitTestCase(StandardTestCases.Views): Circuit(cid='Circuit 2', provider=provider, type=circuittype), Circuit(cid='Circuit 3', provider=provider, type=circuittype), ]) + + cls.form_data = { + 'cid': 'Circuit X', + 'provider': provider.pk, + 'type': circuittype.pk, + 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'tenant': None, + 'install_date': datetime.date(2020, 1, 1), + 'commit_rate': 1000, + 'description': 'A new circuit', + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', + } + + cls.csv_data = ( + "cid,provider,type", + "Circuit 4,Provider 1,Circuit Type 1", + "Circuit 5,Provider 1,Circuit Type 1", + "Circuit 6,Provider 1,Circuit Type 1", + ) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 8066cc710..61b3ed64d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -32,8 +32,6 @@ def post_data(data): for key, value in data.items(): if value is None: ret[key] = '' - elif hasattr(value, 'pk'): - ret[key] = getattr(value, 'pk') else: ret[key] = str(value) From 7daf1df22da42efe3312a4f7a40ddd442dc68150 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 10:30:13 -0500 Subject: [PATCH 68/99] Add _get_url() for View test case --- netbox/utilities/testing/testcases.py | 57 +++++++++++++++++---------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index ef9660fa7..90138c3d9 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -1,7 +1,7 @@ from django.contrib.auth.models import Permission, User from django.core.exceptions import ObjectDoesNotExist from django.test import Client, TestCase as _TestCase -from django.urls import reverse +from django.urls import reverse, NoReverseMatch from rest_framework.test import APIClient from users.models import Token @@ -88,15 +88,44 @@ class StandardTestCases: form_data = {} csv_data = {} + maxDiff = None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.model is not None: - self.base_url_name = '{}:{}_{{}}'.format(self.model._meta.app_label, self.model._meta.model_name) + if self.model is None: + raise Exception("Test case requires model to be defined") + + def _get_url(self, action, instance=None): + """ + Return the URL name for a specific action. An instance must be specified for + get/edit/delete views. + """ + url_format = '{}:{}_{{}}'.format( + self.model._meta.app_label, + self.model._meta.model_name + ) + + if action in ('list', 'add', 'import'): + return reverse(url_format.format(action)) + + elif action in ('get', 'edit', 'delete'): + if instance is None: + raise Exception("Resolving {} URL requires specifying an instance".format(action)) + # Attempt to resolve using slug first + if hasattr(self.model, 'slug'): + try: + return reverse(url_format.format(action), kwargs={'slug': instance.slug}) + except NoReverseMatch: + pass + return reverse(url_format.format(action), kwargs={'pk': instance.pk}) + + else: + raise Exception("Invalid action for URL resolution: {}".format(action)) def test_list_objects(self): - response = self.client.get(reverse(self.base_url_name.format('list'))) + response = self.client.get(self._get_url('list')) self.assertHttpStatus(response, 200) def test_get_object(self): @@ -107,7 +136,7 @@ class StandardTestCases: def test_create_object(self): initial_count = self.model.objects.count() request = { - 'path': reverse(self.base_url_name.format('add')), + 'path': self._get_url('add'), 'data': post_data(self.form_data), 'follow': True, } @@ -128,14 +157,8 @@ class StandardTestCases: def test_edit_object(self): instance = self.model.objects.first() - # Determine the proper kwargs to pass to the edit URL - if hasattr(instance, 'slug'): - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - request = { - 'path': reverse(self.base_url_name.format('edit'), kwargs=kwargs), + 'path': self._get_url('edit', instance), 'data': post_data(self.form_data), 'follow': True, } @@ -155,14 +178,8 @@ class StandardTestCases: def test_delete_object(self): instance = self.model.objects.first() - # Determine the proper kwargs to pass to the deletion URL - if hasattr(instance, 'slug'): - kwargs = {'slug': instance.slug} - else: - kwargs = {'pk': instance.pk} - request = { - 'path': reverse(self.base_url_name.format('delete'), kwargs=kwargs), + 'path': self._get_url('delete', instance), 'data': {'confirm': True}, 'follow': True, } @@ -182,7 +199,7 @@ class StandardTestCases: def test_import_objects(self): initial_count = self.model.objects.count() request = { - 'path': reverse(self.base_url_name.format('import')), + 'path': self._get_url('import'), 'data': { 'csv': '\n'.join(self.csv_data) } From d746448d7dd42b27292c3639bc41f15d41d5c2e9 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 31 Jan 2020 09:39:27 -0600 Subject: [PATCH 69/99] Fixes: #3961 - Edit migrate-to-systemd.md to closely match installation instructions under 3-http-daemon.md --- docs/installation/migrating-to-systemd.md | 101 +++++++--------------- 1 file changed, 29 insertions(+), 72 deletions(-) diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index 6199b5511..f5fcb7598 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -12,84 +12,19 @@ Migration is not required, as supervisord will still continue to function. ### systemd configuration: -Copy or link contrib/netbox.service and contrib/netbox-rq.service to /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service +We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: ```no-highlight -# cp contrib/netbox.service /etc/systemd/system/netbox.service -# cp contrib/netbox-rq.service /etc/systemd/system/netbox-rq.service +# cp contrib/*.service /etc/systemd/system/ ``` -Edit /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service. Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`). If using CentOS/RHEL. Change the username from `www-data` to `nginx` or `apache`: +!!! note + These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files. -```no-highlight -/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi -``` +!!! note + You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data" or any number of other usernames. -```no-highlight -User=www-data -Group=www-data -``` - -Copy contrib/netbox.env to /etc/sysconfig/netbox.env - -```no-highlight -# cp contrib/netbox.env /etc/sysconfig/netbox.env -``` - -Edit /etc/sysconfig/netbox.env and change the settings as required. Update the `WorkingDirectory` variable if needed. - -```no-highlight -# Name is the Process Name -# -Name = 'Netbox' - -# ConfigPath is the path to the gunicorn config file. -# -ConfigPath=/opt/netbox/gunicorn.conf - -# WorkingDirectory is the Working Directory for Netbox. -# -WorkingDirectory=/opt/netbox/ - -# PidPath is the path to the pid for the netbox WSGI -# -PidPath=/var/run/netbox.pid -``` - -Copy contrib/gunicorn.conf to gunicorn.conf - -```no-highlight -# cp contrib/gunicorn.conf to gunicorn.conf -``` - -Edit gunicorn.conf and change the settings as required. - -``` -# Bind is the ip and port that the Netbox WSGI should bind to -# -bind='127.0.0.1:8001' - -# Workers is the number of workers that GUnicorn should spawn. -# Workers should be: cores * 2 + 1. So if you have 8 cores, it would be 17. -# -workers=3 - -# Threads -# The number of threads for handling requests -# -threads=3 - -# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error) -# -timeout=120 - -# ErrorLog -# ErrorLog is the logfile for the ErrorLog -# -errorlog='/opt/netbox/netbox.log' -``` - -Finally, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: +Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: ```no-highlight # systemctl daemon-reload @@ -98,3 +33,25 @@ Finally, start the `netbox` and `netbox-rq` services and enable them to initiate # systemctl enable netbox.service # systemctl enable netbox-rq.service ``` + +You can use the command `systemctl status netbox` to verify that the WSGI service is running: + +``` +# systemctl status netbox.service +● netbox.service - NetBox WSGI Service + Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) + Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago + Docs: https://netbox.readthedocs.io/en/stable/ + Main PID: 11993 (gunicorn) + Tasks: 6 (limit: 2362) + CGroup: /system.slice/netbox.service + ├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... + ├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... + ├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... +... +``` + +At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. + +!!! info + Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment. From 6a17be740b18d1797f5e18b075adb0a286c3aa2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 11:50:12 -0500 Subject: [PATCH 70/99] post_data(): Ignore iterables --- netbox/utilities/testing/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 61b3ed64d..cdd08d5ac 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -32,6 +32,8 @@ def post_data(data): for key, value in data.items(): if value is None: ret[key] = '' + elif type(value) in (list, tuple): + ret[key] = value else: ret[key] = str(value) From a208cbdf0b6439fd9fc59050ea9c9afb304bc75d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 12:14:51 -0500 Subject: [PATCH 71/99] model_to_dict(): Remove fields that start with an underscore --- netbox/utilities/testing/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index cdd08d5ac..0cc2e6ce0 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -9,6 +9,7 @@ def model_to_dict(instance, fields=None, exclude=None): """ Customized wrapper for Django's built-in model_to_dict(). Does the following: - Excludes the instance ID field + - Exclude any fields prepended with an underscore - Convert any assigned tags to a comma-separated string """ _exclude = ['id'] @@ -17,6 +18,10 @@ def model_to_dict(instance, fields=None, exclude=None): model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude) + for key in list(model_dict.keys()): + if key.startswith('_'): + del model_dict[key] + if 'tags' in model_dict: model_dict['tags'] = ','.join(sorted([tag.name for tag in model_dict['tags']])) From c14496d0c42919273a72adf32fa592dfb09bb660 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 12:28:50 -0500 Subject: [PATCH 72/99] DeviceForm.manufacturer should not be a required field --- netbox/dcim/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7efd400fb..dbb3a3bb4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1549,6 +1549,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", filter_for={ From 86ef739c1201c0196179aa4a718df675b3b079f3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 12:32:33 -0500 Subject: [PATCH 73/99] Migrate (most) DCIM view tests to use StandardTestCases --- netbox/dcim/tests/test_views.py | 589 +++++++++++++++----------------- 1 file changed, 267 insertions(+), 322 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6a07e0153..35fdf3aa4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,54 +1,53 @@ import urllib.parse +from decimal import Decimal +import pytz import yaml from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.models import * -from utilities.testing import TestCase +from utilities.testing import StandardTestCases, TestCase -class RegionTestCase(TestCase): - user_permissions = ( - 'dcim.view_region', - ) +class RegionTestCase(StandardTestCases.Views): + model = Region + + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): # Create three Regions - for i in range(1, 4): - Region(name='Region {}'.format(i), slug='region-{}'.format(i)).save() + 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() - def test_region_list(self): + cls.form_data = { + 'name': 'Region X', + 'slug': 'region-x', + 'parent': regions[2].pk, + } - url = reverse('dcim:region_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_region_import(self): - self.add_permissions('dcim.add_region') - - csv_data = ( + cls.csv_data = ( "name,slug", "Region 4,region-4", "Region 5,region-5", "Region 6,region-6", ) - response = self.client.post(reverse('dcim:region_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Region.objects.count(), 6) - - -class SiteTestCase(TestCase): - user_permissions = ( - 'dcim.view_site', - ) +class SiteTestCase(StandardTestCases.Views): + model = Site @classmethod def setUpTestData(cls): @@ -62,42 +61,41 @@ class SiteTestCase(TestCase): Site(name='Site 3', slug='site-3', region=region), ]) - def test_site_list(self): - - url = reverse('dcim:site_list') - params = { - "region": Region.objects.first().slug, + cls.form_data = { + 'name': 'Site X', + 'slug': 'site-x', + 'status': SiteStatusChoices.STATUS_PLANNED, + 'region': region.pk, + 'tenant': None, + 'facility': 'Facility X', + 'asn': 65001, + 'time_zone': pytz.UTC, + 'description': 'Site description', + 'physical_address': '742 Evergreen Terrace, Springfield, USA', + 'shipping_address': '742 Evergreen Terrace, Springfield, USA', + 'latitude': Decimal('35.780000'), + 'longitude': Decimal('-78.642000'), + 'contact_name': 'Hank Hill', + 'contact_phone': '123-555-9999', + 'contact_email': 'hank@stricklandpropane.com', + 'comments': 'Test site', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_site(self): - - site = Site.objects.first() - response = self.client.get(site.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_site_import(self): - self.add_permissions('dcim.add_site') - - csv_data = ( + cls.csv_data = ( "name,slug", "Site 4,site-4", "Site 5,site-5", "Site 6,site-6", ) - response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Site.objects.count(), 6) +class RackGroupTestCase(StandardTestCases.Views): + model = RackGroup - -class RackGroupTestCase(TestCase): - user_permissions = ( - 'dcim.view_rackgroup', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -111,33 +109,26 @@ class RackGroupTestCase(TestCase): RackGroup(name='Rack Group 3', slug='rack-group-3', site=site), ]) - def test_rackgroup_list(self): + cls.form_data = { + 'name': 'Rack Group X', + 'slug': 'rack-group-x', + 'site': site.pk, + } - url = reverse('dcim:rackgroup_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_rackgroup_import(self): - self.add_permissions('dcim.add_rackgroup') - - csv_data = ( + cls.csv_data = ( "site,name,slug", "Site 1,Rack Group 4,rack-group-4", "Site 1,Rack Group 5,rack-group-5", "Site 1,Rack Group 6,rack-group-6", ) - response = self.client.post(reverse('dcim:rackgroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(RackGroup.objects.count(), 6) +class RackRoleTestCase(StandardTestCases.Views): + model = RackRole - -class RackRoleTestCase(TestCase): - user_permissions = ( - 'dcim.view_rackrole', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -148,33 +139,28 @@ class RackRoleTestCase(TestCase): RackRole(name='Rack Role 3', slug='rack-role-3'), ]) - def test_rackrole_list(self): + cls.form_data = { + 'name': 'Rack Role X', + 'slug': 'rack-role-x', + 'color': 'c0c0c0', + 'description': 'New role', + } - url = reverse('dcim:rackrole_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_rackrole_import(self): - self.add_permissions('dcim.add_rackrole') - - csv_data = ( + cls.csv_data = ( "name,slug,color", "Rack Role 4,rack-role-4,ff0000", "Rack Role 5,rack-role-5,00ff00", "Rack Role 6,rack-role-6,0000ff", ) - response = self.client.post(reverse('dcim:rackrole_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(RackRole.objects.count(), 6) +class RackReservationTestCase(StandardTestCases.Views): + model = RackReservation - -class RackReservationTestCase(TestCase): - user_permissions = ( - 'dcim.view_rackreservation', - ) + # Disable inapplicable tests + test_get_object = None + test_create_object = None # TODO: Fix URL name for view + test_import_objects = None @classmethod def setUpTestData(cls): @@ -193,24 +179,27 @@ class RackReservationTestCase(TestCase): RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'), ]) - def test_rackreservation_list(self): - - url = reverse('dcim:rackreservation_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) + cls.form_data = { + 'rack': rack.pk, + 'units': [10, 11, 12], + 'user': user.pk, + 'tenant': None, + 'description': 'New reservation', + } -class RackTestCase(TestCase): - user_permissions = ( - 'dcim.view_rack', - ) +class RackTestCase(StandardTestCases.Views): + model = Rack + + # TODO: Remove this when #4067 is fixed + test_create_object = None @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') + rackgroup = RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site) + rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1') Rack.objects.bulk_create([ Rack(name='Rack 1', site=site), @@ -218,42 +207,41 @@ class RackTestCase(TestCase): Rack(name='Rack 3', site=site), ]) - def test_rack_list(self): - - url = reverse('dcim:rack_list') - params = { - "site": Site.objects.first().slug, + cls.form_data = { + 'name': 'Rack X', + 'facility_id': 'Facility X', + 'site': site.pk, + 'group': rackgroup.pk, + 'tenant': None, + 'status': RackStatusChoices.STATUS_PLANNED, + 'role': rackrole.pk, + 'serial': '123456', + 'asset_tag': 'ABCDEF', + 'type': RackTypeChoices.TYPE_CABINET, + 'width': RackWidthChoices.WIDTH_19IN, + 'u_height': 48, + 'desc_units': False, + 'outer_width': 500, + 'outer_depth': 500, + 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_rack(self): - - rack = Rack.objects.first() - response = self.client.get(rack.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_rack_import(self): - self.add_permissions('dcim.add_rack') - - csv_data = ( + cls.csv_data = ( "site,name,width,u_height", "Site 1,Rack 4,19,42", "Site 1,Rack 5,19,42", "Site 1,Rack 6,19,42", ) - response = self.client.post(reverse('dcim:rack_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Rack.objects.count(), 6) +class ManufacturerTestCase(StandardTestCases.Views): + model = Manufacturer - -class ManufacturerTypeTestCase(TestCase): - user_permissions = ( - 'dcim.view_manufacturer', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -264,33 +252,21 @@ class ManufacturerTypeTestCase(TestCase): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ]) - def test_manufacturer_list(self): + cls.form_data = { + 'name': 'Manufacturer X', + 'slug': 'manufacturer-x', + } - url = reverse('dcim:manufacturer_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_manufacturer_import(self): - self.add_permissions('dcim.add_manufacturer') - - csv_data = ( + cls.csv_data = ( "name,slug", "Manufacturer 4,manufacturer-4", "Manufacturer 5,manufacturer-5", "Manufacturer 6,manufacturer-6", ) - response = self.client.post(reverse('dcim:manufacturer_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Manufacturer.objects.count(), 6) - - -class DeviceTypeTestCase(TestCase): - user_permissions = ( - 'dcim.view_devicetype', - ) +class DeviceTypeTestCase(StandardTestCases.Views): + model = DeviceType @classmethod def setUpTestData(cls): @@ -304,35 +280,22 @@ class DeviceTypeTestCase(TestCase): DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer), ]) - def test_devicetype_list(self): - - url = reverse('dcim:devicetype_list') - params = { - "manufacturer": Manufacturer.objects.first().slug, + cls.form_data = { + 'manufacturer': manufacturer.pk, + 'model': 'Device Type X', + 'slug': 'device-type-x', + 'part_number': '123ABC', + 'u_height': 2, + 'is_full_depth': True, + 'subdevice_role': '', # CharField + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_devicetype_export(self): - - url = reverse('dcim:devicetype_list') - - response = self.client.get('{}?export'.format(url)) - self.assertHttpStatus(response, 200) - data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) - self.assertEqual(len(data), 3) - self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1') - self.assertEqual(data[0]['model'], 'Device Type 1') - - def test_devicetype(self): - - devicetype = DeviceType.objects.first() - response = self.client.get(devicetype.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_devicetype_import(self): - + def test_import_objects(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ IMPORT_DATA = """ manufacturer: Generic model: TEST-1000 @@ -471,11 +434,24 @@ device-bays: db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') + def test_devicetype_export(self): -class DeviceRoleTestCase(TestCase): - user_permissions = ( - 'dcim.view_devicerole', - ) + url = reverse('dcim:devicetype_list') + + response = self.client.get('{}?export'.format(url)) + self.assertEqual(response.status_code, 200) + data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader)) + self.assertEqual(len(data), 3) + self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1') + self.assertEqual(data[0]['model'], 'Device Type 1') + + +class DeviceRoleTestCase(StandardTestCases.Views): + model = DeviceRole + + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -486,85 +462,68 @@ class DeviceRoleTestCase(TestCase): DeviceRole(name='Device Role 3', slug='device-role-3'), ]) - def test_devicerole_list(self): + cls.form_data = { + 'name': 'Devie Role X', + 'slug': 'device-role-x', + 'color': 'c0c0c0', + 'vm_role': False, + 'description': 'New device role', + } - url = reverse('dcim:devicerole_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_devicerole_import(self): - self.add_permissions('dcim.add_devicerole') - - csv_data = ( + cls.csv_data = ( "name,slug,color", "Device Role 4,device-role-4,ff0000", "Device Role 5,device-role-5,00ff00", "Device Role 6,device-role-6,0000ff", ) - response = self.client.post(reverse('dcim:devicerole_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(DeviceRole.objects.count(), 6) +class PlatformTestCase(StandardTestCases.Views): + model = Platform - -class PlatformTestCase(TestCase): - user_permissions = ( - 'dcim.view_platform', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + Platform.objects.bulk_create([ - Platform(name='Platform 1', slug='platform-1'), - Platform(name='Platform 2', slug='platform-2'), - Platform(name='Platform 3', slug='platform-3'), + Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer), + Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer), + Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ]) - def test_platform_list(self): + cls.form_data = { + 'name': 'Platform X', + 'slug': 'platform-x', + 'manufacturer': manufacturer.pk, + 'napalm_driver': 'junos', + 'napalm_args': None, + } - url = reverse('dcim:platform_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_platform_import(self): - self.add_permissions('dcim.add_platform') - - csv_data = ( + cls.csv_data = ( "name,slug", "Platform 4,platform-4", "Platform 5,platform-5", "Platform 6,platform-6", ) - response = self.client.post(reverse('dcim:platform_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Platform.objects.count(), 6) - - -class DeviceTestCase(TestCase): - user_permissions = ( - 'dcim.view_device', - ) +class DeviceTestCase(StandardTestCases.Views): + model = Device @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() + site = Site.objects.create(name='Site 1', slug='site-1') + rack = Rack.objects.create(name='Rack 1', site=site) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + platform = Platform.objects.create(name='Platform 1', slug='platform-1') Device.objects.bulk_create([ Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), @@ -572,39 +531,39 @@ class DeviceTestCase(TestCase): Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), ]) - def test_device_list(self): - - url = reverse('dcim:device_list') - params = { - "device_type_id": DeviceType.objects.first().pk, - "role": DeviceRole.objects.first().slug, + cls.form_data = { + 'device_type': devicetype.pk, + 'device_role': devicerole.pk, + 'tenant': None, + 'platform': platform.pk, + 'name': 'Device X', + 'serial': '123456', + 'asset_tag': 'ABCDEF', + 'site': site.pk, + 'rack': rack.pk, + 'position': 1, + 'face': DeviceFaceChoices.FACE_FRONT, + 'status': DeviceStatusChoices.STATUS_PLANNED, + 'primary_ip4': None, + 'primary_ip6': None, + 'cluster': None, + 'virtual_chassis': None, + 'vc_position': None, + 'vc_priority': None, + 'comments': 'A new device', + 'tags': 'Alpha,Bravo,Charlie', + 'local_context_data': None, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_device(self): - - device = Device.objects.first() - response = self.client.get(device.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_device_import(self): - self.add_permissions('dcim.add_device') - - csv_data = ( + cls.csv_data = ( "device_role,manufacturer,model_name,status,site,name", "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 4", "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 5", "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 6", ) - response = self.client.post(reverse('dcim:device_import'), {'csv': '\n'.join(csv_data)}) - - self.assertHttpStatus(response, 200) - self.assertEqual(Device.objects.count(), 6) - +# TODO: Convert to StandardTestCases.Views class ConsolePortTestCase(TestCase): user_permissions = ( 'dcim.view_consoleport', @@ -657,6 +616,7 @@ class ConsolePortTestCase(TestCase): self.assertEqual(ConsolePort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class ConsoleServerPortTestCase(TestCase): user_permissions = ( 'dcim.view_consoleserverport', @@ -709,6 +669,7 @@ class ConsoleServerPortTestCase(TestCase): self.assertEqual(ConsoleServerPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class PowerPortTestCase(TestCase): user_permissions = ( 'dcim.view_powerport', @@ -761,6 +722,7 @@ class PowerPortTestCase(TestCase): self.assertEqual(PowerPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class PowerOutletTestCase(TestCase): user_permissions = ( 'dcim.view_poweroutlet', @@ -813,6 +775,7 @@ class PowerOutletTestCase(TestCase): self.assertEqual(PowerOutlet.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class InterfaceTestCase(TestCase): user_permissions = ( 'dcim.view_interface', @@ -865,6 +828,7 @@ class InterfaceTestCase(TestCase): self.assertEqual(Interface.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class FrontPortTestCase(TestCase): user_permissions = ( 'dcim.view_frontport', @@ -929,6 +893,7 @@ class FrontPortTestCase(TestCase): self.assertEqual(FrontPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class RearPortTestCase(TestCase): user_permissions = ( 'dcim.view_rearport', @@ -981,6 +946,7 @@ class RearPortTestCase(TestCase): self.assertEqual(RearPort.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class DeviceBayTestCase(TestCase): user_permissions = ( 'dcim.view_devicebay', @@ -1037,6 +1003,7 @@ class DeviceBayTestCase(TestCase): self.assertEqual(DeviceBay.objects.count(), 6) +# TODO: Convert to StandardTestCases.Views class InventoryItemTestCase(TestCase): user_permissions = ( 'dcim.view_inventoryitem', @@ -1092,26 +1059,19 @@ class InventoryItemTestCase(TestCase): self.assertEqual(InventoryItem.objects.count(), 6) -class CableTestCase(TestCase): - user_permissions = ( - 'dcim.view_cable', - ) +class CableTestCase(StandardTestCases.Views): + model = Cable + + # TODO: Creation URL needs termination context + test_create_object = None @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer) - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) device1.save() device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole) @@ -1121,67 +1081,59 @@ class CableTestCase(TestCase): device4 = Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole) device4.save() - iface1 = Interface(device=device1, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface1.save() - iface2 = Interface(device=device1, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface2.save() - iface3 = Interface(device=device1, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface3.save() - iface4 = Interface(device=device2, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface4.save() - iface5 = Interface(device=device2, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface5.save() - iface6 = Interface(device=device2, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED) - iface6.save() + interfaces = ( + Interface(device=device1, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device1, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device1, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device2, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device2, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device2, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device3, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device3, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device3, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device4, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device4, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device4, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(interfaces) - # Interfaces for CSV import testing - Interface(device=device3, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device3, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device3, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device4, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device4, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() - Interface(device=device4, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED).save() + Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save() + Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() + Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=iface1, termination_b=iface4, type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=iface2, termination_b=iface5, type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=iface3, termination_b=iface6, type=CableTypeChoices.TYPE_CAT6).save() - - def test_cable_list(self): - - url = reverse('dcim:cable_list') - params = { - "type": CableTypeChoices.TYPE_CAT6, + interface_ct = ContentType.objects.get_for_model(Interface) + cls.form_data = { + # Changing terminations not supported when editing an existing Cable + 'termination_a_type': interface_ct.pk, + 'termination_a_id': interfaces[0].pk, + 'termination_b_type': interface_ct.pk, + 'termination_b_id': interfaces[3].pk, + 'type': CableTypeChoices.TYPE_CAT6, + 'status': CableStatusChoices.STATUS_PLANNED, + 'label': 'New cable', + 'color': 'c0c0c0', + 'length': 100, + 'length_unit': CableLengthUnitChoices.UNIT_FOOT, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_cable(self): - - cable = Cable.objects.first() - response = self.client.get(cable.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_cable_import(self): - self.add_permissions('dcim.add_cable') - - csv_data = ( + cls.csv_data = ( "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", "Device 3,interface,Interface 1,Device 4,interface,Interface 1", "Device 3,interface,Interface 2,Device 4,interface,Interface 2", "Device 3,interface,Interface 3,Device 4,interface,Interface 3", ) - response = self.client.post(reverse('dcim:cable_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Cable.objects.count(), 6) +class VirtualChassisTestCase(StandardTestCases.Views): + model = VirtualChassis + # Disable inapplicable tests + test_get_object = None + test_import_objects = None -class VirtualChassisTestCase(TestCase): - user_permissions = ( - 'dcim.view_virtualchassis', - ) + # TODO: Requires special form handling + test_create_object = None + test_edit_object = None @classmethod def setUpTestData(cls): @@ -1222,10 +1174,3 @@ class VirtualChassisTestCase(TestCase): Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2) vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3') Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) - - def test_virtualchassis_list(self): - - url = reverse('dcim:virtualchassis_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) From c9d0dcecf301bc2fcb3c91fe4c78d888e69f0d5f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 13:44:34 -0500 Subject: [PATCH 74/99] model_to_dict(): Convert object lists to PK lists --- netbox/utilities/testing/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 0cc2e6ce0..7ff45a30d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -22,6 +22,10 @@ def model_to_dict(instance, fields=None, exclude=None): if key.startswith('_'): del model_dict[key] + # Convert ManyToManyField to list of instance PKs + elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'): + model_dict[key] = [obj.pk for obj in model_dict[key]] + if 'tags' in model_dict: model_dict['tags'] = ','.join(sorted([tag.name for tag in model_dict['tags']])) From ab7b9216411fdabd925506cf0b8f8097e1370c6e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 13:45:09 -0500 Subject: [PATCH 75/99] Convert extras view tests to StandardTestCases --- netbox/extras/tests/test_views.py | 70 ++++++++++++++++--------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index fc77a81f5..0fc60dcd9 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -7,44 +7,47 @@ from django.urls import reverse from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import ConfigContext, ObjectChange, Tag -from utilities.testing import TestCase +from utilities.testing import StandardTestCases, TestCase -class TagTestCase(TestCase): - user_permissions = ( - 'extras.view_tag', - ) +class TagTestCase(StandardTestCases.Views): + model = Tag + + # Disable inapplicable tests + test_create_object = None + test_import_objects = None @classmethod def setUpTestData(cls): - Tag.objects.bulk_create([ + Tag.objects.bulk_create(( Tag(name='Tag 1', slug='tag-1'), Tag(name='Tag 2', slug='tag-2'), Tag(name='Tag 3', slug='tag-3'), - ]) + )) - def test_tag_list(self): - - url = reverse('extras:tag_list') - params = { - "q": "tag", + cls.form_data = { + 'name': 'Tag X', + 'slug': 'tag-x', + 'color': 'c0c0c0', + 'comments': 'Some comments', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) +class ConfigContextTestCase(StandardTestCases.Views): + model = ConfigContext -class ConfigContextTestCase(TestCase): - user_permissions = ( - 'extras.view_configcontext', - ) + # Disable inapplicable tests + test_import_objects = None + + # TODO: Resolve model discrepancies when creating/editing ConfigContexts + test_create_object = None + test_edit_object = None @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') # Create three ConfigContexts for i in range(1, 4): @@ -55,22 +58,21 @@ class ConfigContextTestCase(TestCase): configcontext.save() configcontext.sites.add(site) - def test_configcontext_list(self): - - url = reverse('extras:configcontext_list') - params = { - "q": "foo", + cls.form_data = { + 'name': 'Config Context X', + 'weight': 200, + 'description': 'A new config context', + 'is_active': True, + 'regions': [], + 'sites': [site.pk], + 'roles': [], + 'platforms': [], + 'tenant_groups': [], + 'tenants': [], + 'tags': [], + 'data': '{"foo": 123}', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_configcontext(self): - - configcontext = ConfigContext.objects.first() - response = self.client.get(configcontext.get_absolute_url()) - self.assertHttpStatus(response, 200) - class ObjectChangeTestCase(TestCase): user_permissions = ( From 936e3424bbffeba660543a397d9a6201b9fbbd96 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 14:12:48 -0500 Subject: [PATCH 76/99] Refactor model_to_dict() to better handle tags --- netbox/utilities/testing/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 7ff45a30d..6d20d4fff 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -22,13 +22,14 @@ def model_to_dict(instance, fields=None, exclude=None): if key.startswith('_'): del model_dict[key] + # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext) + elif key == 'tags': + model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']])) + # Convert ManyToManyField to list of instance PKs elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'): model_dict[key] = [obj.pk for obj in model_dict[key]] - if 'tags' in model_dict: - model_dict['tags'] = ','.join(sorted([tag.name for tag in model_dict['tags']])) - return model_dict From 250bda2bf6089c98ff54f6fda2b6616f7c6b3077 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 14:13:30 -0500 Subject: [PATCH 77/99] Extend and correct evaluation of view permissions --- netbox/utilities/testing/testcases.py | 54 +++++++++++++++++++++------ 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 90138c3d9..f43bacc54 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -1,6 +1,6 @@ from django.contrib.auth.models import Permission, User from django.core.exceptions import ObjectDoesNotExist -from django.test import Client, TestCase as _TestCase +from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch from rest_framework.test import APIClient @@ -124,21 +124,41 @@ class StandardTestCases: else: raise Exception("Invalid action for URL resolution: {}".format(action)) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects(self): + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(self._get_url('list')), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) response = self.client.get(self._get_url('list')) self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): instance = self.model.objects.first() + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) response = self.client.get(instance.get_absolute_url()) self.assertHttpStatus(response, 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object(self): initial_count = self.model.objects.count() request = { 'path': self._get_url('add'), 'data': post_data(self.form_data), - 'follow': True, + 'follow': False, # Do not follow 302 redirects } # Attempt to make the request without required permissions @@ -146,21 +166,24 @@ class StandardTestCases: self.assertHttpStatus(self.client.post(**request), 403) # Assign the required permission and submit again - self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + self.add_permissions( + '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) response = self.client.post(**request) - self.assertHttpStatus(response, 200) + self.assertHttpStatus(response, 302) self.assertEqual(initial_count + 1, self.model.objects.count()) instance = self.model.objects.order_by('-pk').first() self.assertDictEqual(model_to_dict(instance), self.form_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object(self): instance = self.model.objects.first() request = { 'path': self._get_url('edit', instance), 'data': post_data(self.form_data), - 'follow': True, + 'follow': False, # Do not follow 302 redirects } # Attempt to make the request without required permissions @@ -168,20 +191,23 @@ class StandardTestCases: self.assertHttpStatus(self.client.post(**request), 403) # Assign the required permission and submit again - self.add_permissions('{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + self.add_permissions( + '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) response = self.client.post(**request) - self.assertHttpStatus(response, 200) + self.assertHttpStatus(response, 302) instance = self.model.objects.get(pk=instance.pk) self.assertDictEqual(model_to_dict(instance), self.form_data) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_delete_object(self): instance = self.model.objects.first() request = { 'path': self._get_url('delete', instance), 'data': {'confirm': True}, - 'follow': True, + 'follow': False, # Do not follow 302 redirects } # Attempt to make the request without required permissions @@ -189,13 +215,16 @@ class StandardTestCases: self.assertHttpStatus(self.client.post(**request), 403) # Assign the required permission and submit again - self.add_permissions('{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + self.add_permissions( + '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) response = self.client.post(**request) - self.assertHttpStatus(response, 200) + self.assertHttpStatus(response, 302) with self.assertRaises(ObjectDoesNotExist): self.model.objects.get(pk=instance.pk) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_import_objects(self): initial_count = self.model.objects.count() request = { @@ -210,7 +239,10 @@ class StandardTestCases: self.assertHttpStatus(self.client.post(**request), 403) # Assign the required permission and submit again - self.add_permissions('{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)) + self.add_permissions( + '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name), + '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) response = self.client.post(**request) self.assertHttpStatus(response, 200) From 8881bba6968c69062ba75a51d5a64ab14919b6c7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 14:22:56 -0500 Subject: [PATCH 78/99] Suppress tag view test until #4071 is fixed --- netbox/extras/tests/test_views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 0fc60dcd9..0b9a0ffdf 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -17,6 +17,9 @@ class TagTestCase(StandardTestCases.Views): test_create_object = None test_import_objects = None + # TODO: Restore test when #4071 is resolved + test_get_object = None + @classmethod def setUpTestData(cls): From 3668aa21fe6434c67c58018bf3d9e2d2e0ef4a3b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 14:29:56 -0500 Subject: [PATCH 79/99] Fix DeviceTypeTestCase permissions assignment for custom tests --- netbox/dcim/tests/test_views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 35fdf3aa4..8fc883812 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -371,6 +371,7 @@ device-bays: # Add all required permissions to the test user self.add_permissions( + 'dcim.view_devicetype', 'dcim.add_devicetype', 'dcim.add_consoleporttemplate', 'dcim.add_consoleserverporttemplate', @@ -437,6 +438,7 @@ device-bays: def test_devicetype_export(self): url = reverse('dcim:devicetype_list') + self.add_permissions('dcim.view_devicetype') response = self.client.get('{}?export'.format(url)) self.assertEqual(response.status_code, 200) From b361cb00f2519ff6185c948db8fb2e9abc4910c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 15:19:10 -0500 Subject: [PATCH 80/99] Convert IPAM view tests to use StandardTestCases --- netbox/ipam/tests/test_views.py | 362 +++++++++++--------------------- 1 file changed, 128 insertions(+), 234 deletions(-) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 1ea5f2e2b..ca6a4c42b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,18 +1,17 @@ -from netaddr import IPNetwork +import datetime import urllib.parse from django.urls import reverse +from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site -from ipam.choices import ServiceProtocolChoices +from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import TestCase +from utilities.testing import StandardTestCases, TestCase -class VRFTestCase(TestCase): - user_permissions = ( - 'ipam.view_vrf', - ) +class VRFTestCase(StandardTestCases.Views): + model = VRF @classmethod def setUpTestData(cls): @@ -23,42 +22,29 @@ class VRFTestCase(TestCase): VRF(name='VRF 3', rd='65000:3'), ]) - def test_vrf_list(self): - - url = reverse('ipam:vrf_list') - params = { - "q": "65000", + cls.form_data = { + 'name': 'VRF X', + 'rd': '65000:999', + 'tenant': None, + 'enforce_unique': True, + 'description': 'A new VRF', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_vrf(self): - - vrf = VRF.objects.first() - response = self.client.get(vrf.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_vrf_import(self): - self.add_permissions('ipam.add_vrf') - - csv_data = ( + cls.csv_data = ( "name", "VRF 4", "VRF 5", "VRF 6", ) - response = self.client.post(reverse('ipam:vrf_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(VRF.objects.count(), 6) +class RIRTestCase(StandardTestCases.Views): + model = RIR - -class RIRTestCase(TestCase): - user_permissions = ( - 'ipam.view_rir', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -69,39 +55,27 @@ class RIRTestCase(TestCase): RIR(name='RIR 3', slug='rir-3'), ]) - def test_rir_list(self): + cls.form_data = { + 'name': 'RIR X', + 'slug': 'rir-x', + 'is_private': True, + } - url = reverse('ipam:rir_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_rir_import(self): - self.add_permissions('ipam.add_rir') - - csv_data = ( + cls.csv_data = ( "name,slug", "RIR 4,rir-4", "RIR 5,rir-5", "RIR 6,rir-6", ) - response = self.client.post(reverse('ipam:rir_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(RIR.objects.count(), 6) - - -class AggregateTestCase(TestCase): - user_permissions = ( - 'ipam.view_aggregate', - ) +class AggregateTestCase(StandardTestCases.Views): + model = Aggregate @classmethod def setUpTestData(cls): - rir = RIR(name='RIR 1', slug='rir-1') - rir.save() + rir = RIR.objects.create(name='RIR 1', slug='rir-1') Aggregate.objects.bulk_create([ Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir), @@ -109,42 +83,29 @@ class AggregateTestCase(TestCase): Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir), ]) - def test_aggregate_list(self): - - url = reverse('ipam:aggregate_list') - params = { - "rir": RIR.objects.first().slug, + cls.form_data = { + 'family': 4, + 'prefix': IPNetwork('10.99.0.0/16'), + 'rir': rir.pk, + 'date_added': datetime.date(2020, 1, 1), + 'description': 'A new aggregate', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_aggregate(self): - - aggregate = Aggregate.objects.first() - response = self.client.get(aggregate.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_aggregate_import(self): - self.add_permissions('ipam.add_aggregate') - - csv_data = ( + cls.csv_data = ( "prefix,rir", "10.4.0.0/16,RIR 1", "10.5.0.0/16,RIR 1", "10.6.0.0/16,RIR 1", ) - response = self.client.post(reverse('ipam:aggregate_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Aggregate.objects.count(), 6) +class RoleTestCase(StandardTestCases.Views): + model = Role - -class RoleTestCase(TestCase): - user_permissions = ( - 'ipam.view_role', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -155,39 +116,31 @@ class RoleTestCase(TestCase): Role(name='Role 3', slug='role-3'), ]) - def test_role_list(self): + cls.form_data = { + 'name': 'Role X', + 'slug': 'role-x', + 'weight': 200, + 'description': 'A new role', + } - url = reverse('ipam:role_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_role_import(self): - self.add_permissions('ipam.add_role') - - csv_data = ( + cls.csv_data = ( "name,slug,weight", "Role 4,role-4,1000", "Role 5,role-5,1000", "Role 6,role-6,1000", ) - response = self.client.post(reverse('ipam:role_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Role.objects.count(), 6) - - -class PrefixTestCase(TestCase): - user_permissions = ( - 'ipam.view_prefix', - ) +class PrefixTestCase(StandardTestCases.Views): + model = Prefix @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') + vrf = VRF.objects.create(name='VRF 1', rd='65000:1') + role = Role.objects.create(name='Role 1', slug='role-1') + # vlan = VLAN.objects.create(vid=123, name='VLAN 123') Prefix.objects.bulk_create([ Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site), @@ -195,48 +148,34 @@ class PrefixTestCase(TestCase): Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site), ]) - def test_prefix_list(self): - - url = reverse('ipam:prefix_list') - params = { - "site": Site.objects.first().slug, + cls.form_data = { + 'prefix': IPNetwork('192.0.2.0/24'), + 'site': site.pk, + 'vrf': vrf.pk, + 'tenant': None, + 'vlan': None, + 'status': PrefixStatusChoices.STATUS_RESERVED, + 'role': role.pk, + 'is_pool': True, + 'description': 'A new prefix', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_prefix(self): - - prefix = Prefix.objects.first() - response = self.client.get(prefix.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_prefix_import(self): - self.add_permissions('ipam.add_prefix') - - csv_data = ( + cls.csv_data = ( "prefix,status", "10.4.0.0/16,Active", "10.5.0.0/16,Active", "10.6.0.0/16,Active", ) - response = self.client.post(reverse('ipam:prefix_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Prefix.objects.count(), 6) - - -class IPAddressTestCase(TestCase): - user_permissions = ( - 'ipam.view_ipaddress', - ) +class IPAddressTestCase(StandardTestCases.Views): + model = IPAddress @classmethod def setUpTestData(cls): - vrf = VRF(name='VRF 1', rd='65000:1') - vrf.save() + vrf = VRF.objects.create(name='VRF 1', rd='65000:1') IPAddress.objects.bulk_create([ IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf), @@ -244,48 +183,38 @@ class IPAddressTestCase(TestCase): IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf), ]) - def test_ipaddress_list(self): - - url = reverse('ipam:ipaddress_list') - params = { - "vrf": VRF.objects.first().rd, + cls.form_data = { + 'vrf': vrf.pk, + 'address': IPNetwork('192.0.2.99/24'), + 'tenant': None, + 'status': IPAddressStatusChoices.STATUS_RESERVED, + 'role': IPAddressRoleChoices.ROLE_ANYCAST, + 'interface': None, + 'nat_inside': None, + 'dns_name': 'example', + 'description': 'A new IP address', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_ipaddress(self): - - ipaddress = IPAddress.objects.first() - response = self.client.get(ipaddress.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_ipaddress_import(self): - self.add_permissions('ipam.add_ipaddress') - - csv_data = ( + cls.csv_data = ( "address,status", "192.0.2.4/24,Active", "192.0.2.5/24,Active", "192.0.2.6/24,Active", ) - response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(IPAddress.objects.count(), 6) +class VLANGroupTestCase(StandardTestCases.Views): + model = VLANGroup - -class VLANGroupTestCase(TestCase): - user_permissions = ( - 'ipam.view_vlangroup', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') VLANGroup.objects.bulk_create([ VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site), @@ -293,42 +222,29 @@ class VLANGroupTestCase(TestCase): VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site), ]) - def test_vlangroup_list(self): - - url = reverse('ipam:vlangroup_list') - params = { - "site": Site.objects.first().slug, + cls.form_data = { + 'name': 'VLAN Group X', + 'slug': 'vlan-group-x', + 'site': site.pk, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_vlangroup_import(self): - self.add_permissions('ipam.add_vlangroup') - - csv_data = ( + cls.csv_data = ( "name,slug", "VLAN Group 4,vlan-group-4", "VLAN Group 5,vlan-group-5", "VLAN Group 6,vlan-group-6", ) - response = self.client.post(reverse('ipam:vlangroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(VLANGroup.objects.count(), 6) - - -class VLANTestCase(TestCase): - user_permissions = ( - 'ipam.view_vlan', - ) +class VLANTestCase(StandardTestCases.Views): + model = VLAN @classmethod def setUpTestData(cls): - vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1') - vlangroup.save() + site = Site.objects.create(name='Site 1', slug='site-1') + vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1', site=site) + role = Role.objects.create(name='Role 1', slug='role-1') VLAN.objects.bulk_create([ VLAN(group=vlangroup, vid=101, name='VLAN101'), @@ -336,60 +252,43 @@ class VLANTestCase(TestCase): VLAN(group=vlangroup, vid=103, name='VLAN103'), ]) - def test_vlan_list(self): - - url = reverse('ipam:vlan_list') - params = { - "group": VLANGroup.objects.first().slug, + cls.form_data = { + 'site': site.pk, + 'group': vlangroup.pk, + 'vid': 999, + 'name': 'VLAN999', + 'tenant': None, + 'status': VLANStatusChoices.STATUS_RESERVED, + 'role': role.pk, + 'description': 'A new VLAN', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_vlan(self): - - vlan = VLAN.objects.first() - response = self.client.get(vlan.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_vlan_import(self): - self.add_permissions('ipam.add_vlan') - - csv_data = ( + cls.csv_data = ( "vid,name,status", "104,VLAN104,Active", "105,VLAN105,Active", "106,VLAN106,Active", ) - response = self.client.post(reverse('ipam:vlan_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(VLAN.objects.count(), 6) +class ServiceTestCase(StandardTestCases.Views): + model = Service + # Disable inapplicable tests + test_import_objects = None -class ServiceTestCase(TestCase): - user_permissions = ( - 'ipam.view_service', - ) + # TODO: Resolve URL for Service creation + test_create_object = None @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1') - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) Service.objects.bulk_create([ Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101), @@ -397,18 +296,13 @@ class ServiceTestCase(TestCase): Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), ]) - def test_service_list(self): - - url = reverse('ipam:service_list') - params = { - "device_id": Device.objects.first(), + cls.form_data = { + 'device': device.pk, + 'virtual_machine': None, + 'name': 'Service X', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'port': 999, + 'ipaddresses': [], + 'description': 'A new service', + 'tags': 'Alpha,Bravo,Charlie', } - - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_service(self): - - service = Service.objects.first() - response = self.client.get(service.get_absolute_url()) - self.assertHttpStatus(response, 200) From e8e39dc5e3ec65400f73b641120da77fe130c0c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 15:37:58 -0500 Subject: [PATCH 81/99] Convert secrets view tests to use StandardTestCases --- netbox/secrets/tests/test_views.py | 90 +++++++++++------------------- 1 file changed, 34 insertions(+), 56 deletions(-) diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 336a33320..87c1c3683 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -5,14 +5,16 @@ from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import TestCase +from utilities.testing import StandardTestCases, TestCase from .constants import PRIVATE_KEY, PUBLIC_KEY -class SecretRoleTestCase(TestCase): - user_permissions = ( - 'secrets.view_secretrole', - ) +class SecretRoleTestCase(StandardTestCases.Views): + model = SecretRole + + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -23,54 +25,40 @@ class SecretRoleTestCase(TestCase): SecretRole(name='Secret Role 3', slug='secret-role-3'), ]) - def test_secretrole_list(self): + cls.form_data = { + 'name': 'Secret Role X', + 'slug': 'secret-role-x', + 'description': 'A secret role', + 'users': [], + 'groups': [], + } - url = reverse('secrets:secretrole_list') - - response = self.client.get(url, follow=True) - self.assertHttpStatus(response, 200) - - def test_secretrole_import(self): - self.add_permissions('secrets.add_secretrole') - - csv_data = ( + cls.csv_data = ( "name,slug", "Secret Role 4,secret-role-4", "Secret Role 5,secret-role-5", "Secret Role 6,secret-role-6", ) - response = self.client.post(reverse('secrets:secretrole_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(SecretRole.objects.count(), 6) +class SecretTestCase(StandardTestCases.Views): + model = Secret + # Disable inapplicable tests + test_create_object = None -class SecretTestCase(TestCase): - user_permissions = ( - 'secrets.view_secret', - ) + # TODO: Check permissions enforcement on secrets.views.secret_edit + test_edit_object = None @classmethod def setUpTestData(cls): - site = Site(name='Site 1', slug='site-1') - site.save() - - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() - - devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1') - devicetype.save() - - devicerole = DeviceRole(name='Device Role 1', slug='device-role-1') - devicerole.save() - - device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device.save() - - secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1') - secretrole.save() + site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) + secretrole = SecretRole.objects.create(name='Secret Role 1', slug='secret-role-1') Secret.objects.bulk_create([ Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'), @@ -78,6 +66,12 @@ class SecretTestCase(TestCase): Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'), ]) + cls.form_data = { + 'device': device.pk, + 'role': secretrole.pk, + 'name': 'Secret X', + } + def setUp(self): super().setUp() @@ -89,23 +83,7 @@ class SecretTestCase(TestCase): self.session_key = SessionKey(userkey=userkey) self.session_key.save(master_key) - def test_secret_list(self): - - url = reverse('secrets:secret_list') - params = { - "role": SecretRole.objects.first().slug, - } - - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) - self.assertHttpStatus(response, 200) - - def test_secret(self): - - secret = Secret.objects.first() - response = self.client.get(secret.get_absolute_url(), follow=True) - self.assertHttpStatus(response, 200) - - def test_secret_import(self): + def test_import_objects(self): self.add_permissions('secrets.add_secret') csv_data = ( From 5517145ae3e86e4bbcc654358884c693099a68eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 15:44:10 -0500 Subject: [PATCH 82/99] Convert tenancy view tests to use StandardTestCases --- netbox/tenancy/tests/test_views.py | 76 +++++++++--------------------- 1 file changed, 23 insertions(+), 53 deletions(-) diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 8646abe38..1825a4ff9 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -1,15 +1,13 @@ -import urllib.parse - -from django.urls import reverse - from tenancy.models import Tenant, TenantGroup -from utilities.testing import TestCase +from utilities.testing import StandardTestCases -class TenantGroupTestCase(TestCase): - user_permissions = ( - 'tenancy.view_tenantgroup', - ) +class TenantGroupTestCase(StandardTestCases.Views): + model = TenantGroup + + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -20,39 +18,26 @@ class TenantGroupTestCase(TestCase): TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ]) - def test_tenantgroup_list(self): + cls.form_data = { + 'name': 'Tenant Group X', + 'slug': 'tenant-group-x', + } - url = reverse('tenancy:tenantgroup_list') - - response = self.client.get(url, follow=True) - self.assertHttpStatus(response, 200) - - def test_tenantgroup_import(self): - self.add_permissions('tenancy.add_tenantgroup') - - csv_data = ( + cls.csv_data = ( "name,slug", "Tenant Group 4,tenant-group-4", "Tenant Group 5,tenant-group-5", "Tenant Group 6,tenant-group-6", ) - response = self.client.post(reverse('tenancy:tenantgroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(TenantGroup.objects.count(), 6) - - -class TenantTestCase(TestCase): - user_permissions = ( - 'tenancy.view_tenant', - ) +class TenantTestCase(StandardTestCases.Views): + model = Tenant @classmethod def setUpTestData(cls): - tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1') - tenantgroup.save() + tenantgroup = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1') Tenant.objects.bulk_create([ Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroup), @@ -60,33 +45,18 @@ class TenantTestCase(TestCase): Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroup), ]) - def test_tenant_list(self): - - url = reverse('tenancy:tenant_list') - params = { - "group": TenantGroup.objects.first().slug, + cls.form_data = { + 'name': 'Tenant X', + 'slug': 'tenant-x', + 'group': tenantgroup.pk, + 'description': 'A new tenant', + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) - self.assertHttpStatus(response, 200) - - def test_tenant(self): - - tenant = Tenant.objects.first() - response = self.client.get(tenant.get_absolute_url(), follow=True) - self.assertHttpStatus(response, 200) - - def test_tenant_import(self): - self.add_permissions('tenancy.add_tenant') - - csv_data = ( + cls.csv_data = ( "name,slug", "Tenant 4,tenant-4", "Tenant 5,tenant-5", "Tenant 6,tenant-6", ) - - response = self.client.post(reverse('tenancy:tenant_import'), {'csv': '\n'.join(csv_data)}) - - self.assertHttpStatus(response, 200) - self.assertEqual(Tenant.objects.count(), 6) From e50eab2342b35f107141d29b61667fc55010a5a8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 15:57:33 -0500 Subject: [PATCH 83/99] Convert virtualization view tests to use StandardTestCases --- netbox/virtualization/tests/test_views.py | 167 ++++++++-------------- 1 file changed, 60 insertions(+), 107 deletions(-) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index df346d11e..ed065678b 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,15 +1,15 @@ -import urllib.parse - -from django.urls import reverse - -from utilities.testing import TestCase +from dcim.models import DeviceRole, Platform, Site +from utilities.testing import StandardTestCases +from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine -class ClusterGroupTestCase(TestCase): - user_permissions = ( - 'virtualization.view_clustergroup', - ) +class ClusterGroupTestCase(StandardTestCases.Views): + model = ClusterGroup + + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -20,33 +20,25 @@ class ClusterGroupTestCase(TestCase): ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), ]) - def test_clustergroup_list(self): + cls.form_data = { + 'name': 'Cluster Group X', + 'slug': 'cluster-group-x', + } - url = reverse('virtualization:clustergroup_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_clustergroup_import(self): - self.add_permissions('virtualization.add_clustergroup') - - csv_data = ( + cls.csv_data = ( "name,slug", "Cluster Group 4,cluster-group-4", "Cluster Group 5,cluster-group-5", "Cluster Group 6,cluster-group-6", ) - response = self.client.post(reverse('virtualization:clustergroup_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(ClusterGroup.objects.count(), 6) +class ClusterTypeTestCase(StandardTestCases.Views): + model = ClusterType - -class ClusterTypeTestCase(TestCase): - user_permissions = ( - 'virtualization.view_clustertype', - ) + # Disable inapplicable tests + test_get_object = None + test_delete_object = None @classmethod def setUpTestData(cls): @@ -57,42 +49,28 @@ class ClusterTypeTestCase(TestCase): ClusterType(name='Cluster Type 3', slug='cluster-type-3'), ]) - def test_clustertype_list(self): + cls.form_data = { + 'name': 'Cluster Type X', + 'slug': 'cluster-type-x', + } - url = reverse('virtualization:clustertype_list') - - response = self.client.get(url) - self.assertHttpStatus(response, 200) - - def test_clustertype_import(self): - self.add_permissions('virtualization.add_clustertype') - - csv_data = ( + cls.csv_data = ( "name,slug", "Cluster Type 4,cluster-type-4", "Cluster Type 5,cluster-type-5", "Cluster Type 6,cluster-type-6", ) - response = self.client.post(reverse('virtualization:clustertype_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(ClusterType.objects.count(), 6) - - -class ClusterTestCase(TestCase): - user_permissions = ( - 'virtualization.view_cluster', - ) +class ClusterTestCase(StandardTestCases.Views): + model = Cluster @classmethod def setUpTestData(cls): - clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1') - clustergroup.save() - - clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1') - clustertype.save() + site = Site.objects.create(name='Site 1', slug='site-1') + clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') Cluster.objects.bulk_create([ Cluster(name='Cluster 1', group=clustergroup, type=clustertype), @@ -100,52 +78,34 @@ class ClusterTestCase(TestCase): Cluster(name='Cluster 3', group=clustergroup, type=clustertype), ]) - def test_cluster_list(self): - - url = reverse('virtualization:cluster_list') - params = { - "group": ClusterGroup.objects.first().slug, - "type": ClusterType.objects.first().slug, + cls.form_data = { + 'name': 'Cluster X', + 'group': clustergroup.pk, + 'type': clustertype.pk, + 'tenant': None, + 'site': site.pk, + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_cluster(self): - - cluster = Cluster.objects.first() - response = self.client.get(cluster.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_cluster_import(self): - self.add_permissions('virtualization.add_cluster') - - csv_data = ( + cls.csv_data = ( "name,type", "Cluster 4,Cluster Type 1", "Cluster 5,Cluster Type 1", "Cluster 6,Cluster Type 1", ) - response = self.client.post(reverse('virtualization:cluster_import'), {'csv': '\n'.join(csv_data)}) - self.assertHttpStatus(response, 200) - self.assertEqual(Cluster.objects.count(), 6) - - -class VirtualMachineTestCase(TestCase): - user_permissions = ( - 'virtualization.view_virtualmachine', - ) +class VirtualMachineTestCase(StandardTestCases.Views): + model = VirtualMachine @classmethod def setUpTestData(cls): - clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1') - clustertype.save() - - cluster = Cluster(name='Cluster 1', type=clustertype) - cluster.save() + devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + platform = Platform.objects.create(name='Platform 1', slug='platform-1') + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=clustertype) VirtualMachine.objects.bulk_create([ VirtualMachine(name='Virtual Machine 1', cluster=cluster), @@ -153,33 +113,26 @@ class VirtualMachineTestCase(TestCase): VirtualMachine(name='Virtual Machine 3', cluster=cluster), ]) - def test_virtualmachine_list(self): - - url = reverse('virtualization:virtualmachine_list') - params = { - "cluster_id": Cluster.objects.first().pk, + cls.form_data = { + 'cluster': cluster.pk, + 'tenant': None, + 'platform': None, + 'name': 'Virtual Machine X', + 'status': VirtualMachineStatusChoices.STATUS_STAGED, + 'role': devicerole.pk, + 'primary_ip4': None, + 'primary_ip6': None, + 'vcpus': 4, + 'memory': 32768, + 'disk': 4000, + 'comments': 'Some comments', + 'tags': 'Alpha,Bravo,Charlie', + 'local_context_data': None, } - response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) - self.assertHttpStatus(response, 200) - - def test_virtualmachine(self): - - virtualmachine = VirtualMachine.objects.first() - response = self.client.get(virtualmachine.get_absolute_url()) - self.assertHttpStatus(response, 200) - - def test_virtualmachine_import(self): - self.add_permissions('virtualization.add_virtualmachine') - - csv_data = ( + cls.csv_data = ( "name,cluster", "Virtual Machine 4,Cluster 1", "Virtual Machine 5,Cluster 1", "Virtual Machine 6,Cluster 1", ) - - response = self.client.post(reverse('virtualization:virtualmachine_import'), {'csv': '\n'.join(csv_data)}) - - self.assertHttpStatus(response, 200) - self.assertEqual(VirtualMachine.objects.count(), 6) From eb9538d6da0cc77443be3070dc4db596531b69df Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 15:59:26 -0500 Subject: [PATCH 84/99] Clean up imports --- netbox/ipam/tests/test_views.py | 4 +--- netbox/secrets/tests/test_views.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index ca6a4c42b..db8326fbd 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,13 +1,11 @@ import datetime -import urllib.parse -from django.urls import reverse from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -from utilities.testing import StandardTestCases, TestCase +from utilities.testing import StandardTestCases class VRFTestCase(StandardTestCases.Views): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 87c1c3683..1da689e53 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,11 +1,10 @@ import base64 -import urllib.parse from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.testing import StandardTestCases, TestCase +from utilities.testing import StandardTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY From cea1e3d090f35a28045d7994673f0990e9ec05e2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 16:10:16 -0500 Subject: [PATCH 85/99] Fixes #4071: Enforce "view tag" permission on individual tag view --- docs/release-notes/version-2.7.md | 1 + netbox/extras/tests/test_views.py | 3 --- netbox/extras/views.py | 3 ++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 0c0518dee..4cd35744a 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -12,6 +12,7 @@ * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer * [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines * [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569) +* [#4071](https://github.com/netbox-community/netbox/issues/4071) - Enforce "view tag" permission on individual tag view --- diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 0b9a0ffdf..0fc60dcd9 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -17,9 +17,6 @@ class TagTestCase(StandardTestCases.Views): test_create_object = None test_import_objects = None - # TODO: Restore test when #4071 is resolved - test_get_object = None - @classmethod def setUpTestData(cls): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2fce98cc4..40f3466ef 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -37,7 +37,8 @@ class TagListView(PermissionRequiredMixin, ObjectListView): template_name = 'extras/tag_list.html' -class TagView(View): +class TagView(PermissionRequiredMixin, View): + permission_required = 'extras.view_tag' def get(self, request, slug): From f632b5bc294c21b0f74463d3ec944af3c24ccfda Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 16:12:46 -0500 Subject: [PATCH 86/99] Fixes #4067: Correct permission checked when creating a rack (vs. editing) --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/tests/test_views.py | 3 --- netbox/dcim/urls.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 4cd35744a..a58c7e9f1 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -12,6 +12,7 @@ * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer * [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines * [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569) +* [#4067](https://github.com/netbox-community/netbox/issues/4067) - Correct permission checked when creating a rack (vs. editing) * [#4071](https://github.com/netbox-community/netbox/issues/4071) - Enforce "view tag" permission on individual tag view --- diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8fc883812..5a4d78d50 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -191,9 +191,6 @@ class RackReservationTestCase(StandardTestCases.Views): class RackTestCase(StandardTestCases.Views): model = Rack - # TODO: Remove this when #4067 is fixed - test_create_object = None - @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 956b49bc4..834a6070f 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -60,7 +60,7 @@ urlpatterns = [ # Racks path(r'racks/', views.RackListView.as_view(), name='rack_list'), path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), - path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'), + path(r'racks/add/', views.RackCreateView.as_view(), name='rack_add'), path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), From 2ea95941e2c64aee278429d483cd69e03c8611f3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 17:08:38 -0500 Subject: [PATCH 87/99] Removed obsolete CSV headers from DeviceType (export is now YAML-based) --- netbox/dcim/models/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 350330757..2a4988ba4 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1018,9 +1018,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) - csv_headers = [ - 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', - ] clone_fields = [ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', ] From 5386ed438ecaabb916ebcc56693bbee86977828c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 31 Jan 2020 17:09:50 -0500 Subject: [PATCH 88/99] Extend standard view test case to validate built-in CSV export --- netbox/utilities/testing/testcases.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index f43bacc54..a29fd4de9 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -137,6 +137,12 @@ class StandardTestCases: response = self.client.get(self._get_url('list')) self.assertHttpStatus(response, 200) + # Built-in CSV export + if hasattr(self.model, 'csv_headers'): + response = self.client.get('{}?export'.format(self._get_url('list'))) + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv') + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_get_object(self): instance = self.model.objects.first() From bceaa4a9a499504c119ed289de74756230d52915 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Sun, 2 Feb 2020 23:37:01 +0000 Subject: [PATCH 89/99] Corrected models for cluster and cluster group fields --- netbox/extras/forms.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 5c33c7c98..1e9efce74 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -13,6 +13,7 @@ from utilities.forms import ( CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) +from virtualization.models import Cluster, ClusterGroup from .choices import * from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag @@ -347,7 +348,7 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): ) ) cluster_group = FilterChoiceField( - queryset=TenantGroup.objects.all(), + queryset=ClusterGroup.objects.all(), to_field_name='slug', widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", @@ -355,7 +356,7 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): ) ) cluster_id = FilterChoiceField( - queryset=Tenant.objects.all(), + queryset=Cluster.objects.all(), label='Cluster', widget=APISelectMultiple( api_url="/api/virtualization/clusters/", From 24ab0826743474ef3414aa81fd1e99a4eb16fe5f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 10:04:09 -0500 Subject: [PATCH 90/99] Add bulk delete view tests --- netbox/dcim/tests/test_views.py | 1 + netbox/utilities/testing/testcases.py | 30 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 5a4d78d50..34c0f38b4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1129,6 +1129,7 @@ class VirtualChassisTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_import_objects = None + test_bulk_delete_objects = None # TODO: Requires special form handling test_create_object = None diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index a29fd4de9..ff4353962 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -107,7 +107,7 @@ class StandardTestCases: self.model._meta.model_name ) - if action in ('list', 'add', 'import'): + if action in ('list', 'add', 'import', 'bulk_delete'): return reverse(url_format.format(action)) elif action in ('get', 'edit', 'delete'): @@ -253,3 +253,31 @@ class StandardTestCases: self.assertHttpStatus(response, 200) self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_delete_objects(self): + pk_list = self.model.objects.values_list('pk', flat=True) + + request = { + 'path': self._get_url('bulk_delete'), + 'data': { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + }, + 'follow': False, # Do not follow 302 redirects + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + # Check that all objects were deleted + self.assertEqual(self.model.objects.count(), 0) From a4aadf730c42e79ea9f9b8785668aa71bbc3b5c5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 11:59:43 -0500 Subject: [PATCH 91/99] Correct default_return_url for TagBulkEditView --- netbox/extras/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 40f3466ef..73d29393f 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -85,10 +85,9 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView): ).order_by( 'name' ) - # filter = filters.ProviderFilter table = TagTable form = forms.TagBulkEditForm - default_return_url = 'circuits:provider_list' + default_return_url = 'extras:tag_list' class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): From c3bd1881f527f747b2afb4fd68265b498d0a0c03 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 12:25:20 -0500 Subject: [PATCH 92/99] Correct nullable_fields for ServiceBulkEditForm --- netbox/ipam/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 237ee2238..e166136dd 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1392,5 +1392,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class Meta: nullable_fields = [ - 'site', 'tenant', 'role', 'description', + 'description', ] From 4aa694f04422ebafbf109be5fff6dbe51dd2937b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 13:07:46 -0500 Subject: [PATCH 93/99] Skip non-model fields when applying bulk changes --- netbox/utilities/views.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 88e5005bc..f62ebe6cf 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -4,11 +4,10 @@ from copy import deepcopy from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import Count, ManyToManyField, ProtectedError -from django.db.models.query import QuerySet -from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render from django.template import loader @@ -651,7 +650,7 @@ class BulkEditView(GetReturnURLMixin, View): custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] standard_fields = [ - field for field in form.fields if field not in custom_fields + ['pk', 'add_tags', 'remove_tags'] + field for field in form.fields if field not in custom_fields + ['pk'] ] nullified_fields = request.POST.getlist('_nullify') @@ -665,7 +664,12 @@ class BulkEditView(GetReturnURLMixin, View): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: - model_field = model._meta.get_field(name) + try: + model_field = model._meta.get_field(name) + except FieldDoesNotExist: + # The form field is used to modify a field rather than set its value directly, + # so we skip it. + continue # Handle nullification if name in form.nullable_fields and name in nullified_fields: From d431efb7d433d7ee2ae763b0ae7ed8b07be40fc4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 13:32:53 -0500 Subject: [PATCH 94/99] Add bulk edit view tests --- netbox/circuits/tests/test_views.py | 47 ++++- netbox/dcim/tests/test_views.py | 243 ++++++++++++++++------ netbox/extras/tests/test_views.py | 13 +- netbox/ipam/tests/test_views.py | 140 ++++++++++--- netbox/secrets/tests/test_views.py | 36 +++- netbox/tenancy/tests/test_views.py | 19 +- netbox/utilities/testing/testcases.py | 46 +++- netbox/virtualization/tests/test_views.py | 87 ++++++-- 8 files changed, 491 insertions(+), 140 deletions(-) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 1e065f458..d2cb8e5ab 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -36,6 +36,15 @@ class ProviderTestCase(StandardTestCases.Views): "Provider 6,provider-6", ) + cls.bulk_edit_data = { + 'asn': 65009, + 'account': '5678', + 'portal_url': 'http://example.com/portal2', + 'noc_contact': 'noc2@example.com', + 'admin_contact': 'admin2@example.com', + 'comments': 'New comments', + } + class CircuitTypeTestCase(StandardTestCases.Views): model = CircuitType @@ -43,6 +52,7 @@ class CircuitTypeTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -73,23 +83,29 @@ class CircuitTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - provider = Provider(name='Provider 1', slug='provider-1', asn=65001) - provider.save() + providers = ( + Provider(name='Provider 1', slug='provider-1', asn=65001), + Provider(name='Provider 2', slug='provider-2', asn=65002), + ) + Provider.objects.bulk_create(providers) - circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1') - circuittype.save() + circuittypes = ( + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), + CircuitType(name='Circuit Type 2', slug='circuit-type-2'), + ) + CircuitType.objects.bulk_create(circuittypes) Circuit.objects.bulk_create([ - Circuit(cid='Circuit 1', provider=provider, type=circuittype), - Circuit(cid='Circuit 2', provider=provider, type=circuittype), - Circuit(cid='Circuit 3', provider=provider, type=circuittype), + Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]), + Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]), + Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), ]) cls.form_data = { 'cid': 'Circuit X', - 'provider': provider.pk, - 'type': circuittype.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'provider': providers[1].pk, + 'type': circuittypes[1].pk, + 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, 'tenant': None, 'install_date': datetime.date(2020, 1, 1), 'commit_rate': 1000, @@ -104,3 +120,14 @@ class CircuitTestCase(StandardTestCases.Views): "Circuit 5,Provider 1,Circuit Type 1", "Circuit 6,Provider 1,Circuit Type 1", ) + + cls.bulk_edit_data = { + 'provider': providers[1].pk, + 'type': circuittypes[1].pk, + 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, + 'tenant': None, + 'commit_rate': 2000, + 'description': 'New description', + 'comments': 'New comments', + + } diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 34c0f38b4..ab5400f56 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -19,6 +19,7 @@ class RegionTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -52,20 +53,24 @@ class SiteTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - region = Region(name='Region 1', slug='region-1') - region.save() + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + ) + for region in regions: + region.save() Site.objects.bulk_create([ - Site(name='Site 1', slug='site-1', region=region), - Site(name='Site 2', slug='site-2', region=region), - Site(name='Site 3', slug='site-3', region=region), + Site(name='Site 1', slug='site-1', region=regions[0]), + Site(name='Site 2', slug='site-2', region=regions[0]), + Site(name='Site 3', slug='site-3', region=regions[0]), ]) cls.form_data = { 'name': 'Site X', 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, - 'region': region.pk, + 'region': regions[1].pk, 'tenant': None, 'facility': 'Facility X', 'asn': 65001, @@ -89,6 +94,15 @@ class SiteTestCase(StandardTestCases.Views): "Site 6,site-6", ) + cls.bulk_edit_data = { + 'status': SiteStatusChoices.STATUS_PLANNED, + 'region': regions[1].pk, + 'tenant': None, + 'asn': 65009, + 'time_zone': pytz.timezone('US/Eastern'), + 'description': 'New description', + } + class RackGroupTestCase(StandardTestCases.Views): model = RackGroup @@ -96,6 +110,7 @@ class RackGroupTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -129,6 +144,7 @@ class RackRoleTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -159,32 +175,40 @@ class RackReservationTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None - test_create_object = None # TODO: Fix URL name for view + test_create_object = None + + # TODO: Fix URL name for view test_import_objects = None @classmethod def setUpTestData(cls): - user = User.objects.create_user(username='testuser2') + user2 = User.objects.create_user(username='testuser2') + user3 = User.objects.create_user(username='testuser3') - site = Site(name='Site 1', slug='site-1') - site.save() + site = Site.objects.create(name='Site 1', slug='site-1') rack = Rack(name='Rack 1', site=site) rack.save() RackReservation.objects.bulk_create([ - RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'), - RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'), - RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'), + RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'), + RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'), + RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), ]) cls.form_data = { 'rack': rack.pk, 'units': [10, 11, 12], - 'user': user.pk, + 'user': user3.pk, 'tenant': None, - 'description': 'New reservation', + 'description': 'Rack reservation', + } + + cls.bulk_edit_data = { + 'user': user3.pk, + 'tenant': None, + 'description': 'New description', } @@ -194,24 +218,38 @@ class RackTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') - rackgroup = RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site) - rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) - Rack.objects.bulk_create([ - Rack(name='Rack 1', site=site), - Rack(name='Rack 2', site=site), - Rack(name='Rack 3', site=site), - ]) + rackgroups = ( + RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), + RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]) + ) + RackGroup.objects.bulk_create(rackgroups) + + rackroles = ( + RackRole(name='Rack Role 1', slug='rack-role-1'), + RackRole(name='Rack Role 2', slug='rack-role-2'), + ) + RackRole.objects.bulk_create(rackroles) + + Rack.objects.bulk_create(( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[0]), + Rack(name='Rack 3', site=sites[0]), + )) cls.form_data = { 'name': 'Rack X', 'facility_id': 'Facility X', - 'site': site.pk, - 'group': rackgroup.pk, + 'site': sites[1].pk, + 'group': rackgroups[1].pk, 'tenant': None, 'status': RackStatusChoices.STATUS_PLANNED, - 'role': rackrole.pk, + 'role': rackroles[1].pk, 'serial': '123456', 'asset_tag': 'ABCDEF', 'type': RackTypeChoices.TYPE_CABINET, @@ -232,6 +270,23 @@ class RackTestCase(StandardTestCases.Views): "Site 1,Rack 6,19,42", ) + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'group': rackgroups[1].pk, + 'tenant': None, + 'status': RackStatusChoices.STATUS_DEPRECATED, + 'role': rackroles[1].pk, + 'serial': '654321', + 'type': RackTypeChoices.TYPE_4POST, + 'width': RackWidthChoices.WIDTH_23IN, + 'u_height': 49, + 'desc_units': True, + 'outer_width': 30, + 'outer_depth': 30, + 'outer_unit': RackDimensionUnitChoices.UNIT_INCH, + 'comments': 'New comments', + } + class ManufacturerTestCase(StandardTestCases.Views): model = Manufacturer @@ -239,6 +294,7 @@ class ManufacturerTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -268,17 +324,20 @@ class DeviceTypeTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') - manufacturer.save() + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2') + ) + Manufacturer.objects.bulk_create(manufacturers) DeviceType.objects.bulk_create([ - DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer), - DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer), - DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer), + DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]), + DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]), + DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]), ]) cls.form_data = { - 'manufacturer': manufacturer.pk, + 'manufacturer': manufacturers[1].pk, 'model': 'Device Type X', 'slug': 'device-type-x', 'part_number': '123ABC', @@ -289,6 +348,12 @@ class DeviceTypeTestCase(StandardTestCases.Views): 'tags': 'Alpha,Bravo,Charlie', } + cls.bulk_edit_data = { + 'manufacturer': manufacturers[1].pk, + 'u_height': 3, + 'is_full_depth': False, + } + def test_import_objects(self): """ Custom import test for YAML-based imports (versus CSV) @@ -451,6 +516,7 @@ class DeviceRoleTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -483,6 +549,7 @@ class PlatformTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -517,29 +584,54 @@ class DeviceTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') - rack = Rack.objects.create(name='Rack 1', site=site) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + ) + Rack.objects.bulk_create(racks) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) - devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - platform = Platform.objects.create(name='Platform 1', slug='platform-1') + + devicetypes = ( + DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer), + DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer), + ) + DeviceType.objects.bulk_create(devicetypes) + + deviceroles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + ) + DeviceRole.objects.bulk_create(deviceroles) + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + ) + Platform.objects.bulk_create(platforms) Device.objects.bulk_create([ - Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), - Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), - Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), + Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), + Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]), ]) cls.form_data = { - 'device_type': devicetype.pk, - 'device_role': devicerole.pk, + 'device_type': devicetypes[1].pk, + 'device_role': deviceroles[1].pk, 'tenant': None, - 'platform': platform.pk, + 'platform': platforms[1].pk, 'name': 'Device X', 'serial': '123456', 'asset_tag': 'ABCDEF', - 'site': site.pk, - 'rack': rack.pk, + 'site': sites[1].pk, + 'rack': racks[1].pk, 'position': 1, 'face': DeviceFaceChoices.FACE_FRONT, 'status': DeviceStatusChoices.STATUS_PLANNED, @@ -561,6 +653,15 @@ class DeviceTestCase(StandardTestCases.Views): "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 6", ) + cls.bulk_edit_data = { + 'device_type': devicetypes[1].pk, + 'device_role': deviceroles[1].pk, + 'tenant': None, + 'platform': platforms[1].pk, + 'serial': '123456', + 'status': DeviceStatusChoices.STATUS_DECOMMISSIONING, + } + # TODO: Convert to StandardTestCases.Views class ConsolePortTestCase(TestCase): @@ -1071,28 +1172,28 @@ class CableTestCase(StandardTestCases.Views): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - device1.save() - device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole) - device2.save() - device3 = Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole) - device3.save() - device4 = Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole) - device4.save() + + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole), + ) + Device.objects.bulk_create(devices) interfaces = ( - Interface(device=device1, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device1, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device1, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device2, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device2, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device2, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device3, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device3, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device3, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device4, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device4, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), - Interface(device=device4, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) Interface.objects.bulk_create(interfaces) @@ -1109,7 +1210,7 @@ class CableTestCase(StandardTestCases.Views): 'termination_b_id': interfaces[3].pk, 'type': CableTypeChoices.TYPE_CAT6, 'status': CableStatusChoices.STATUS_PLANNED, - 'label': 'New cable', + 'label': 'Label', 'color': 'c0c0c0', 'length': 100, 'length_unit': CableLengthUnitChoices.UNIT_FOOT, @@ -1122,6 +1223,15 @@ class CableTestCase(StandardTestCases.Views): "Device 3,interface,Interface 3,Device 4,interface,Interface 3", ) + cls.bulk_edit_data = { + 'type': CableTypeChoices.TYPE_CAT5E, + 'status': CableStatusChoices.STATUS_CONNECTED, + 'label': 'New label', + 'color': '00ff00', + 'length': 50, + 'length_unit': CableLengthUnitChoices.UNIT_METER, + } + class VirtualChassisTestCase(StandardTestCases.Views): model = VirtualChassis @@ -1129,6 +1239,7 @@ class VirtualChassisTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_import_objects = None + test_bulk_edit_objects = None test_bulk_delete_objects = None # TODO: Requires special form handling diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 0fc60dcd9..ecb25a78c 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -33,6 +33,10 @@ class TagTestCase(StandardTestCases.Views): 'comments': 'Some comments', } + cls.bulk_edit_data = { + 'color': '00ff00', + } + class ConfigContextTestCase(StandardTestCases.Views): model = ConfigContext @@ -53,7 +57,7 @@ class ConfigContextTestCase(StandardTestCases.Views): for i in range(1, 4): configcontext = ConfigContext( name='Config Context {}'.format(i), - data='{{"foo": {}}}'.format(i) + data={'foo': i} ) configcontext.save() configcontext.sites.add(site) @@ -73,7 +77,14 @@ class ConfigContextTestCase(StandardTestCases.Views): 'data': '{"foo": 123}', } + cls.bulk_edit_data = { + 'weight': 300, + 'is_active': False, + 'description': 'New description', + } + +# TODO: Convert to StandardTestCases.Views class ObjectChangeTestCase(TestCase): user_permissions = ( 'extras.view_objectchange', diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index db8326fbd..cfa06788c 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -36,6 +36,12 @@ class VRFTestCase(StandardTestCases.Views): "VRF 6", ) + cls.bulk_edit_data = { + 'tenant': None, + 'enforce_unique': False, + 'description': 'New description', + } + class RIRTestCase(StandardTestCases.Views): model = RIR @@ -43,6 +49,7 @@ class RIRTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -73,18 +80,22 @@ class AggregateTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - rir = RIR.objects.create(name='RIR 1', slug='rir-1') + rirs = ( + RIR(name='RIR 1', slug='rir-1'), + RIR(name='RIR 2', slug='rir-2'), + ) + RIR.objects.bulk_create(rirs) Aggregate.objects.bulk_create([ - Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir), - Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir), - Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir), + Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]), + Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]), + Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]), ]) cls.form_data = { 'family': 4, 'prefix': IPNetwork('10.99.0.0/16'), - 'rir': rir.pk, + 'rir': rirs[1].pk, 'date_added': datetime.date(2020, 1, 1), 'description': 'A new aggregate', 'tags': 'Alpha,Bravo,Charlie', @@ -97,6 +108,12 @@ class AggregateTestCase(StandardTestCases.Views): "10.6.0.0/16,RIR 1", ) + cls.bulk_edit_data = { + 'rir': rirs[1].pk, + 'date_added': datetime.date(2020, 1, 1), + 'description': 'New description', + } + class RoleTestCase(StandardTestCases.Views): model = Role @@ -104,6 +121,7 @@ class RoleTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -135,25 +153,37 @@ class PrefixTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') - vrf = VRF.objects.create(name='VRF 1', rd='65000:1') - role = Role.objects.create(name='Role 1', slug='role-1') - # vlan = VLAN.objects.create(vid=123, name='VLAN 123') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + ) + VRF.objects.bulk_create(vrfs) + + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) Prefix.objects.bulk_create([ - Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site), - Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site), - Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site), + Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), + Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), + Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), ]) cls.form_data = { 'prefix': IPNetwork('192.0.2.0/24'), - 'site': site.pk, - 'vrf': vrf.pk, + 'site': sites[1].pk, + 'vrf': vrfs[1].pk, 'tenant': None, 'vlan': None, 'status': PrefixStatusChoices.STATUS_RESERVED, - 'role': role.pk, + 'role': roles[1].pk, 'is_pool': True, 'description': 'A new prefix', 'tags': 'Alpha,Bravo,Charlie', @@ -166,6 +196,16 @@ class PrefixTestCase(StandardTestCases.Views): "10.6.0.0/16,Active", ) + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'vrf': vrfs[1].pk, + 'tenant': None, + 'status': PrefixStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'is_pool': False, + 'description': 'New description', + } + class IPAddressTestCase(StandardTestCases.Views): model = IPAddress @@ -173,16 +213,19 @@ class IPAddressTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - vrf = VRF.objects.create(name='VRF 1', rd='65000:1') + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + ) IPAddress.objects.bulk_create([ - IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf), - IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrf), - IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf), + IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]), + IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]), + IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]), ]) cls.form_data = { - 'vrf': vrf.pk, + 'vrf': vrfs[1].pk, 'address': IPNetwork('192.0.2.99/24'), 'tenant': None, 'status': IPAddressStatusChoices.STATUS_RESERVED, @@ -201,6 +244,15 @@ class IPAddressTestCase(StandardTestCases.Views): "192.0.2.6/24,Active", ) + cls.bulk_edit_data = { + 'vrf': vrfs[1].pk, + 'tenant': None, + 'status': IPAddressStatusChoices.STATUS_RESERVED, + 'role': IPAddressRoleChoices.ROLE_ANYCAST, + 'dns_name': 'example', + 'description': 'New description', + } + class VLANGroupTestCase(StandardTestCases.Views): model = VLANGroup @@ -208,6 +260,7 @@ class VLANGroupTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -240,24 +293,38 @@ class VLANTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') - vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1', site=site) - role = Role.objects.create(name='Role 1', slug='role-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + vlangroups = ( + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), + ) + VLANGroup.objects.bulk_create(vlangroups) + + roles = ( + Role(name='Role 1', slug='role-1'), + Role(name='Role 2', slug='role-2'), + ) + Role.objects.bulk_create(roles) VLAN.objects.bulk_create([ - VLAN(group=vlangroup, vid=101, name='VLAN101'), - VLAN(group=vlangroup, vid=102, name='VLAN102'), - VLAN(group=vlangroup, vid=103, name='VLAN103'), + VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]), + VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]), + VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]), ]) cls.form_data = { - 'site': site.pk, - 'group': vlangroup.pk, + 'site': sites[1].pk, + 'group': vlangroups[1].pk, 'vid': 999, 'name': 'VLAN999', 'tenant': None, 'status': VLANStatusChoices.STATUS_RESERVED, - 'role': role.pk, + 'role': roles[1].pk, 'description': 'A new VLAN', 'tags': 'Alpha,Bravo,Charlie', } @@ -269,6 +336,15 @@ class VLANTestCase(StandardTestCases.Views): "106,VLAN106,Active", ) + cls.bulk_edit_data = { + 'site': sites[1].pk, + 'group': vlangroups[1].pk, + 'tenant': None, + 'status': VLANStatusChoices.STATUS_RESERVED, + 'role': roles[1].pk, + 'description': 'New description', + } + class ServiceTestCase(StandardTestCases.Views): model = Service @@ -304,3 +380,9 @@ class ServiceTestCase(StandardTestCases.Views): 'description': 'A new service', 'tags': 'Alpha,Bravo,Charlie', } + + cls.bulk_edit_data = { + 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, + 'port': 888, + 'description': 'New description', + } diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 1da689e53..94f4cbd6a 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -14,6 +14,7 @@ class SecretRoleTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -56,21 +57,38 @@ class SecretTestCase(StandardTestCases.Views): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) - secretrole = SecretRole.objects.create(name='Secret Role 1', slug='secret-role-1') - Secret.objects.bulk_create([ - Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'), - Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'), - Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'), - ]) + devices = ( + Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole), + Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole), + ) + Device.objects.bulk_create(devices) + + secretroles = ( + SecretRole(name='Secret Role 1', slug='secret-role-1'), + SecretRole(name='Secret Role 2', slug='secret-role-2'), + ) + SecretRole.objects.bulk_create(secretroles) + + # Create one secret per device to allow bulk-editing of names (which must be unique per device/role) + Secret.objects.bulk_create(( + Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'), + Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'), + Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'), + )) cls.form_data = { - 'device': device.pk, - 'role': secretrole.pk, + 'device': devices[1].pk, + 'role': secretroles[1].pk, 'name': 'Secret X', } + cls.bulk_edit_data = { + 'role': secretroles[1].pk, + 'name': 'New name', + } + def setUp(self): super().setUp() diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 1825a4ff9..a44ca2932 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -8,6 +8,7 @@ class TenantGroupTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -37,18 +38,22 @@ class TenantTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - tenantgroup = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1') + tenantgroups = ( + TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + ) + TenantGroup.objects.bulk_create(tenantgroups) Tenant.objects.bulk_create([ - Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroup), - Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroup), - Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroup), + Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroups[0]), + Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroups[0]), + Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroups[0]), ]) cls.form_data = { 'name': 'Tenant X', 'slug': 'tenant-x', - 'group': tenantgroup.pk, + 'group': tenantgroups[1].pk, 'description': 'A new tenant', 'comments': 'Some comments', 'tags': 'Alpha,Bravo,Charlie', @@ -60,3 +65,7 @@ class TenantTestCase(StandardTestCases.Views): "Tenant 5,tenant-5", "Tenant 6,tenant-6", ) + + cls.bulk_edit_data = { + 'group': tenantgroups[1].pk, + } diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index ff4353962..b55d914d9 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -85,8 +85,15 @@ class StandardTestCases: - Import multiple new objects """ model = None + + # Data to be sent when creating/editing individual objects form_data = {} - csv_data = {} + + # CSV lines used for bulk import of new objects + csv_data = () + + # Form data to be used when editing multiple objects at once + bulk_edit_data = {} maxDiff = None @@ -107,7 +114,7 @@ class StandardTestCases: self.model._meta.model_name ) - if action in ('list', 'add', 'import', 'bulk_delete'): + if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'): return reverse(url_format.format(action)) elif action in ('get', 'edit', 'delete'): @@ -254,6 +261,41 @@ class StandardTestCases: self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def test_bulk_edit_objects(self): + pk_list = self.model.objects.values_list('pk', flat=True) + + request = { + 'path': self._get_url('bulk_edit'), + 'data': { + 'pk': pk_list, + '_apply': True, # Form button + }, + 'follow': False, # Do not follow 302 redirects + } + + # Append the form data to the request + request['data'].update(post_data(self.bulk_edit_data)) + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + bulk_edit_fields = self.bulk_edit_data.keys() + for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)): + self.assertDictEqual( + model_to_dict(instance, fields=bulk_edit_fields), + self.bulk_edit_data, + msg="Instance {} failed to validate after bulk edit: {}".format(i, instance) + ) + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_bulk_delete_objects(self): pk_list = self.model.objects.values_list('pk', flat=True) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index ed065678b..77f87c92a 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -10,6 +10,7 @@ class ClusterGroupTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -39,6 +40,7 @@ class ClusterTypeTestCase(StandardTestCases.Views): # Disable inapplicable tests test_get_object = None test_delete_object = None + test_bulk_edit_objects = None @classmethod def setUpTestData(cls): @@ -68,22 +70,36 @@ class ClusterTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') - clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') - clustertype = 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) + + clustergroups = ( + ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), + ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'), + ) + ClusterGroup.objects.bulk_create(clustergroups) + + clustertypes = ( + ClusterType(name='Cluster Type 1', slug='cluster-type-1'), + ClusterType(name='Cluster Type 2', slug='cluster-type-2'), + ) + ClusterType.objects.bulk_create(clustertypes) Cluster.objects.bulk_create([ - Cluster(name='Cluster 1', group=clustergroup, type=clustertype), - Cluster(name='Cluster 2', group=clustergroup, type=clustertype), - Cluster(name='Cluster 3', group=clustergroup, type=clustertype), + 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]), ]) cls.form_data = { 'name': 'Cluster X', - 'group': clustergroup.pk, - 'type': clustertype.pk, + 'group': clustergroups[1].pk, + 'type': clustertypes[1].pk, 'tenant': None, - 'site': site.pk, + 'site': sites[1].pk, 'comments': 'Some comments', 'tags': 'Alpha,Bravo,Charlie', } @@ -95,6 +111,14 @@ class ClusterTestCase(StandardTestCases.Views): "Cluster 6,Cluster Type 1", ) + cls.bulk_edit_data = { + 'group': clustergroups[1].pk, + 'type': clustertypes[1].pk, + 'tenant': None, + 'site': sites[1].pk, + 'comments': 'New comments', + } + class VirtualMachineTestCase(StandardTestCases.Views): model = VirtualMachine @@ -102,24 +126,39 @@ class VirtualMachineTestCase(StandardTestCases.Views): @classmethod def setUpTestData(cls): - devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') - platform = Platform.objects.create(name='Platform 1', slug='platform-1') + deviceroles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + ) + DeviceRole.objects.bulk_create(deviceroles) + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + ) + Platform.objects.bulk_create(platforms) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') - cluster = Cluster.objects.create(name='Cluster 1', type=clustertype) + + clusters = ( + Cluster(name='Cluster 1', type=clustertype), + Cluster(name='Cluster 2', type=clustertype), + ) + Cluster.objects.bulk_create(clusters) VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=cluster), - VirtualMachine(name='Virtual Machine 2', cluster=cluster), - VirtualMachine(name='Virtual Machine 3', cluster=cluster), + 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]), ]) cls.form_data = { - 'cluster': cluster.pk, + 'cluster': clusters[1].pk, 'tenant': None, - 'platform': None, + 'platform': platforms[1].pk, 'name': 'Virtual Machine X', 'status': VirtualMachineStatusChoices.STATUS_STAGED, - 'role': devicerole.pk, + 'role': deviceroles[1].pk, 'primary_ip4': None, 'primary_ip6': None, 'vcpus': 4, @@ -136,3 +175,15 @@ class VirtualMachineTestCase(StandardTestCases.Views): "Virtual Machine 5,Cluster 1", "Virtual Machine 6,Cluster 1", ) + + cls.bulk_edit_data = { + 'cluster': clusters[1].pk, + 'tenant': None, + 'platform': platforms[1].pk, + 'status': VirtualMachineStatusChoices.STATUS_STAGED, + 'role': deviceroles[1].pk, + 'vcpus': 8, + 'memory': 65535, + 'disk': 8000, + 'comments': 'New comments', + } From 7b4f3e82615db94bef6421155cde965ead30e8f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 14:24:32 -0500 Subject: [PATCH 95/99] Correct view for PowerFeed creation URL --- netbox/dcim/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 834a6070f..38e24308b 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -318,7 +318,7 @@ urlpatterns = [ # Power feeds path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), - path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), + path(r'power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'), path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), From 3f13441a5d6af34c28d2b4698cc08cbe73753afa Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 14:25:06 -0500 Subject: [PATCH 96/99] Add view tests for power panels and power feeds --- netbox/dcim/tests/test_views.py | 111 ++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index ab5400f56..9dd5c6114 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1285,3 +1285,114 @@ class VirtualChassisTestCase(StandardTestCases.Views): Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2) vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3') Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) + + +class PowerPanelTestCase(StandardTestCases.Views): + model = PowerPanel + + # Disable inapplicable tests + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + rackgroups = ( + RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), + RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), + ) + RackGroup.objects.bulk_create(rackgroups) + + PowerPanel.objects.bulk_create(( + PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'), + PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'), + PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'), + )) + + cls.form_data = { + 'site': sites[1].pk, + 'rack_group': rackgroups[1].pk, + 'name': 'Power Panel X', + } + + cls.csv_data = ( + "site,rack_group_name,name", + "Site 1,Rack Group 1,Power Panel 4", + "Site 1,Rack Group 1,Power Panel 5", + "Site 1,Rack Group 1,Power Panel 6", + ) + + +class PowerFeedTestCase(StandardTestCases.Views): + model = PowerFeed + + # TODO: Re-enable this test once #4079 is fixed + test_bulk_edit_objects = None + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1', slug='site-1') + + powerpanels = ( + PowerPanel(site=site, name='Power Panel 1'), + PowerPanel(site=site, name='Power Panel 2'), + ) + PowerPanel.objects.bulk_create(powerpanels) + + racks = ( + Rack(site=site, name='Rack 1'), + Rack(site=site, name='Rack 2'), + ) + Rack.objects.bulk_create(racks) + + PowerFeed.objects.bulk_create(( + PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]), + PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]), + PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), + )) + + cls.form_data = { + 'name': 'Power Feed X', + 'power_panel': powerpanels[1].pk, + 'rack': racks[1].pk, + 'status': PowerFeedStatusChoices.STATUS_PLANNED, + 'type': PowerFeedTypeChoices.TYPE_REDUNDANT, + 'supply': PowerFeedSupplyChoices.SUPPLY_DC, + 'phase': PowerFeedPhaseChoices.PHASE_3PHASE, + 'voltage': 100, + 'amperage': 100, + 'max_utilization': 50, + 'comments': 'New comments', + 'tags': 'Alpha,Bravo,Charlie', + + # Connection + 'cable': None, + 'connected_endpoint': None, + 'connection_status': None, + } + + cls.csv_data = ( + "site,panel_name,name,voltage,amperage,max_utilization", + "Site 1,Power Panel 1,Power Feed 4,120,20,80", + "Site 1,Power Panel 1,Power Feed 5,120,20,80", + "Site 1,Power Panel 1,Power Feed 6,120,20,80", + ) + + cls.bulk_edit_data = { + 'power_panel': powerpanels[1].pk, + 'rack': racks[1].pk, + 'status': PowerFeedStatusChoices.STATUS_PLANNED, + 'type': PowerFeedTypeChoices.TYPE_REDUNDANT, + 'supply': PowerFeedSupplyChoices.SUPPLY_DC, + 'phase': PowerFeedPhaseChoices.PHASE_3PHASE, + 'voltage': 100, + 'amperage': 100, + 'max_utilization': 50, + 'comments': 'New comments', + } From eef79e1443bc4fd37f501ac2a1b200499388d317 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 14:34:47 -0500 Subject: [PATCH 97/99] Fixes #4079: Fix assignment of power panel when bulk editing power feeds --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 2 +- netbox/dcim/tests/test_views.py | 3 --- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index a58c7e9f1..142c11244 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -14,6 +14,7 @@ * [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569) * [#4067](https://github.com/netbox-community/netbox/issues/4067) - Correct permission checked when creating a rack (vs. editing) * [#4071](https://github.com/netbox-community/netbox/issues/4071) - Enforce "view tag" permission on individual tag view +* [#4079](https://github.com/netbox-community/netbox/issues/4079) - Fix assignment of power panel when bulk editing power feeds --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index dbb3a3bb4..8619d6844 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4371,7 +4371,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput ) - powerpanel = forms.ModelChoiceField( + power_panel = forms.ModelChoiceField( queryset=PowerPanel.objects.all(), required=False, widget=APISelect( diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 9dd5c6114..32789accd 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1331,9 +1331,6 @@ class PowerPanelTestCase(StandardTestCases.Views): class PowerFeedTestCase(StandardTestCases.Views): model = PowerFeed - # TODO: Re-enable this test once #4079 is fixed - test_bulk_edit_objects = None - @classmethod def setUpTestData(cls): From 0a87df48ab37266431b74c1ab55d361c5960378e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 14:45:36 -0500 Subject: [PATCH 98/99] Update GitHub issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 8 +++++--- .github/ISSUE_TEMPLATE/documentation_change.md | 10 ++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 8 +++++--- .github/ISSUE_TEMPLATE/housekeeping.md | 9 ++++----- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b01cb6dc8..38a6fd550 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,7 +5,9 @@ about: Report a reproducible bug in the current release of NetBox --- ### Environment -* Python version: -* NetBox version: +* Python version: +* NetBox version: @@ -14,5 +16,13 @@ about: Suggest an addition or modification to the NetBox documentation [ ] Deprecation [ ] Cleanup (formatting, typos, etc.) +### Area +[ ] Installation instructions +[ ] Configuration parameters +[ ] Functionality/features +[ ] REST API +[ ] Administration/development +[ ] Other + ### Proposed Changes diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index ebe19d811..2f742d416 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -5,7 +5,9 @@ about: Propose a new NetBox feature or enhancement --- ### Environment -* Python version: -* NetBox version: +* Python version: +* NetBox version: ### Proposed Changes From b0f7feefa8453338571ccd9546eb0a868f11ce52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 3 Feb 2020 16:04:25 -0500 Subject: [PATCH 99/99] Changelog for #3886 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 142c11244..46c2a5a83 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -25,6 +25,7 @@ * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable * [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts +* [#3886](https://github.com/netbox-community/netbox/issues/3886) - Enable assigning config contexts by cluster and cluster group * [#3978](https://github.com/netbox-community/netbox/issues/3978) - Add VRF filtering to search NAT IP * [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps