diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index c6f0dfdc4..427dc2e89 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -8,9 +8,9 @@ from extras.forms import ( from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, + CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -55,12 +55,6 @@ class ProviderCSVForm(CustomFieldModelCSVForm): class Meta: model = Provider fields = Provider.csv_headers - help_texts = { - 'name': 'Provider name', - 'asn': '32-bit autonomous system number', - 'portal_url': 'Portal URL', - 'comments': 'Free-form comments', - } class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -148,7 +142,7 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): ] -class CircuitTypeCSVForm(forms.ModelForm): +class CircuitTypeCSVForm(CSVModelForm): slug = SlugField() class Meta: @@ -192,35 +186,26 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class CircuitCSVForm(CustomFieldModelCSVForm): - provider = forms.ModelChoiceField( + provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', - help_text='Name of parent provider', - error_messages={ - 'invalid_choice': 'Provider not found.' - } + help_text='Assigned provider' ) - type = forms.ModelChoiceField( + type = CSVModelChoiceField( queryset=CircuitType.objects.all(), to_field_name='name', - help_text='Type of circuit', - error_messages={ - 'invalid_choice': 'Invalid circuit type.' - } + help_text='Type of circuit' ) status = CSVChoiceField( choices=CircuitStatusChoices, required=False, help_text='Operational status' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.' - } + help_text='Assigned tenant' ) class Meta: diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index e9e8f8aa1..57d41a994 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -38,7 +38,8 @@ class Provider(ChangeLoggedModel, CustomFieldModel): asn = ASNField( blank=True, null=True, - verbose_name='ASN' + verbose_name='ASN', + help_text='32-bit autonomous system number' ) account = models.CharField( max_length=30, @@ -47,7 +48,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) portal_url = models.URLField( blank=True, - verbose_name='Portal' + verbose_name='Portal URL' ) noc_contact = models.TextField( blank=True, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 98b321b90..ef6f222a9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist +from django.utils.safestring import mark_safe from mptt.forms import TreeNodeChoiceField from netaddr import EUI from netaddr.core import AddrFormatError @@ -22,9 +23,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, form_from_model, JSONField, - SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, + CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, + JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine @@ -192,24 +193,17 @@ class RegionForm(BootstrapMixin, forms.ModelForm): ) -class RegionCSVForm(forms.ModelForm): - parent = forms.ModelChoiceField( +class RegionCSVForm(CSVModelForm): + parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Name of parent region', - error_messages={ - 'invalid_choice': 'Region not found.', - } + help_text='Name of parent region' ) class Meta: model = Region fields = Region.csv_headers - help_texts = { - 'name': 'Region name', - 'slug': 'URL-friendly slug', - } class RegionFilterForm(BootstrapMixin, forms.Form): @@ -276,32 +270,26 @@ class SiteCSVForm(CustomFieldModelCSVForm): required=False, help_text='Operational status' ) - region = forms.ModelChoiceField( + region = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned region', - error_messages={ - 'invalid_choice': 'Region not found.', - } + help_text='Assigned region' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) class Meta: model = Site fields = Site.csv_headers help_texts = { - 'name': 'Site name', - 'slug': 'URL-friendly slug', - 'asn': '32-bit autonomous system number', + 'time_zone': mark_safe( + 'Time zone (available options)' + ) } @@ -391,20 +379,17 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): ) -class RackGroupCSVForm(forms.ModelForm): - site = forms.ModelChoiceField( +class RackGroupCSVForm(CSVModelForm): + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + help_text='Assigned site' ) - parent = forms.ModelChoiceField( + parent = CSVModelChoiceField( queryset=RackGroup.objects.all(), required=False, to_field_name='name', - help_text='Name of parent rack group', + help_text='Parent rack group', error_messages={ 'invalid_choice': 'Rack group not found.', } @@ -413,10 +398,6 @@ class RackGroupCSVForm(forms.ModelForm): class Meta: model = RackGroup fields = RackGroup.csv_headers - help_texts = { - 'name': 'Name of rack group', - 'slug': 'URL-friendly slug', - } class RackGroupFilterForm(BootstrapMixin, forms.Form): @@ -468,15 +449,14 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(forms.ModelForm): +class RackRoleCSVForm(CSVModelForm): slug = SlugField() class Meta: model = RackRole fields = RackRole.csv_headers help_texts = { - 'name': 'Name of rack role', - 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -527,40 +507,31 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RackCSVForm(CustomFieldModelCSVForm): - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), - to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + to_field_name='name' ) - group_name = forms.CharField( - help_text='Name of rack group', - required=False + group = CSVModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + to_field_name='name' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Name of assigned tenant' ) status = CSVChoiceField( choices=RackStatusChoices, required=False, help_text='Operational status' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=RackRole.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned role', - error_messages={ - 'invalid_choice': 'Role not found.', - } + help_text='Name of assigned role' ) type = CSVChoiceField( choices=RackTypeChoices, @@ -580,38 +551,15 @@ class RackCSVForm(CustomFieldModelCSVForm): class Meta: model = Rack fields = Rack.csv_headers - help_texts = { - 'name': 'Rack name', - 'u_height': 'Height in rack units', - } - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - group_name = self.cleaned_data.get('group_name') - name = self.cleaned_data.get('name') - facility_id = self.cleaned_data.get('facility_id') - - # Validate rack group - if group_name: - try: - self.instance.group = RackGroup.objects.get(site=site, name=group_name) - except RackGroup.DoesNotExist: - raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) - - # Validate uniqueness of rack name within group - if Rack.objects.filter(group=self.instance.group, name=name).exists(): - raise forms.ValidationError( - "A rack named {} already exists within group {}".format(name, group_name) - ) - - # Validate uniqueness of facility ID within group - if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists(): - raise forms.ValidationError( - "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name) - ) + # Limit group queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -828,62 +776,54 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): return unit_choices -class RackReservationCSVForm(forms.ModelForm): - site = forms.ModelChoiceField( +class RackReservationCSVForm(CSVModelForm): + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Invalid site name.', - } + help_text='Parent site' ) - rack_group = forms.CharField( + rack_group = CSVModelChoiceField( + queryset=RackGroup.objects.all(), + to_field_name='name', required=False, help_text="Rack's group (if any)" ) - rack_name = forms.CharField( - help_text="Rack name" + rack = CSVModelChoiceField( + queryset=Rack.objects.all(), + to_field_name='name', + help_text='Rack' ) units = SimpleArrayField( base_field=forms.IntegerField(), required=True, help_text='Comma-separated list of individual unit numbers' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) class Meta: model = RackReservation - fields = ('site', 'rack_group', 'rack_name', 'units', 'tenant', 'description') - help_texts = { - } + fields = ('site', 'rack_group', 'rack', 'units', 'tenant', 'description') - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - rack_group = self.cleaned_data.get('rack_group') - rack_name = self.cleaned_data.get('rack_name') + # Limit rack_group queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) - # Validate rack - if site and rack_group and rack_name: - try: - self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name) - except Rack.DoesNotExist: - raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group)) - elif site and rack_name: - try: - self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name) - except Rack.DoesNotExist: - raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site)) + # Limit rack queryset by assigned site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + } + self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): @@ -949,15 +889,11 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): ] -class ManufacturerCSVForm(forms.ModelForm): +class ManufacturerCSVForm(CSVModelForm): class Meta: model = Manufacturer fields = Manufacturer.csv_headers - help_texts = { - 'name': 'Manufacturer name', - 'slug': 'URL-friendly slug', - } # @@ -1668,15 +1604,14 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): ] -class DeviceRoleCSVForm(forms.ModelForm): +class DeviceRoleCSVForm(CSVModelForm): slug = SlugField() class Meta: model = DeviceRole fields = DeviceRole.csv_headers help_texts = { - 'name': 'Name of device role', - 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } @@ -1703,24 +1638,18 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): } -class PlatformCSVForm(forms.ModelForm): +class PlatformCSVForm(CSVModelForm): slug = SlugField() - manufacturer = forms.ModelChoiceField( + manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), required=False, to_field_name='name', - help_text='Manufacturer name', - error_messages={ - 'invalid_choice': 'Manufacturer not found.', - } + help_text='Limit platform assignments to this manufacturer' ) class Meta: model = Platform fields = Platform.csv_headers - help_texts = { - 'name': 'Platform name', - } # @@ -1922,173 +1851,131 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class BaseDeviceCSVForm(CustomFieldModelCSVForm): - device_role = forms.ModelChoiceField( + device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', - help_text='Name of assigned role', - error_messages={ - 'invalid_choice': 'Invalid device role.', - } + help_text='Assigned role' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) - manufacturer = forms.ModelChoiceField( + manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', - help_text='Device type manufacturer', - error_messages={ - 'invalid_choice': 'Invalid manufacturer.', - } + help_text='Device type manufacturer' ) - model_name = forms.CharField( - help_text='Device type model name' + device_type = CSVModelChoiceField( + queryset=DeviceType.objects.all(), + to_field_name='model', + help_text='Device type model' ) - platform = forms.ModelChoiceField( + platform = CSVModelChoiceField( queryset=Platform.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned platform', - error_messages={ - 'invalid_choice': 'Invalid platform.', - } + help_text='Assigned platform' ) status = CSVChoiceField( choices=DeviceStatusChoices, help_text='Operational status' ) + cluster = CSVModelChoiceField( + queryset=Cluster.objects.all(), + to_field_name='name', + required=False, + help_text='Virtualization cluster' + ) class Meta: fields = [] model = Device - help_texts = { - 'name': 'Device name', - } - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - manufacturer = self.cleaned_data.get('manufacturer') - model_name = self.cleaned_data.get('model_name') - - # Validate device type - if manufacturer and model_name: - try: - self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name) - except DeviceType.DoesNotExist: - raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name)) + # Limit device type queryset by manufacturer + params = {f"manufacturer__{self.fields['manufacturer'].to_field_name}": data.get('manufacturer')} + self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params) class DeviceCSVForm(BaseDeviceCSVForm): - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Invalid site name.', - } + help_text='Assigned site' ) - rack_group = forms.CharField( + rack_group = CSVModelChoiceField( + queryset=RackGroup.objects.all(), + to_field_name='name', required=False, - help_text='Parent rack\'s group (if any)' + help_text="Rack's group (if any)" ) - rack_name = forms.CharField( + rack = CSVModelChoiceField( + queryset=Rack.objects.all(), + to_field_name='name', required=False, - help_text='Name of parent rack' + help_text="Assigned rack" ) face = CSVChoiceField( choices=DeviceFaceChoices, required=False, help_text='Mounted rack face' ) - cluster = forms.ModelChoiceField( - queryset=Cluster.objects.all(), - to_field_name='name', - required=False, - help_text='Virtualization cluster', - error_messages={ - 'invalid_choice': 'Invalid cluster name.', - } - ) class Meta(BaseDeviceCSVForm.Meta): fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments', + 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', + 'site', 'rack_group', 'rack', 'position', 'face', 'cluster', 'comments', ] - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - rack_group = self.cleaned_data.get('rack_group') - rack_name = self.cleaned_data.get('rack_name') + # Limit rack_group queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) - # Validate rack - if site and rack_group and rack_name: - try: - self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name) - except Rack.DoesNotExist: - raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group)) - elif site and rack_name: - try: - self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name) - except Rack.DoesNotExist: - raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site)) + # Limit rack queryset by assigned site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + } + self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) class ChildDeviceCSVForm(BaseDeviceCSVForm): - parent = FlexibleModelChoiceField( + parent = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Name or ID of parent device', - error_messages={ - 'invalid_choice': 'Parent device not found.', - } + help_text='Parent device' ) - device_bay_name = forms.CharField( - help_text='Name of device bay', - ) - cluster = forms.ModelChoiceField( - queryset=Cluster.objects.all(), + device_bay = CSVModelChoiceField( + queryset=Device.objects.all(), to_field_name='name', - required=False, - help_text='Virtualization cluster', - error_messages={ - 'invalid_choice': 'Invalid cluster name.', - } + help_text='Device bay in which this device is installed' ) class Meta(BaseDeviceCSVForm.Meta): fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay_name', 'cluster', 'comments', + 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', + 'parent', 'device_bay', 'cluster', 'comments', ] - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - parent = self.cleaned_data.get('parent') - device_bay_name = self.cleaned_data.get('device_bay_name') - - # Validate device bay - if parent and device_bay_name: - try: - self.instance.parent_bay = DeviceBay.objects.get(device=parent, name=device_bay_name) - # Inherit site and rack from parent device - self.instance.site = parent.site - self.instance.rack = parent.rack - except DeviceBay.DoesNotExist: - raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name)) + # Limit device bay queryset by parent device + params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')} + self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -2380,14 +2267,10 @@ class ConsolePortBulkEditForm( ) -class ConsolePortCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class ConsolePortCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) class Meta: @@ -2484,14 +2367,10 @@ class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): ) -class ConsoleServerPortCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class ConsoleServerPortCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) class Meta: @@ -2584,14 +2463,10 @@ class PowerPortBulkEditForm( ) -class PowerPortCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class PowerPortCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) class Meta: @@ -2735,27 +2610,21 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): ) -class PowerOutletCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class PowerOutletCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) - power_port = FlexibleModelChoiceField( + power_port = CSVModelChoiceField( queryset=PowerPort.objects.all(), required=False, to_field_name='name', - help_text='Name or ID of Power Port', - error_messages={ - 'invalid_choice': 'Power Port not found.', - } + help_text='Local power port which feeds this outlet' ) feed_leg = CSVChoiceField( choices=PowerOutletFeedLegChoices, required=False, + help_text='Electrical phase (for three-phase circuits)' ) class Meta: @@ -3057,40 +2926,31 @@ class InterfaceBulkDisconnectForm(ConfirmationForm): ) -class InterfaceCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class InterfaceCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) - virtual_machine = FlexibleModelChoiceField( + virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, - to_field_name='name', - help_text='Name or ID of virtual machine', - error_messages={ - 'invalid_choice': 'Virtual machine not found.', - } + to_field_name='name' ) - lag = FlexibleModelChoiceField( + lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, to_field_name='name', - help_text='Name or ID of LAG interface', - error_messages={ - 'invalid_choice': 'LAG interface not found.', - } + help_text='Parent LAG interface' ) type = CSVChoiceField( choices=InterfaceTypeChoices, + help_text='Physical medium' ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, + help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) class Meta: @@ -3270,30 +3130,27 @@ class FrontPortBulkDisconnectForm(ConfirmationForm): ) -class FrontPortCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class FrontPortCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) - rear_port = FlexibleModelChoiceField( + rear_port = CSVModelChoiceField( queryset=RearPort.objects.all(), to_field_name='name', - help_text='Name or ID of Rear Port', - error_messages={ - 'invalid_choice': 'Rear Port not found.', - } + help_text='Corresponding rear port' ) type = CSVChoiceField( choices=PortTypeChoices, + help_text='Physical medium classification' ) class Meta: model = FrontPort fields = FrontPort.csv_headers + help_texts = { + 'rear_port_position': 'Mapped position on corresponding rear port', + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -3408,22 +3265,22 @@ class RearPortBulkDisconnectForm(ConfirmationForm): ) -class RearPortCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class RearPortCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) type = CSVChoiceField( + help_text='Physical medium classification', choices=PortTypeChoices, ) class Meta: model = RearPort fields = RearPort.csv_headers + help_texts = { + 'positions': 'Number of front ports which may be mapped' + } # @@ -3516,20 +3373,16 @@ class DeviceBayBulkRenameForm(BulkRenameForm): ) -class DeviceBayCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class DeviceBayCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) - installed_device = FlexibleModelChoiceField( + installed_device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Name or ID of device', + help_text='Child device installed within this bay', error_messages={ 'invalid_choice': 'Child device not found.', } @@ -3808,44 +3661,37 @@ class CableForm(BootstrapMixin, forms.ModelForm): } -class CableCSVForm(forms.ModelForm): - +class CableCSVForm(CSVModelForm): # Termination A - side_a_device = FlexibleModelChoiceField( + side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Side A device name or ID', - error_messages={ - 'invalid_choice': 'Side A device not found', - } + help_text='Side A device' ) - side_a_type = forms.ModelChoiceField( + side_a_type = CSVModelChoiceField( queryset=ContentType.objects.all(), limit_choices_to=CABLE_TERMINATION_MODELS, to_field_name='model', help_text='Side A type' ) side_a_name = forms.CharField( - help_text='Side A component' + help_text='Side A component name' ) # Termination B - side_b_device = FlexibleModelChoiceField( + side_b_device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Side B device name or ID', - error_messages={ - 'invalid_choice': 'Side B device not found', - } + help_text='Side B device' ) - side_b_type = forms.ModelChoiceField( + side_b_type = CSVModelChoiceField( queryset=ContentType.objects.all(), limit_choices_to=CABLE_TERMINATION_MODELS, to_field_name='model', help_text='Side B type' ) side_b_name = forms.CharField( - help_text='Side B component' + help_text='Side B component name' ) # Cable attributes @@ -3857,7 +3703,7 @@ class CableCSVForm(forms.ModelForm): type = CSVChoiceField( choices=CableTypeChoices, required=False, - help_text='Cable type' + help_text='Physical medium classification' ) length_unit = CSVChoiceField( choices=CableLengthUnitChoices, @@ -3872,7 +3718,7 @@ class CableCSVForm(forms.ModelForm): 'status', 'label', 'color', 'length', 'length_unit', ] help_texts = { - 'color': 'RGB color in hexadecimal (e.g. 00ff00)' + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } # TODO: Merge the clean() methods for either end @@ -4163,23 +4009,15 @@ class InventoryItemCreateForm(BootstrapMixin, forms.Form): ) -class InventoryItemCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( +class InventoryItemCSVForm(CSVModelForm): + device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found.', - } + to_field_name='name' ) - manufacturer = forms.ModelChoiceField( + manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', - required=False, - help_text='Manufacturer name', - error_messages={ - 'invalid_choice': 'Invalid manufacturer.', - } + required=False ) class Meta: @@ -4476,39 +4314,30 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): ] -class PowerPanelCSVForm(forms.ModelForm): - site = forms.ModelChoiceField( +class PowerPanelCSVForm(CSVModelForm): + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + help_text='Name of parent site' ) - rack_group_name = forms.CharField( + rack_group = CSVModelChoiceField( + queryset=RackGroup.objects.all(), required=False, - help_text="Rack group name (optional)" + to_field_name='name' ) class Meta: model = PowerPanel fields = PowerPanel.csv_headers - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - rack_group_name = self.cleaned_data.get('rack_group_name') - - # Validate rack group - if rack_group_name: - try: - self.instance.rack_group = RackGroup.objects.get(site=site, name=rack_group_name) - except RackGroup.DoesNotExist: - raise forms.ValidationError( - "Rack group {} not found in site {}".format(rack_group_name, site) - ) + # Limit group queryset by assigned site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm): @@ -4624,29 +4453,27 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class PowerFeedCSVForm(CustomFieldModelCSVForm): - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + help_text='Assigned site' ) - panel_name = forms.ModelChoiceField( + power_panel = CSVModelChoiceField( queryset=PowerPanel.objects.all(), to_field_name='name', - help_text='Name of upstream power panel', - error_messages={ - 'invalid_choice': 'Power panel not found.', - } + help_text='Upstream power panel' ) - rack_group = forms.CharField( + rack_group = CSVModelChoiceField( + queryset=RackGroup.objects.all(), + to_field_name='name', required=False, - help_text="Rack group name (optional)" + help_text="Rack's group (if any)" ) - rack_name = forms.CharField( + rack = CSVModelChoiceField( + queryset=Rack.objects.all(), + to_field_name='name', required=False, - help_text="Rack name (optional)" + help_text='Rack' ) status = CSVChoiceField( choices=PowerFeedStatusChoices, @@ -4661,7 +4488,7 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): supply = CSVChoiceField( choices=PowerFeedSupplyChoices, required=False, - help_text='AC/DC' + help_text='Supply type (AC/DC)' ) phase = CSVChoiceField( choices=PowerFeedPhaseChoices, @@ -4673,32 +4500,25 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): model = PowerFeed fields = PowerFeed.csv_headers - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - panel_name = self.cleaned_data.get('panel_name') - rack_group = self.cleaned_data.get('rack_group') - rack_name = self.cleaned_data.get('rack_name') + # Limit power_panel queryset by site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params) - # Validate power panel - if panel_name: - try: - self.instance.power_panel = PowerPanel.objects.get(site=site, name=panel_name) - except Rack.DoesNotExist: - raise forms.ValidationError( - "Power panel {} not found in site {}".format(panel_name, site) - ) + # Limit rack_group queryset by site + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) - # Validate rack - if rack_name: - try: - self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name) - except Rack.DoesNotExist: - raise forms.ValidationError( - "Rack {} not found in site {}, group {}".format(rack_name, site, rack_group) - ) + # Limit rack queryset by site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + } + self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 0af4ef6a4..b0da352da 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -180,12 +180,14 @@ class Site(ChangeLoggedModel, CustomFieldModel): ) facility = models.CharField( max_length=50, - blank=True + blank=True, + help_text='Local facility ID or description' ) asn = ASNField( blank=True, null=True, - verbose_name='ASN' + verbose_name='ASN', + help_text='32-bit autonomous system number' ) time_zone = TimeZoneField( blank=True @@ -206,13 +208,15 @@ class Site(ChangeLoggedModel, CustomFieldModel): max_digits=8, decimal_places=6, blank=True, - null=True + null=True, + help_text='GPS coordinate (latitude)' ) longitude = models.DecimalField( max_digits=9, decimal_places=6, blank=True, - null=True + null=True, + help_text='GPS coordinate (longitude)' ) contact_name = models.CharField( max_length=50, @@ -419,7 +423,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): max_length=50, blank=True, null=True, - verbose_name='Facility ID' + verbose_name='Facility ID', + help_text='Locally-assigned identifier' ) site = models.ForeignKey( to='dcim.Site', @@ -431,7 +436,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): on_delete=models.SET_NULL, related_name='racks', blank=True, - null=True + null=True, + help_text='Assigned group' ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -450,7 +456,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): on_delete=models.PROTECT, related_name='racks', blank=True, - null=True + null=True, + help_text='Functional role' ) serial = models.CharField( max_length=50, @@ -480,7 +487,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel): u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)] + validators=[MinValueValidator(1), MaxValueValidator(100)], + help_text='Height in rack units' ) desc_units = models.BooleanField( default=False, @@ -489,11 +497,13 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ) outer_width = models.PositiveSmallIntegerField( blank=True, - null=True + null=True, + help_text='Outer dimension of rack (width)' ) outer_depth = models.PositiveSmallIntegerField( blank=True, - null=True + null=True, + help_text='Outer dimension of rack (depth)' ) outer_unit = models.CharField( max_length=50, @@ -514,7 +524,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', + 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] clone_fields = [ @@ -821,7 +831,7 @@ class RackReservation(ChangeLoggedModel): def clean(self): - if self.units: + if hasattr(self, 'rack') and self.units: # Validate that all specified units exist in the Rack. invalid_units = [u for u in self.units if u not in self.rack.units] @@ -1415,7 +1425,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', + 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', ] clone_fields = [ @@ -1798,7 +1808,7 @@ class PowerPanel(ChangeLoggedModel): max_length=50 ) - csv_headers = ['site', 'rack_group_name', 'name'] + csv_headers = ['site', 'rack_group', 'name'] class Meta: ordering = ['site', 'name'] @@ -1905,7 +1915,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', ] clone_fields = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3b61f80ba..4005d41a4 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -239,7 +239,8 @@ class ConsolePort(CableTermination, ComponentModel): type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, - blank=True + blank=True, + help_text='Physical port type' ) connected_endpoint = models.OneToOneField( to='dcim.ConsoleServerPort', @@ -300,7 +301,8 @@ class ConsoleServerPort(CableTermination, ComponentModel): type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, - blank=True + blank=True, + help_text='Physical port type' ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, @@ -354,7 +356,8 @@ class PowerPort(CableTermination, ComponentModel): type = models.CharField( max_length=50, choices=PowerPortTypeChoices, - blank=True + blank=True, + help_text='Physical port type' ) maximum_draw = models.PositiveSmallIntegerField( blank=True, @@ -516,7 +519,8 @@ class PowerOutlet(CableTermination, ComponentModel): type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, - blank=True + blank=True, + help_text='Physical port type' ) power_port = models.ForeignKey( to='dcim.PowerPort', @@ -653,7 +657,7 @@ class Interface(CableTermination, ComponentModel): mode = models.CharField( max_length=50, choices=InterfaceModeChoices, - blank=True, + blank=True ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', @@ -1083,7 +1087,8 @@ class InventoryItem(ComponentModel): part_id = models.CharField( max_length=50, verbose_name='Part ID', - blank=True + blank=True, + help_text='Manufacturer-assigned part identifier' ) serial = models.CharField( max_length=50, @@ -1100,7 +1105,7 @@ class InventoryItem(ComponentModel): ) discovered = models.BooleanField( default=False, - verbose_name='Discovered' + help_text='This item was automatically discovered' ) tags = TaggableManager(through=TaggedItem) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b1aaf4449..65f37c1d5 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -184,7 +184,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): site = Site.objects.create(name='Site 1', slug='site-1') - rack = Rack(name='Rack 1', site=site) + rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site) + rack_group.save() + + rack = Rack(name='Rack 1', site=site, group=rack_group) rack.save() RackReservation.objects.bulk_create([ @@ -202,10 +205,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'site,rack_name,units,description', - 'Site 1,Rack 1,"10,11,12",Reservation 1', - 'Site 1,Rack 1,"13,14,15",Reservation 2', - 'Site 1,Rack 1,"16,17,18",Reservation 3', + 'site,rack_group,rack,units,description', + 'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1', + 'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2', + 'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3', ) cls.bulk_edit_data = { @@ -268,10 +271,10 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): } 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", + "site,group,name,width,u_height", + "Site 1,,Rack 4,19,42", + "Site 1,Rack Group 1,Rack 5,19,42", + "Site 2,Rack Group 2,Rack 6,19,42", ) cls.bulk_edit_data = { @@ -890,8 +893,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) + rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1') + rack_group.save() + racks = ( - Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 1', site=sites[0], group=rack_group), Rack(name='Rack 2', site=sites[1]), ) Rack.objects.bulk_create(racks) @@ -947,10 +953,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } 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", + "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face", + "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 4,Site 1,Rack Group 1,Rack 1,10,Front", + "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 5,Site 1,Rack Group 1,Rack 1,20,Front", + "Device Role 1,Manufacturer 1,Device Type 1,Active,Device 6,Site 1,Rack Group 1,Rack 1,30,Front", ) cls.bulk_edit_data = { @@ -1586,7 +1592,7 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "site,rack_group_name,name", + "site,rack_group,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", @@ -1645,7 +1651,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "site,panel_name,name,voltage,amperage,max_utilization", + "site,power_panel,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", diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 676d7ceba..384b3563b 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -8,7 +8,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, - CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, + ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup @@ -89,7 +89,7 @@ class CustomFieldModelForm(forms.ModelForm): return obj -class CustomFieldModelCSVForm(CustomFieldModelForm): +class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): def _append_customfield_fields(self): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 854843f2e..7eda1add3 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.core.exceptions import MultipleObjectsReturned from django.core.validators import MaxValueValidator, MinValueValidator from taggit.forms import TagField @@ -11,16 +10,15 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, - DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, - FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + CSVModelChoiceField, CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine -from .constants import * from .choices import * +from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF - PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) ]) @@ -53,22 +51,16 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class VRFCSVForm(CustomFieldModelCSVForm): - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) class Meta: model = VRF fields = VRF.csv_headers - help_texts = { - 'name': 'VRF name', - } class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -120,7 +112,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm): ] -class RIRCSVForm(forms.ModelForm): +class RIRCSVForm(CSVModelForm): slug = SlugField() class Meta: @@ -168,13 +160,10 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): class AggregateCSVForm(CustomFieldModelCSVForm): - rir = forms.ModelChoiceField( + rir = CSVModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', - help_text='Name of parent RIR', - error_messages={ - 'invalid_choice': 'RIR not found.', - } + help_text='Assigned RIR' ) class Meta: @@ -247,15 +236,12 @@ class RoleForm(BootstrapMixin, forms.ModelForm): ] -class RoleCSVForm(forms.ModelForm): +class RoleCSVForm(CSVModelForm): slug = SlugField() class Meta: model = Role fields = Role.csv_headers - help_texts = { - 'name': 'Role name', - } # @@ -333,92 +319,62 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class PrefixCSVForm(CustomFieldModelCSVForm): - vrf = FlexibleModelChoiceField( + vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Name of parent VRF (or {ID})', - error_messages={ - 'invalid_choice': 'VRF not found.', - } + help_text='Assigned VRF' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + help_text='Assigned site' ) - vlan_group = forms.CharField( - help_text='Group name of assigned VLAN', - required=False + vlan_group = CSVModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text="VLAN's group (if any)" ) - vlan_vid = forms.IntegerField( - help_text='Numeric ID of assigned VLAN', - required=False + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text="Assigned VLAN" ) status = CSVChoiceField( choices=PrefixStatusChoices, help_text='Operational status' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role', - error_messages={ - 'invalid_choice': 'Invalid role.', - } + help_text='Functional role' ) class Meta: model = Prefix fields = Prefix.csv_headers - def clean(self): + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - super().clean() + if data: - site = self.cleaned_data.get('site') - vlan_group = self.cleaned_data.get('vlan_group') - vlan_vid = self.cleaned_data.get('vlan_vid') - - # Validate VLAN - if vlan_group and vlan_vid: - try: - self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid) - except VLAN.DoesNotExist: - if site: - raise forms.ValidationError("VLAN {} not found in site {} group {}".format( - vlan_vid, site, vlan_group - )) - else: - raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group)) - except MultipleObjectsReturned: - raise forms.ValidationError( - "Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group) - ) - elif vlan_vid: - try: - self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid) - except VLAN.DoesNotExist: - if site: - raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site)) - else: - raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid)) - except MultipleObjectsReturned: - raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid)) + # Limit vlan queryset by assigned site and group + params = { + f"site__{self.fields['site'].to_field_name}": data.get('site'), + f"group__{self.fields['vlan_group'].to_field_name}": data.get('vlan_group'), + } + self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -737,23 +693,17 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class IPAddressCSVForm(CustomFieldModelCSVForm): - vrf = FlexibleModelChoiceField( + vrf = CSVModelChoiceField( queryset=VRF.objects.all(), to_field_name='name', required=False, - help_text='Name of parent VRF (or {ID})', - error_messages={ - 'invalid_choice': 'VRF not found.', - } + help_text='Assigned VRF' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Name of the assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) status = CSVChoiceField( choices=IPAddressStatusChoices, @@ -764,27 +714,23 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): required=False, help_text='Functional role' ) - device = FlexibleModelChoiceField( + device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Name or ID of assigned device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + help_text='Parent device of assigned interface (if any)' ) - virtual_machine = forms.ModelChoiceField( + virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned virtual machine', - error_messages={ - 'invalid_choice': 'Virtual machine not found.', - } + help_text='Parent VM of assigned interface (if any)' ) - interface_name = forms.CharField( - help_text='Name of assigned interface', - required=False + interface = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned interface' ) is_primary = forms.BooleanField( help_text='Make this the primary IP for the assigned device', @@ -795,38 +741,34 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): model = IPAddress fields = IPAddress.csv_headers + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit interface queryset by assigned device or virtual machine + if data.get('device'): + params = { + f"device__{self.fields['device'].to_field_name}": data.get('device') + } + elif data.get('virtual_machine'): + params = { + f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine') + } + else: + params = { + 'device': None, + 'virtual_machine': None, + } + self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params) + def clean(self): super().clean() device = self.cleaned_data.get('device') virtual_machine = self.cleaned_data.get('virtual_machine') - interface_name = self.cleaned_data.get('interface_name') is_primary = self.cleaned_data.get('is_primary') - # Validate interface - if interface_name and device: - try: - self.instance.interface = Interface.objects.get(device=device, name=interface_name) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface {} for device {}".format( - interface_name, device - )) - elif interface_name and virtual_machine: - try: - self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface {} for virtual machine {}".format( - interface_name, virtual_machine - )) - elif interface_name: - raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format( - interface_name - )) - elif device: - raise forms.ValidationError("Device specified ({}) but interface missing".format(device)) - elif virtual_machine: - raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine)) - # Validate is_primary if is_primary and not device and not virtual_machine: raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP") @@ -993,24 +935,18 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): ] -class VLANGroupCSVForm(forms.ModelForm): - site = forms.ModelChoiceField( +class VLANGroupCSVForm(CSVModelForm): + site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + help_text='Assigned site' ) slug = SlugField() class Meta: model = VLANGroup fields = VLANGroup.csv_headers - help_texts = { - 'name': 'Name of VLAN group', - } class VLANGroupFilterForm(BootstrapMixin, forms.Form): @@ -1082,40 +1018,33 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class VLANCSVForm(CustomFieldModelCSVForm): - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, to_field_name='name', - help_text='Name of parent site', - error_messages={ - 'invalid_choice': 'Site not found.', - } + help_text='Assigned site' ) - group_name = forms.CharField( - help_text='Name of VLAN group', - required=False + group = CSVModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned VLAN group' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.', - } + help_text='Assigned tenant' ) status = CSVChoiceField( choices=VLANStatusChoices, help_text='Operational status' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=Role.objects.all(), required=False, to_field_name='name', - help_text='Functional role', - error_messages={ - 'invalid_choice': 'Invalid role.', - } + help_text='Functional role' ) class Meta: @@ -1126,25 +1055,14 @@ class VLANCSVForm(CustomFieldModelCSVForm): 'name': 'VLAN name', } - def clean(self): - super().clean() + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) - site = self.cleaned_data.get('site') - group_name = self.cleaned_data.get('group_name') + if data: - # Validate VLAN group - if group_name: - try: - self.instance.group = VLANGroup.objects.get(site=site, name=group_name) - except VLANGroup.DoesNotExist: - if site: - raise forms.ValidationError( - "VLAN group {} not found for site {}".format(group_name, site) - ) - else: - raise forms.ValidationError( - "Global VLAN group {} not found".format(group_name) - ) + # Limit vlan queryset by assigned group + params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} + self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -1299,23 +1217,17 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): class ServiceCSVForm(CustomFieldModelCSVForm): - device = FlexibleModelChoiceField( + device = CSVModelChoiceField( queryset=Device.objects.all(), required=False, to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } + help_text='Required if not assigned to a VM' ) - virtual_machine = FlexibleModelChoiceField( + virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, to_field_name='name', - help_text='Name or ID of virtual machine', - error_messages={ - 'invalid_choice': 'Virtual machine not found.', - } + help_text='Required if not assigned to a device' ) protocol = CSVChoiceField( choices=ServiceProtocolChoices, @@ -1325,8 +1237,6 @@ class ServiceCSVForm(CustomFieldModelCSVForm): class Meta: model = Service fields = Service.csv_headers - help_texts = { - } class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f6ed7901a..84720845e 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -50,7 +50,8 @@ class VRF(ChangeLoggedModel, CustomFieldModel): unique=True, blank=True, null=True, - verbose_name='Route distinguisher' + verbose_name='Route distinguisher', + help_text='Unique route distinguisher (as defined in RFC 4364)' ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -364,7 +365,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', + 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description', ] clone_fields = [ 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', @@ -635,7 +636,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', 'dns_name', 'description', ] clone_fields = [ @@ -925,7 +926,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): tags = TaggableManager(through=TaggedItem) - csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] clone_fields = [ 'site', 'group', 'tenant', 'status', 'role', 'description', ] @@ -1017,7 +1018,10 @@ class Service(ChangeLoggedModel, CustomFieldModel): choices=ServiceProtocolChoices ) port = models.PositiveIntegerField( - validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)], + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ], verbose_name='Port number' ) ipaddresses = models.ManyToManyField( diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 03ff8fab8..368a47590 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -8,8 +8,8 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, + APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, ) from .constants import * from .models import Secret, SecretRole, UserKey @@ -55,15 +55,12 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): } -class SecretRoleCSVForm(forms.ModelForm): +class SecretRoleCSVForm(CSVModelForm): slug = SlugField() class Meta: model = SecretRole fields = SecretRole.csv_headers - help_texts = { - 'name': 'Name of secret role', - } # @@ -120,21 +117,15 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): class SecretCSVForm(CustomFieldModelCSVForm): - device = FlexibleModelChoiceField( + device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - help_text='Device name or ID', - error_messages={ - 'invalid_choice': 'Device not found.', - } + help_text='Assigned device' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=SecretRole.objects.all(), to_field_name='name', - help_text='Name of assigned role', - error_messages={ - 'invalid_choice': 'Invalid secret role.', - } + help_text='Assigned role' ) plaintext = forms.CharField( help_text='Plaintext secret data' diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/utilities/obj_bulk_import.html index a476cbd15..4359d49a6 100644 --- a/netbox/templates/utilities/obj_bulk_import.html +++ b/netbox/templates/utilities/obj_bulk_import.html @@ -3,58 +3,95 @@ {% load form_helpers %} {% block content %} -

{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

{% block tabs %}{% endblock %} -
-
- {% if form.non_field_errors %} -
-
Errors
-
- {{ form.non_field_errors }} +
+
+

{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %}

+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
-
- {% endif %} -
- {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel + {% endif %} + +
+
+ + {% csrf_token %} + {% render_form form %} +
+
+ + {% if return_url %} + Cancel + {% endif %} +
+
+ +
+

+ {% if fields %} +
+
+ CSV Field Options +
+ + + + + + + + {% for name, field in fields.items %} + + + + + + + {% endfor %} +
FieldRequiredAccessorDescription
+ {{ name }} + + {% if field.required %} + + {% else %} + + {% endif %} + + {% if field.to_field_name %} + {{ field.to_field_name }} + {% else %} + + {% endif %} + + {% if field.help_text %} + {{ field.help_text }}
+ {% elif field.label %} + {{ field.label }}
+ {% endif %} + {% if field|widget_type == 'dateinput' %} + Format: YYYY-MM-DD + {% elif field|widget_type == 'checkboxinput' %} + Specify "true" or "false" + {% endif %} +
+
+

+ Required fields must be specified for all + objects. +

+

+ Related objects may be referenced by any unique attribute. + For example, vrf.rd would identify a VRF by its route distinguisher. +

{% endif %}
- -
-
- {% if fields %} -

CSV Format

- - - - - - - {% for name, field in fields.items %} - - - - - - {% endfor %} -
FieldRequiredDescription
{{ name }}{% if field.required %}{% endif %} - {{ field.help_text|default:field.label }} - {% if field.choices %} -
Choices: {{ field|example_choices }} - {% elif field|widget_type == 'dateinput' %} -
Format: YYYY-MM-DD - {% elif field|widget_type == 'checkboxinput' %} -
Specify "true" or "false" - {% endif %} -
- {% endif %} -
-
+
+
{% endblock %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 78c872c6a..700d88b1d 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,11 +2,11 @@ from django import forms from taggit.forms import TagField from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SlugField, TagFilterField, + APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -32,24 +32,18 @@ class TenantGroupForm(BootstrapMixin, forms.ModelForm): ] -class TenantGroupCSVForm(forms.ModelForm): - parent = forms.ModelChoiceField( +class TenantGroupCSVForm(CSVModelForm): + parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Name of parent tenant group', - error_messages={ - 'invalid_choice': 'Tenant group not found.', - } + help_text='Parent group' ) slug = SlugField() class Meta: model = TenantGroup fields = TenantGroup.csv_headers - help_texts = { - 'name': 'Group name', - } # @@ -74,25 +68,18 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): ) -class TenantCSVForm(CustomFieldModelForm): +class TenantCSVForm(CustomFieldModelCSVForm): slug = SlugField() - group = forms.ModelChoiceField( + group = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', - help_text='Name of parent group', - error_messages={ - 'invalid_choice': 'Group not found.' - } + help_text='Assigned group' ) class Meta: model = Tenant fields = Tenant.csv_headers - help_texts = { - 'name': 'Tenant name', - 'comments': 'Free-form comments' - } class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index c1d925999..bfc783631 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -8,6 +8,7 @@ import yaml from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput +from django.core.exceptions import MultipleObjectsReturned from django.db.models import Count from django.forms import BoundField from django.forms.models import fields_for_model @@ -400,15 +401,22 @@ class TimePicker(forms.TextInput): class CSVDataField(forms.CharField): """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping - column headers to values. Each dictionary represents an individual record. + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. """ widget = forms.Textarea - def __init__(self, fields, required_fields=[], *args, **kwargs): + def __init__(self, from_form, *args, **kwargs): - self.fields = fields - self.required_fields = required_fields + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] super().__init__(*args, **kwargs) @@ -416,7 +424,7 @@ class CSVDataField(forms.CharField): if not self.label: self.label = '' if not self.initial: - self.initial = ','.join(required_fields) + '\n' + self.initial = ','.join(self.required_fields) + '\n' if not self.help_text: self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ @@ -425,36 +433,55 @@ class CSVDataField(forms.CharField): def to_python(self, value): records = [] - reader = csv.reader(StringIO(value)) + reader = csv.reader(StringIO(value.strip())) - # Consume and validate the first line of CSV data as column headers - headers = next(reader) + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + headers = {} + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + + return headers, records + + def validate(self, value): + headers, records = value + + # Validate provided column headers + for field, to_field in headers.items(): + if field not in self.fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(self.fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(self.fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields for f in self.required_fields: if f not in headers: - raise forms.ValidationError('Required column header "{}" not found.'.format(f)) - for f in headers: - if f not in self.fields: - raise forms.ValidationError('Unexpected column header "{}" found.'.format(f)) + raise forms.ValidationError(f'Required column header "{f}" not found.') - # Parse CSV data - for i, row in enumerate(reader, start=1): - if row: - if len(row) != len(headers): - raise forms.ValidationError( - "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row)) - ) - row = [col.strip() for col in row] - record = dict(zip(headers, row)) - records.append(record) - - return records + return value class CSVChoiceField(forms.ChoiceField): """ 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.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] @@ -469,6 +496,23 @@ class CSVChoiceField(forms.ChoiceField): return self.choice_values[value] +class CSVModelChoiceField(forms.ModelChoiceField): + """ + Provides additional validation for model choices entered as CSV data. + """ + default_error_messages = { + 'invalid_choice': 'Object not found.', + } + + def to_python(self, value): + try: + return super().to_python(value) + except MultipleObjectsReturned as e: + raise forms.ValidationError( + f'"{value}" is not a unique value for this field; multiple objects were found' + ) + + class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion @@ -530,27 +574,6 @@ class CommentField(forms.CharField): super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) -class FlexibleModelChoiceField(forms.ModelChoiceField): - """ - Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`. - """ - def to_python(self, value): - if value in self.empty_values: - return None - try: - if not self.to_field_name: - key = 'pk' - elif re.match(r'^\{\d+\}$', value): - key = 'pk' - value = value.strip('{}') - else: - key = self.to_field_name - value = self.queryset.get(**{key: value}) - except (ValueError, TypeError, self.queryset.model.DoesNotExist): - raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice') - return value - - class SlugField(forms.SlugField): """ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. @@ -709,6 +732,20 @@ class BulkEditForm(forms.Form): self.nullable_fields = self.Meta.nullable_fields +class CSVModelForm(forms.ModelForm): + """ + ModelForm used for the import of objects in CSV format. + """ + def __init__(self, *args, headers=None, **kwargs): + super().__init__(*args, **kwargs) + + # Modify the model form to accommodate any customized to_field_name properties + if headers: + for field, to_field in headers.items(): + if to_field is not None: + self.fields[field].to_field_name = to_field + + class ImportForm(BootstrapMixin, forms.Form): """ Generic form for creating an object from JSON/YAML data diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 466690a4c..8a82fc48b 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -116,28 +116,6 @@ def humanize_speed(speed): return '{} Kbps'.format(speed) -@register.filter() -def example_choices(field, arg=3): - """ - Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms). - """ - examples = [] - if hasattr(field, 'queryset'): - choices = [ - (obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1] - ] - else: - choices = field.choices - for value, label in unpack_grouped_choices(choices): - if len(examples) == arg: - examples.append('etc.') - break - if not value or not label: - continue - examples.append(label) - return ', '.join(examples) or 'None' - - @register.filter() def tzoffset(value): """ diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 2d7235505..d6af27b93 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,6 +1,8 @@ from django import forms from django.test import TestCase +from ipam.forms import IPAddressCSVForm +from ipam.models import VRF from utilities.forms import * @@ -281,3 +283,85 @@ class ExpandAlphanumeric(TestCase): with self.assertRaises(ValueError): sorted(expand_alphanumeric_pattern('r[a,,b]a')) + + +class CSVDataFieldTest(TestCase): + + def setUp(self): + self.field = CSVDataField(from_form=IPAddressCSVForm) + + def test_clean(self): + input = """ + address,status,vrf + 192.0.2.1/32,Active,Test VRF + """ + output = ( + {'address': None, 'status': None, 'vrf': None}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_invalid_header(self): + input = """ + address,status,vrf,xxx + 192.0.2.1/32,Active,Test VRF,123 + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) + + def test_clean_missing_required_header(self): + input = """ + status,vrf + Active,Test VRF + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) + + def test_clean_default_to_field(self): + input = """ + address,status,vrf.name + 192.0.2.1/32,Active,Test VRF + """ + output = ( + {'address': None, 'status': None, 'vrf': 'name'}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': 'Test VRF'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_pk_to_field(self): + input = """ + address,status,vrf.pk + 192.0.2.1/32,Active,123 + """ + output = ( + {'address': None, 'status': None, 'vrf': 'pk'}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_custom_to_field(self): + input = """ + address,status,vrf.rd + 192.0.2.1/32,Active,123:456 + """ + output = ( + {'address': None, 'status': None, 'vrf': 'rd'}, + [{'address': '192.0.2.1/32', 'status': 'Active', 'vrf': '123:456'}] + ) + self.assertEqual(self.field.clean(input), output) + + def test_clean_invalid_to_field(self): + input = """ + address,status,vrf.xxx + 192.0.2.1/32,Active,123:456 + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) + + def test_clean_to_field_on_non_object(self): + input = """ + address,status.foo,vrf + 192.0.2.1/32,Bar,Test VRF + """ + with self.assertRaises(forms.ValidationError): + self.field.clean(input) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index eca124a4a..3064abe4e 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -575,11 +575,11 @@ class BulkImportView(GetReturnURLMixin, View): def _import_form(self, *args, **kwargs): - fields = self.model_form().fields.keys() - required_fields = [name for name, field in self.model_form().fields.items() if field.required] - class ImportForm(BootstrapMixin, Form): - csv = CSVDataField(fields=fields, required_fields=required_fields, widget=Textarea(attrs=self.widget_attrs)) + csv = CSVDataField( + from_form=self.model_form, + widget=Textarea(attrs=self.widget_attrs) + ) return ImportForm(*args, **kwargs) @@ -609,8 +609,10 @@ class BulkImportView(GetReturnURLMixin, View): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - for row, data in enumerate(form.cleaned_data['csv'], start=1): - obj_form = self.model_form(data) + headers, records = form.cleaned_data['csv'] + for row, data in enumerate(records, start=1): + obj_form = self.model_form(data, headers=headers) + if obj_form.is_valid(): obj = self._save_obj(obj_form, request) new_objs.append(obj) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 9ba5ff032..0983b2432 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -14,9 +14,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, - TagFilterField, + CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -36,15 +36,12 @@ class ClusterTypeForm(BootstrapMixin, forms.ModelForm): ] -class ClusterTypeCSVForm(forms.ModelForm): +class ClusterTypeCSVForm(CSVModelForm): slug = SlugField() class Meta: model = ClusterType fields = ClusterType.csv_headers - help_texts = { - 'name': 'Name of cluster type', - } # @@ -61,15 +58,12 @@ class ClusterGroupForm(BootstrapMixin, forms.ModelForm): ] -class ClusterGroupCSVForm(forms.ModelForm): +class ClusterGroupCSVForm(CSVModelForm): slug = SlugField() class Meta: model = ClusterGroup fields = ClusterGroup.csv_headers - help_texts = { - 'name': 'Name of cluster group', - } # @@ -101,40 +95,28 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class ClusterCSVForm(CustomFieldModelCSVForm): - type = forms.ModelChoiceField( + type = CSVModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', - help_text='Name of cluster type', - error_messages={ - 'invalid_choice': 'Invalid cluster type name.', - } + help_text='Type of cluster' ) - group = forms.ModelChoiceField( + group = CSVModelChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='name', required=False, - help_text='Name of cluster group', - error_messages={ - 'invalid_choice': 'Invalid cluster group name.', - } + help_text='Assigned cluster group' ) - site = forms.ModelChoiceField( + site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', required=False, - help_text='Name of assigned site', - error_messages={ - 'invalid_choice': 'Invalid site name.', - } + help_text='Assigned site' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), to_field_name='name', required=False, - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Invalid tenant name' - } + help_text='Assigned tenant' ) class Meta: @@ -407,42 +389,30 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm): required=False, help_text='Operational status of device' ) - cluster = forms.ModelChoiceField( + cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', - help_text='Name of parent cluster', - error_messages={ - 'invalid_choice': 'Invalid cluster name.', - } + help_text='Assigned cluster' ) - role = forms.ModelChoiceField( + role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True ), required=False, to_field_name='name', - help_text='Name of functional role', - error_messages={ - 'invalid_choice': 'Invalid role name.' - } + help_text='Functional role' ) - tenant = forms.ModelChoiceField( + tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned tenant', - error_messages={ - 'invalid_choice': 'Tenant not found.' - } + help_text='Assigned tenant' ) - platform = forms.ModelChoiceField( + platform = CSVModelChoiceField( queryset=Platform.objects.all(), required=False, to_field_name='name', - help_text='Name of assigned platform', - error_messages={ - 'invalid_choice': 'Invalid platform.', - } + help_text='Assigned platform' ) class Meta: