From 1a8a16c32c95d8265b9306cad4c7202d523f327d 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 7b6b4f6e74dc255441db6ffc635396e2242f6fb8 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 46ed7418fc59e529eb324b98b43d5bedbe98ff76 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 b5441ea0c8de8df455a04ebf89e6ab9e56f863b5 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 9e4d087ebaeccd3c0bcfa1c4e825406dd01cf61d 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 9148608af44bb6b1ad69717d5afaf78cf7e09660 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 d4ce046afd6485f5580118bfc20de8723c8617f2 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 a802846cb7749bb24fe59b601bebd0d14190b07a 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 017c92aae9a9517819499a95c5bbd3774c6b1a25 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 16f72769b7c840b7a2b78616ce483eeeb386f471 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 39e0c1f8f97336c5b23ecb9e1f1fbb73e69608e7 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 0d2d0e857b829c62b31a441da455660cb0f12be9 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 8440419ebeb677d996b4215d3445f66fc0045111 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 561df015ea9840112b29d38c10d6fedf5ce0e27d 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 bb99342d1af8932bc010f83bf0885b4b75e8da25 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 8fb8cc01bfbe0ea0040d010cbbdbbf7e6ed71aab 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 ef6dc73a1b530ff782e89920fe0539402c8858c0 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 d98b4ec74b63b619ca9cc572ab83402fa85ecce1 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 d841c8c2d8346b541cc25fab216775f871f0ea13 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 b2cbbaf574a0122800340d1fb1f471042df5ecf3 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 c92bb4c1bf29c6bf8273cb4fb74b55e7fe229e1a 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 237518a17cac388172ca186d537955336a6c4b8d 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 0406c0c3eb4fd18a2993a1e342344e96ab0eb23d 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 eb4abc6b4c92e7a286cce1a1d067baa70181e5fa 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 5cc81d6a46d1d270969b91b28fa683b3855845f5 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 7486103ca7f81dc0c87754fd4b1650b4d4d5b61f 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 a85df418deed0aefe79b146c6ff34f8659b34387 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 ac3cac1b1cf74e023fd1f86385680ff1e55ed3f2 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 7cba8a6c107339b5ddbba7a844b0054cd003c0c2 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 0fa1701905aa59ebc8c2ca4936b351639a47f561 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 0f8534e65e5f09e85cf1d0f943a97ff18cbade2a 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 81803a55465eb0ab303dda916ada8a54072da4c3 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 4045630c2f4f4a5464c43e98eba2ec5f8ec59d3c 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 ce5620f494748c7bad9192a38ac2497199759342 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 061e4da46caaee3a696847de194b5b2b4d18d4b6 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 c995a478dca6d5e2d9500ef6ce66020f67eba9cf 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 ee3a64f5ca8a4d772e900d759a93135447b208e5 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 96540c8c64f4e7eb359ddedba98f026a231724fb 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 bbca6de77ff6c1f2993b00f38693be16a187342b 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 0acc59db0cd98d330769c762dc43cce13c4b450e 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 098f47462c0d2d3366b499b9ac64b4bc7cc28a9e 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 47a857f472055020f5f402dd0adf15a436a39495 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 2054cd4e2f6587bb9cf49755ac3db8404649dcb3 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 aa958955ae974d76616743fa21c711e2ad443b4f 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 011256feeefd7e3fb1ec75d7629b8f7cbbc549a7 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 d1b5e1a04d002757d6258e39fe51f945a4067d84 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 447240c8017bcfcf8b3b07a2d8451f8105e07ec6 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 730eb47f252888188b550f4eba4d01037d71df6f 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 10d3f71c9397438d9b47c026dd940cc5789bb1ce 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 d473c4e682793b0b6b335270abfeda028a736d23 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 1fbf137ccf644e1bbe6f4422d9079810a7869d93 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 6e43e0fd490c6b65c30698eaa6744e9443bd9ae8 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 767ac3476fc51e38426835fbb9ff51b409a1334e 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 afc8aa0bc50b0503214a496f8dda43d6ebb32c23 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 50bed647916847de60ba88559a1b8fc8b06d4cd1 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 3846416492e6c9659367da5d01810c61445ce542 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 a9a622449f3198dabdcad6cfb710f2e353d5e97d 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 a57c38246ad3e960ddd44eb12682c710c707227c 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 2fea7657e6ed40c353886c6a3ac3e8c83f5f19c4 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 eb69ea52f98a0ce0a2efcc5fa79d383ed1b246fb 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 965a807581e9ce27187292a565640ec79394fc06 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 c40bc53e800fa57c031848ee324e7f4a4efa8f03 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 22bd2b731a3907dcb967e99bdf2eb99ab244a915 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 bc590633b480faf379ded089164ab4dd6ae0678c 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 b1a38fe0da19fde4f7e80bdbd4c5eee53c7c6751 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 8aa6a9bf99c68dbaf18144dcf27685849ab75e83 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 da1ca77c345eace45bfdf2e0222eba0da01019c1 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 4691e70ddfa92d720e21f9942f11efd596b0cefb 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 52c22d5f56ca164cabc2a299ff74c4976d6ef082 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 bae3c83a5bb7b0367f0e453dec1d1dd5df2c82a8 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 8d41faebf4eaabf1f32b5d6191421cfb30d8224a 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 ce9ee8075301d1cfb8b6da350960ee14fce1280a 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 b3ff757fef44522701917ba1e0bdb1aff7ad3814 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 acda39814bbf1ee46b570721ae16e9bf9511765e 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 c95b786d98648260aa725f05eeb80366a8d63c2c 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 cc9deb1220acc1b5642290cb02952d0176e7eba9 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 8224b891abd69a988b8da53ad6bdf41da6a253ef 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 a0b575e3107a07b7edf4ee8f3bfec1d185fe3dbe 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 6093445d105506f2015d6182039713f8beee1730 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 00da429ab948682170ba72e57e5a9455efbfdf20 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 1cee09484060d91c81c69e8741a0e7c3dfd40d8b 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 1965156d9014b371e18f9801f75e272a229be043 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 256058497077866dd47b80a0e1077769349a8830 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 706dd8ca34cc0023e237298ecd19bf5fe6f7b90c 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 634d3f8a5dc95768c630c92b160c53bac6e9384e 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 a7b8f9b5d3f88c26b27a43e90f686b01764af64d 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 99a35b91284886dcfed8380ea449d1dcddaa6999 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 3c0870664edc06420b8964a79b4f073b6d52ef92 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 76157d68c1b11c1ed9af4132369e5e196cbf7a56 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 acdbb126f1b36bea64b06b5062102a218f156929 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 20c6b6fdfbe046fd1100ce67b1e07285f696dc2f 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 8a374b16b5e9515f85cb4c74c92116187c9b2af6 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 405f00154c772c99785db6ee8195eda043e32c8c 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 5e1a14c31449a237fec84d4b0bb33d20d9e5b850 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 701d05c903410f56a93ceacfd6a0efe7a364b3d3 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 652541bf421a15f4e4393b4a3472797e94db139d 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 4c8fed4259e9fc45adf904890dfcfa40b6f3fd8e 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 c3a3546c613d58dd75897d27c2c544f439bef3fc 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 9ee8ba1e2c5adab51985944400fd3fc3531c0256 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