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 %}
-