diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 79cad0a6b..46472b228 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, - FilterChoiceField, Livesearch, SmallTextarea, SlugField, + APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, + SmallTextarea, SlugField, ) from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -39,15 +39,18 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderFromCSVForm(forms.ModelForm): +class ProviderCSVForm(forms.ModelForm): + slug = SlugField() class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url'] - - -class ProviderImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ProviderFromCSVForm) + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments'] + help_texts = { + 'name': 'Provider name', + 'asn': '32-bit autonomous system number', + 'portal_url': 'Portal URL', + 'comments': 'Free-form comments', + } class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -102,21 +105,36 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class CircuitFromCSVForm(forms.ModelForm): - provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Provider not found.'}) - type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid circuit type.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) +class CircuitCSVForm(forms.ModelForm): + provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Name of parent provider', + error_messages={ + 'invalid_choice': 'Provider not found.' + } + ) + type = forms.ModelChoiceField( + queryset=CircuitType.objects.all(), + to_field_name='name', + help_text='Type of circuit', + error_messages={ + 'invalid_choice': 'Invalid circuit type.' + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) class Meta: model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] - - -class CircuitImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=CircuitFromCSVForm) + fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index eed612a33..35e37f4c5 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -65,9 +65,8 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_provider' - form = forms.ProviderImportForm + model_form = forms.ProviderCSVForm table = tables.ProviderTable - template_name = 'circuits/provider_import.html' default_return_url = 'circuits:provider_list' @@ -163,9 +162,8 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_circuit' - form = forms.CircuitImportForm + model_form = forms.CircuitCSVForm table = tables.CircuitTable - template_name = 'circuits/circuit_import.html' default_return_url = 'circuits:circuit_list' diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9e1cc657d..996c231c8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,7 +5,6 @@ import re from django import forms from django.contrib.postgres.forms.array import SimpleArrayField -from django.core.exceptions import ValidationError from django.db.models import Count, Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -14,18 +13,18 @@ from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField, - FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVChoiceField, ExpandableNameField, FilterChoiceField, + FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, ) from .formfields import MACAddressFormField from .models import ( - DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, - ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, - Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, - RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, - SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES, + DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort, + ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, + IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, + InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES, + RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN, + Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES, ) @@ -50,14 +49,6 @@ def get_device_by_name_or_pk(name): return device -def validate_connection_status(value): - """ - Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive). - """ - if value.lower() not in ['planned', 'connected']: - raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) - - class DeviceComponentForm(BootstrapMixin, forms.Form): """ Allow inclusion of the parent device as context for limiting field choices. @@ -107,27 +98,37 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class SiteFromCSVForm(forms.ModelForm): +class SiteCSVForm(forms.ModelForm): region = forms.ModelChoiceField( - Region.objects.all(), to_field_name='name', required=False, error_messages={ - 'invalid_choice': 'Tenant not found.' + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned region', + error_messages={ + 'invalid_choice': 'Region not found.', } ) tenant = forms.ModelChoiceField( - Tenant.objects.all(), to_field_name='name', required=False, error_messages={ - 'invalid_choice': 'Tenant not found.' + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', } ) class Meta: model = Site fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', ] - - -class SiteImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=SiteFromCSVForm) + help_texts = { + 'name': 'Site name', + 'slug': 'URL-friendly slug', + 'asn': '32-bit autonomous system number', + } class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -217,49 +218,73 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class RackFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - group_name = forms.CharField(required=False) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Role not found.'}) - type = forms.CharField(required=False) +class RackCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + group_name = forms.CharField( + help_text='Name of rack group', + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + role = forms.ModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Role not found.', + } + ) + type = CSVChoiceField( + choices=RACK_TYPE_CHOICES, + required=False, + help_text='Rack type' + ) + width = forms.ChoiceField( + choices = ( + (RACK_WIDTH_19IN, '19'), + (RACK_WIDTH_23IN, '23'), + ), + help_text='Rail-to-rail width (in inches)' + ) class Meta: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units'] + fields = [ + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + ] + help_texts = { + 'name': 'Rack name', + 'u_height': 'Height in rack units', + } def clean(self): + super(RackCSVForm, self).clean() + site = self.cleaned_data.get('site') - group = self.cleaned_data.get('group_name') + group_name = self.cleaned_data.get('group_name') # Validate rack group - if site and group: + if group_name: try: - self.instance.group = RackGroup.objects.get(site=site, name=group) + self.instance.group = RackGroup.objects.get(site=site, name=group_name) except RackGroup.DoesNotExist: - self.add_error('group_name', "Invalid rack group ({})".format(group)) - - def clean_type(self): - rack_type = self.cleaned_data['type'] - if not rack_type: - return None - try: - choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES} - return choices[rack_type.lower()] - except KeyError: - raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format( - rack_type, - ', '.join({v: k for k, v in RACK_TYPE_CHOICES}), - )) - - -class RackImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=RackFromCSVForm) + raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -663,32 +688,60 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceFromCSVForm(forms.ModelForm): +class BaseDeviceCSVForm(forms.ModelForm): device_role = forms.ModelChoiceField( - queryset=DeviceRole.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid device role.'} + queryset=DeviceRole.objects.all(), + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Invalid device role.', + } ) tenant = forms.ModelChoiceField( - Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'} + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } ) manufacturer = forms.ModelChoiceField( - queryset=Manufacturer.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid manufacturer.'} + queryset=Manufacturer.objects.all(), + to_field_name='name', + help_text='Device type manufacturer', + error_messages={ + 'invalid_choice': 'Invalid manufacturer.', + } + ) + model_name = forms.CharField( + help_text='Device type model name' ) - model_name = forms.CharField() platform = forms.ModelChoiceField( - queryset=Platform.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid platform.'} + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned platform', + error_messages={ + 'invalid_choice': 'Invalid platform.', + } + ) + status = CSVChoiceField( + choices=STATUS_CHOICES, + help_text='Operational status of device' ) - status = forms.CharField() class Meta: fields = [] model = Device + help_texts = { + 'name': 'Device name', + } def clean(self): + super(BaseDeviceCSVForm, self).clean() + manufacturer = self.cleaned_data.get('manufacturer') model_name = self.cleaned_data.get('model_name') @@ -697,70 +750,73 @@ class BaseDeviceFromCSVForm(forms.ModelForm): try: self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name) except DeviceType.DoesNotExist: - self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name)) - - def clean_status(self): - status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES} - try: - return status_choices[self.cleaned_data['status'].lower()] - except KeyError: - raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) + raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name)) -class DeviceFromCSVForm(BaseDeviceFromCSVForm): +class DeviceCSVForm(BaseDeviceCSVForm): site = forms.ModelChoiceField( - queryset=Site.objects.all(), to_field_name='name', error_messages={ + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ 'invalid_choice': 'Invalid site name.', } ) - rack_name = forms.CharField(required=False) - face = forms.CharField(required=False) + rack_group = forms.CharField( + required=False, + help_text='Parent rack\'s group (if any)' + ) + rack_name = forms.CharField( + required=False, + help_text='Name of parent rack' + ) + face = CSVChoiceField( + choices=RACK_FACE_CHOICES, + required=False, + help_text='Mounted rack face' + ) - class Meta(BaseDeviceFromCSVForm.Meta): + class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_name', 'position', 'face', + 'site', 'rack_group', 'rack_name', 'position', 'face', ] def clean(self): - super(DeviceFromCSVForm, self).clean() + super(DeviceCSVForm, self).clean() site = self.cleaned_data.get('site') + rack_group = self.cleaned_data.get('rack_group') rack_name = self.cleaned_data.get('rack_name') # Validate rack - if site and rack_name: + if site and rack_group and rack_name: try: - self.instance.rack = Rack.objects.get(site=site, name=rack_name) + self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name) except Rack.DoesNotExist: - self.add_error('rack_name', "Invalid rack ({})".format(rack_name)) - - def clean_face(self): - face = self.cleaned_data['face'] - if not face: - return None - try: - return { - 'front': 0, - 'rear': 1, - }[face.lower()] - except KeyError: - raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face)) + 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)) -class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): +class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - required=False, + help_text='Name or ID of parent device', error_messages={ - 'invalid_choice': 'Parent device not found.' + 'invalid_choice': 'Parent device not found.', } ) - device_bay_name = forms.CharField(required=False) + device_bay_name = forms.CharField( + help_text='Name of device bay', + ) - class Meta(BaseDeviceFromCSVForm.Meta): + class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', 'parent', 'device_bay_name', @@ -768,7 +824,7 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): def clean(self): - super(ChildDeviceFromCSVForm, self).clean() + super(ChildDeviceCSVForm, self).clean() parent = self.cleaned_data.get('parent') device_bay_name = self.cleaned_data.get('device_bay_name') @@ -776,22 +832,12 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): # Validate device bay if parent and device_bay_name: try: - device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name) - if device_bay.installed_device: - self.add_error('device_bay_name', - "Device bay ({} {}) is already occupied".format(parent, device_bay_name)) - else: - self.instance.parent_bay = device_bay + 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: - self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name)) - - -class DeviceImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=DeviceFromCSVForm) - - -class ChildDeviceImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) + raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name)) class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -889,75 +935,84 @@ class ConsolePortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class ConsoleConnectionCSVForm(forms.Form): +class ConsoleConnectionCSVForm(forms.ModelForm): console_server = FlexibleModelChoiceField( queryset=Device.objects.filter(device_type__is_console_server=True), to_field_name='name', + help_text='Console server name or ID', error_messages={ 'invalid_choice': 'Console server not found', } ) - cs_port = forms.CharField() - device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device not found'}) - console_port = forms.CharField() - status = forms.CharField(validators=[validate_connection_status]) + cs_port = forms.CharField( + help_text='Console server port name' + ) + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Device name or ID', + error_messages={ + 'invalid_choice': 'Device not found', + } + ) + console_port = forms.CharField( + help_text='Console port name' + ) + connection_status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + help_text='Connection status' + ) - def clean(self): + class Meta: + model = ConsolePort + fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] - # Validate console server port - if self.cleaned_data.get('console_server'): - try: - cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'], - name=self.cleaned_data['cs_port']) - if ConsolePort.objects.filter(cs_port=cs_port): - raise forms.ValidationError("Console server port is already occupied (by {} {})" - .format(cs_port.connected_console.device, cs_port.connected_console)) - except ConsoleServerPort.DoesNotExist: - raise forms.ValidationError("Invalid console server port ({} {})" - .format(self.cleaned_data['console_server'], self.cleaned_data['cs_port'])) + def clean_console_port(self): - # Validate console port - if self.cleaned_data.get('device'): - try: - console_port = ConsolePort.objects.get(device=self.cleaned_data['device'], - name=self.cleaned_data['console_port']) - if console_port.cs_port: - raise forms.ValidationError("Console port is already connected (to {} {})" - .format(console_port.cs_port.device, console_port.cs_port)) - except ConsolePort.DoesNotExist: - raise forms.ValidationError("Invalid console port ({} {})" - .format(self.cleaned_data['device'], self.cleaned_data['console_port'])) + console_port_name = self.cleaned_data.get('console_port') + if not self.cleaned_data.get('device') or not console_port_name: + return None + try: + # Retrieve console port by name + consoleport = ConsolePort.objects.get( + device=self.cleaned_data['device'], name=console_port_name + ) + # Check if the console port is already connected + if consoleport.cs_port is not None: + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device'], console_port_name + )) + except ConsolePort.DoesNotExist: + raise forms.ValidationError("Invalid console port ({} {})".format( + self.cleaned_data['device'], console_port_name + )) -class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ConsoleConnectionCSVForm) + self.instance = consoleport + return consoleport - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return + def clean_cs_port(self): - connection_list = [] + cs_port_name = self.cleaned_data.get('cs_port') + if not self.cleaned_data.get('console_server') or not cs_port_name: + return None - for i, record in enumerate(records, start=1): - form = self.fields['csv'].csv_form(data=record) - if form.is_valid(): - console_port = ConsolePort.objects.get(device=form.cleaned_data['device'], - name=form.cleaned_data['console_port']) - console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'], - name=form.cleaned_data['cs_port']) - if form.cleaned_data['status'] == 'planned': - console_port.connection_status = CONNECTION_STATUS_PLANNED - else: - console_port.connection_status = CONNECTION_STATUS_CONNECTED - connection_list.append(console_port) - else: - for field, errors in form.errors.items(): - for e in errors: - self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + try: + # Retrieve console server port by name + cs_port = ConsoleServerPort.objects.get( + device=self.cleaned_data['console_server'], name=cs_port_name + ) + # Check if the console server port is already connected + if ConsolePort.objects.filter(cs_port=cs_port).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['console_server'], cs_port_name + )) + except ConsoleServerPort.DoesNotExist: + raise forms.ValidationError("Invalid console server port ({} {})".format( + self.cleaned_data['console_server'], cs_port_name + )) - self.cleaned_data['csv'] = connection_list + return cs_port class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): @@ -1137,76 +1192,84 @@ class PowerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class PowerConnectionCSVForm(forms.Form): +class PowerConnectionCSVForm(forms.ModelForm): pdu = FlexibleModelChoiceField( queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name', + help_text='PDU name or ID', error_messages={ 'invalid_choice': 'PDU not found.', } ) - power_outlet = forms.CharField() - device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device not found'}) - power_port = forms.CharField() - status = forms.CharField(validators=[validate_connection_status]) + power_outlet = forms.CharField( + help_text='Power outlet name' + ) + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Device name or ID', + error_messages={ + 'invalid_choice': 'Device not found', + } + ) + power_port = forms.CharField( + help_text='Power port name' + ) + connection_status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + help_text='Connection status' + ) - def clean(self): + class Meta: + model = PowerPort + fields = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] - # Validate power outlet - if self.cleaned_data.get('pdu'): - try: - power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'], - name=self.cleaned_data['power_outlet']) - if PowerPort.objects.filter(power_outlet=power_outlet): - raise forms.ValidationError("Power outlet is already occupied (by {} {})" - .format(power_outlet.connected_port.device, - power_outlet.connected_port)) - except PowerOutlet.DoesNotExist: - raise forms.ValidationError("Invalid PDU port ({} {})" - .format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet'])) + def clean_power_port(self): - # Validate power port - if self.cleaned_data.get('device'): - try: - power_port = PowerPort.objects.get(device=self.cleaned_data['device'], - name=self.cleaned_data['power_port']) - if power_port.power_outlet: - raise forms.ValidationError("Power port is already connected (to {} {})" - .format(power_port.power_outlet.device, power_port.power_outlet)) - except PowerPort.DoesNotExist: - raise forms.ValidationError("Invalid power port ({} {})" - .format(self.cleaned_data['device'], self.cleaned_data['power_port'])) + power_port_name = self.cleaned_data.get('power_port') + if not self.cleaned_data.get('device') or not power_port_name: + return None + try: + # Retrieve power port by name + powerport = PowerPort.objects.get( + device=self.cleaned_data['device'], name=power_port_name + ) + # Check if the power port is already connected + if powerport.power_outlet is not None: + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device'], power_port_name + )) + except PowerPort.DoesNotExist: + raise forms.ValidationError("Invalid power port ({} {})".format( + self.cleaned_data['device'], power_port_name + )) -class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=PowerConnectionCSVForm) + self.instance = powerport + return powerport - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return + def clean_power_outlet(self): - connection_list = [] + power_outlet_name = self.cleaned_data.get('power_outlet') + if not self.cleaned_data.get('pdu') or not power_outlet_name: + return None - for i, record in enumerate(records, start=1): - form = self.fields['csv'].csv_form(data=record) - if form.is_valid(): - power_port = PowerPort.objects.get(device=form.cleaned_data['device'], - name=form.cleaned_data['power_port']) - power_port.power_outlet = PowerOutlet.objects.get(device=form.cleaned_data['pdu'], - name=form.cleaned_data['power_outlet']) - if form.cleaned_data['status'] == 'planned': - power_port.connection_status = CONNECTION_STATUS_PLANNED - else: - power_port.connection_status = CONNECTION_STATUS_CONNECTED - connection_list.append(power_port) - else: - for field, errors in form.errors.items(): - for e in errors: - self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + try: + # Retrieve power outlet by name + power_outlet = PowerOutlet.objects.get( + device=self.cleaned_data['pdu'], name=power_outlet_name + ) + # Check if the power outlet is already connected + if PowerPort.objects.filter(power_outlet=power_outlet).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['pdu'], power_outlet_name + )) + except PowerOutlet.DoesNotExist: + raise forms.ValidationError("Invalid power outlet ({} {})".format( + self.cleaned_data['pdu'], power_outlet_name + )) - self.cleaned_data['csv'] = connection_list + return power_outlet class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): @@ -1536,94 +1599,79 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor ] -class InterfaceConnectionCSVForm(forms.Form): +class InterfaceConnectionCSVForm(forms.ModelForm): device_a = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', + help_text='Name or ID of device A', error_messages={'invalid_choice': 'Device A not found.'} ) - interface_a = forms.CharField() + interface_a = forms.CharField( + help_text='Name of interface A' + ) device_b = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', + help_text='Name or ID of device B', error_messages={'invalid_choice': 'Device B not found.'} ) - interface_b = forms.CharField() - status = forms.CharField( - validators=[validate_connection_status] + interface_b = forms.CharField( + help_text='Name of interface B' + ) + connection_status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + help_text='Connection status' ) - def clean(self): + class Meta: + model = InterfaceConnection + fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] - # Validate interface A - if self.cleaned_data.get('device_a'): - try: - interface_a = Interface.objects.get(device=self.cleaned_data['device_a'], - name=self.cleaned_data['interface_a']) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})" - .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a'])) - try: - InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a)) - raise forms.ValidationError("{} {} is already connected" - .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a'])) - except InterfaceConnection.DoesNotExist: - pass + def clean_interface_a(self): - # Validate interface B - if self.cleaned_data.get('device_b'): - try: - interface_b = Interface.objects.get(device=self.cleaned_data['device_b'], - name=self.cleaned_data['interface_b']) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})" - .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b'])) - try: - InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b)) - raise forms.ValidationError("{} {} is already connected" - .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b'])) - except InterfaceConnection.DoesNotExist: - pass + interface_name = self.cleaned_data.get('interface_a') + if not interface_name: + return None + try: + # Retrieve interface by name + interface = Interface.objects.get( + device=self.cleaned_data['device_a'], name=interface_name + ) + # Check for an existing connection to this interface + if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device_a'], interface_name + )) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface ({} {})".format( + self.cleaned_data['device_a'], interface_name + )) -class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=InterfaceConnectionCSVForm) + return interface - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return + def clean_interface_b(self): - connection_list = [] - occupied_interfaces = [] + interface_name = self.cleaned_data.get('interface_b') + if not interface_name: + return None - for i, record in enumerate(records, start=1): - form = self.fields['csv'].csv_form(data=record) - if form.is_valid(): - interface_a = Interface.objects.get(device=form.cleaned_data['device_a'], - name=form.cleaned_data['interface_a']) - if interface_a in occupied_interfaces: - raise forms.ValidationError("{} {} found in multiple connections" - .format(interface_a.device.name, interface_a.name)) - interface_b = Interface.objects.get(device=form.cleaned_data['device_b'], - name=form.cleaned_data['interface_b']) - if interface_b in occupied_interfaces: - raise forms.ValidationError("{} {} found in multiple connections" - .format(interface_b.device.name, interface_b.name)) - connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b) - if form.cleaned_data['status'] == 'planned': - connection.connection_status = CONNECTION_STATUS_PLANNED - else: - connection.connection_status = CONNECTION_STATUS_CONNECTED - connection_list.append(connection) - occupied_interfaces.append(interface_a) - occupied_interfaces.append(interface_b) - else: - for field, errors in form.errors.items(): - for e in errors: - self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + try: + # Retrieve interface by name + interface = Interface.objects.get( + device=self.cleaned_data['device_b'], name=interface_name + ) + # Check for an existing connection to this interface + if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device_b'], interface_name + )) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface ({} {})".format( + self.cleaned_data['device_b'], interface_name + )) - self.cleaned_data['csv'] = connection_list + return interface class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form): diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 043df10dc..bbc27b8d9 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -346,7 +346,7 @@ class RackGroup(models.Model): ] def __str__(self): - return '{} - {}'.format(self.site.name, self.name) + return self.name def get_absolute_url(self): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) @@ -1393,10 +1393,13 @@ class InterfaceConnection(models.Model): verbose_name='Status') def clean(self): - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) + try: + if self.interface_a == self.interface_b: + raise ValidationError({ + 'interface_b': "Cannot connect an interface to itself." + }) + except ObjectDoesNotExist: + pass # Used for connections export def to_csv(self): diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index be80233e0..c9a5ecdc9 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -247,7 +247,7 @@ class RackImportTable(BaseTable): class Meta(BaseTable.Meta): model = Rack - fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height') + fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f6e00be04..01957ccbb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -29,8 +29,8 @@ from . import filters, forms, tables from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, Region, Site, ) @@ -219,9 +219,8 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_site' - form = forms.SiteImportForm + model_form = forms.SiteCSVForm table = tables.SiteTable - template_name = 'dcim/site_import.html' default_return_url = 'dcim:site_list' @@ -390,9 +389,8 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rack' - form = forms.RackImportForm + model_form = forms.RackCSVForm table = tables.RackImportTable - template_name = 'dcim/rack_import.html' default_return_url = 'dcim:rack_list' @@ -866,7 +864,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' - form = forms.DeviceImportForm + model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' default_return_url = 'dcim:device_list' @@ -874,23 +872,22 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' - form = forms.ChildDeviceImportForm + model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' default_return_url = 'dcim:device_list' - def save_obj(self, obj): + def _save_obj(self, obj_form): - # Inherit site and rack from parent device - obj.site = obj.parent_bay.device.site - obj.rack = obj.parent_bay.device.rack - obj.save() + obj = obj_form.save() - # Save the reverse relation + # Save the reverse relation to the parent device bay device_bay = obj.parent_bay device_bay.installed_device = obj device_bay.save() + return obj + class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' @@ -1016,9 +1013,8 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_consoleport' - form = forms.ConsoleConnectionImportForm + model_form = forms.ConsoleConnectionCSVForm table = tables.ConsoleConnectionTable - template_name = 'dcim/console_connections_import.html' default_return_url = 'dcim:console_connections_list' @@ -1239,9 +1235,8 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_powerport' - form = forms.PowerConnectionImportForm + model_form = forms.PowerConnectionCSVForm table = tables.PowerConnectionTable - template_name = 'dcim/power_connections_import.html' default_return_url = 'dcim:power_connections_list' @@ -1676,9 +1671,8 @@ def interfaceconnection_delete(request, pk): class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_interface' - form = forms.InterfaceConnectionImportForm + model_form = forms.InterfaceConnectionCSVForm table = tables.InterfaceConnectionTable - template_name = 'dcim/interface_connections_import.html' default_return_url = 'dcim:interface_connections_list' diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3bc8124ea..82e489fa8 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from django import forms -from django.core.exceptions import ValidationError from django.db.models import Count from dcim.models import Site, Rack, Device, Interface @@ -9,8 +8,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField, - ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, + APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField, + ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField, + add_blank_choice, ) from .models import ( Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, @@ -48,17 +48,23 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VRFFromCSVForm(forms.ModelForm): - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) +class VRFCSVForm(forms.ModelForm): + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) class Meta: model = VRF fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - - -class VRFImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=VRFFromCSVForm) + help_texts = { + 'name': 'VRF name', + } class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -116,19 +122,21 @@ class AggregateForm(BootstrapMixin, CustomFieldForm): } -class AggregateFromCSVForm(forms.ModelForm): - rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'RIR not found.'}) +class AggregateCSVForm(forms.ModelForm): + rir = forms.ModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text='Name of parent RIR', + error_messages={ + 'invalid_choice': 'RIR not found.', + } + ) class Meta: model = Aggregate fields = ['prefix', 'rir', 'date_added', 'description'] -class AggregateImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=AggregateFromCSVForm) - - class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') @@ -197,69 +205,89 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class PrefixFromCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', - error_messages={'invalid_choice': 'VRF not found.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - vlan_group_name = forms.CharField(required=False) - vlan_vid = forms.IntegerField(required=False) - status = forms.CharField() - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid role.'}) +class PrefixCSVForm(forms.ModelForm): + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Route distinguisher of parent VRF', + error_messages={ + 'invalid_choice': 'VRF not found.', + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + vlan_group = forms.CharField( + help_text='Group name of assigned VLAN', + required=False + ) + vlan_vid = forms.IntegerField( + help_text='Numeric ID of assigned VLAN', + required=False + ) + status = CSVChoiceField( + choices=IPADDRESS_STATUS_CHOICES, + help_text='Operational status' + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role', + error_messages={ + 'invalid_choice': 'Invalid role.', + } + ) class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool', - 'description', + 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', ] def clean(self): - super(PrefixFromCSVForm, self).clean() + super(PrefixCSVForm, self).clean() site = self.cleaned_data.get('site') - vlan_group_name = self.cleaned_data.get('vlan_group_name') + vlan_group = self.cleaned_data.get('vlan_group') vlan_vid = self.cleaned_data.get('vlan_vid') - vlan_group = None - - # Validate VLAN group - if vlan_group_name: - try: - vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name) - except VLANGroup.DoesNotExist: - if site: - self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name)) - else: - self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name)) # Validate VLAN - if vlan_vid: + if vlan_group and vlan_vid: try: - self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid) + self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid) except VLAN.DoesNotExist: if site: - self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site)) - elif vlan_group: - self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name)) - elif not vlan_group_name: - self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid)) - except VLAN.MultipleObjectsReturned: - self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid)) - - def clean_status(self): - status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES} - try: - return status_choices[self.cleaned_data['status'].lower()] - except KeyError: - raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) - - -class PrefixImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=PrefixFromCSVForm) + 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)) + 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)) class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -513,16 +541,46 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): self.fields['vrf'].empty_label = 'Global' -class IPAddressFromCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', - error_messages={'invalid_choice': 'VRF not found.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - status = forms.CharField() - device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Device not found.'}) - interface_name = forms.CharField(required=False) - is_primary = forms.BooleanField(required=False) +class IPAddressCSVForm(forms.ModelForm): + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Route distinguisher of the assigned VRF', + error_messages={ + 'invalid_choice': 'VRF not found.', + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Name of the assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + status = CSVChoiceField( + choices=PREFIX_STATUS_CHOICES, + help_text='Operational status' + ) + device = FlexibleModelChoiceField( + 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.', + } + ) + interface_name = forms.CharField( + help_text='Name of assigned interface', + required=False + ) + is_primary = forms.BooleanField( + help_text='Make this the primary IP for the assigned device', + required=False + ) class Meta: model = IPAddress @@ -530,6 +588,8 @@ class IPAddressFromCSVForm(forms.ModelForm): def clean(self): + super(IPAddressCSVForm, self).clean() + device = self.cleaned_data.get('device') interface_name = self.cleaned_data.get('interface_name') is_primary = self.cleaned_data.get('is_primary') @@ -537,24 +597,17 @@ class IPAddressFromCSVForm(forms.ModelForm): # Validate interface if device and interface_name: try: - Interface.objects.get(device=device, name=interface_name) + self.instance.interface = Interface.objects.get(device=device, name=interface_name) except Interface.DoesNotExist: - self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device)) + raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device)) elif device and not interface_name: - self.add_error('interface_name', "Device set ({}) but interface missing".format(device)) + raise forms.ValidationError("Device set ({}) but interface missing".format(device)) elif interface_name and not device: - self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name)) + raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name)) # Validate is_primary if is_primary and not device: - self.add_error('is_primary', "No device specified; cannot set as primary IP") - - def clean_status(self): - status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES} - try: - return status_choices[self.cleaned_data['status'].lower()] - except KeyError: - raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) + raise forms.ValidationError("No device specified; cannot set as primary IP") def save(self, *args, **kwargs): @@ -569,11 +622,7 @@ class IPAddressFromCSVForm(forms.ModelForm): elif self.instance.address.version == 6: self.instance.primary_ip6_for = self.cleaned_data['device'] - return super(IPAddressFromCSVForm, self).save(*args, **kwargs) - - -class IPAddressImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=IPAddressFromCSVForm) + return super(IPAddressCSVForm, self).save(*args, **kwargs) class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -673,60 +722,67 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): } -class VLANFromCSVForm(forms.ModelForm): +class VLANCSVForm(forms.ModelForm): site = forms.ModelChoiceField( - queryset=Site.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'} + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + group_name = forms.CharField( + help_text='Name of VLAN group', + required=False ) - group_name = forms.CharField(required=False) tenant = forms.ModelChoiceField( - Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'} + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + status = CSVChoiceField( + choices=VLAN_STATUS_CHOICES, + help_text='Operational status' ) - status = forms.CharField() role = forms.ModelChoiceField( - queryset=Role.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid role.'} + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role', + error_messages={ + 'invalid_choice': 'Invalid role.', + } ) class Meta: model = VLAN fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + help_texts = { + 'vid': 'Numeric VLAN ID (1-4095)', + 'name': 'VLAN name', + } def clean(self): - super(VLANFromCSVForm, self).clean() + super(VLANCSVForm, self).clean() - # Validate VLANGroup + site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') + + # Validate VLAN group if group_name: try: - VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name) + self.instance.group = VLANGroup.objects.get(site=site, name=group_name) except VLANGroup.DoesNotExist: - self.add_error('group_name', "Invalid VLAN group {}.".format(group_name)) - - def clean_status(self): - status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES} - try: - return status_choices[self.cleaned_data['status'].lower()] - except KeyError: - raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) - - def save(self, *args, **kwargs): - - vlan = super(VLANFromCSVForm, self).save(commit=False) - - # Assign VLANGroup by site and name - if self.cleaned_data['group_name']: - vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name']) - - if kwargs.get('commit'): - vlan.save() - return vlan - - -class VLANImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=VLANFromCSVForm) + 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)) class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 01cdd406d..04f193dd4 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -498,9 +498,7 @@ class VLANGroup(models.Model): verbose_name_plural = 'VLAN groups' def __str__(self): - if self.site is None: - return self.name - return '{} - {}'.format(self.site.name, self.name) + return self.name def get_absolute_url(self): return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a51f47b6e..f8fe0535a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -130,9 +130,8 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vrf' - form = forms.VRFImportForm + model_form = forms.VRFCSVForm table = tables.VRFTable - template_name = 'ipam/vrf_import.html' default_return_url = 'ipam:vrf_list' @@ -341,9 +340,8 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_aggregate' - form = forms.AggregateImportForm + model_form = forms.AggregateCSVForm table = tables.AggregateTable - template_name = 'ipam/aggregate_import.html' default_return_url = 'ipam:aggregate_list' @@ -538,9 +536,8 @@ class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_prefix' - form = forms.PrefixImportForm + model_form = forms.PrefixCSVForm table = tables.PrefixTable - template_name = 'ipam/prefix_import.html' default_return_url = 'ipam:prefix_list' @@ -640,9 +637,8 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_ipaddress' - form = forms.IPAddressImportForm + model_form = forms.IPAddressCSVForm table = tables.IPAddressTable - template_name = 'ipam/ipaddress_import.html' default_return_url = 'ipam:ipaddress_list' def save_obj(self, obj): @@ -748,9 +744,8 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vlan' - form = forms.VLANImportForm + model_form = forms.VLANCSVForm table = tables.VLANTable - template_name = 'ipam/vlan_import.html' default_return_url = 'ipam:vlan_list' diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b8e165804..aafa9c4b1 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -7,7 +7,7 @@ from django import forms from django.db.models import Count from dcim.models import Device -from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField +from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField from .models import Secret, SecretRole, UserKey @@ -65,27 +65,40 @@ class SecretForm(BootstrapMixin, forms.ModelForm): }) -class SecretFromCSVForm(forms.ModelForm): - device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Device not found.'}) - role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid secret role.'}) - plaintext = forms.CharField() +class SecretCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Device name or ID', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + role = forms.ModelChoiceField( + queryset=SecretRole.objects.all(), + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Invalid secret role.', + } + ) + plaintext = forms.CharField( + help_text='Plaintext secret data' + ) class Meta: model = Secret fields = ['device', 'role', 'name', 'plaintext'] + help_texts = { + 'name': 'Name or username', + } def save(self, *args, **kwargs): - s = super(SecretFromCSVForm, self).save(*args, **kwargs) + s = super(SecretCSVForm, self).save(*args, **kwargs) s.plaintext = str(self.cleaned_data['plaintext']) return s -class SecretImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-session-key'})) - - class SecretBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 4961b2c82..b28198a2f 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -16,7 +16,7 @@ urlpatterns = [ # Secrets url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), - url(r'^secrets/import/$', views.secret_import, name='secret_import'), + url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'), url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), url(r'^secrets/(?P\d+)/$', views.SecretView.as_view(), name='secret'), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index d2427dd73..e046f1dbc 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -12,7 +12,9 @@ from django.utils.decorators import method_decorator from django.views.generic import View from dcim.models import Device -from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView +from utilities.views import ( + BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, +) from . import filters, forms, tables from .decorators import userkey_required from .models import SecretRole, Secret, SessionKey @@ -185,58 +187,50 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'secrets:secret_list' -@permission_required('secrets.add_secret') -@userkey_required() -def secret_import(request): +class SecretBulkImportView(BulkImportView): + permission_required = 'ipam.add_vlan' + model_form = forms.SecretCSVForm + table = tables.SecretTable + default_return_url = 'secrets:secret_list' - session_key = request.COOKIES.get('session_key', None) + master_key = None - if request.method == 'POST': - form = forms.SecretImportForm(request.POST) + def _save_obj(self, obj_form): + """ + Encrypt each object before saving it to the database. + """ + obj = obj_form.save(commit=False) + obj.encrypt(self.master_key) + obj.save() + return obj - if session_key is None: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") + def post(self, request): - if form.is_valid(): + # Grab the session key from cookies. + session_key = request.COOKIES.get('session_key') + if session_key: - new_secrets = [] - - session_key = base64.b64decode(session_key) - master_key = None + # Attempt to derive the master key using the provided session key. try: sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) + self.master_key = sk.get_master_key(base64.b64decode(session_key)) except SessionKey.DoesNotExist: - form.add_error(None, "No session key found for this user.") + messages.error(request, "No session key found for this user.") - if master_key is None: - form.add_error(None, "Invalid private key! Unable to encrypt secret data.") + if self.master_key is not None: + return super(SecretBulkImportView, self).post(request) else: - try: - with transaction.atomic(): - for secret in form.cleaned_data['csv']: - secret.encrypt(master_key) - secret.save() - new_secrets.append(secret) + messages.error(request, "Invalid private key! Unable to encrypt secret data.") - table = tables.SecretTable(new_secrets) - messages.success(request, "Imported {} new secrets.".format(len(new_secrets))) + else: + messages.error(request, "No session key was provided with the request. Unable to encrypt secret data.") - return render(request, 'import_success.html', { - 'table': table, - 'return_url': 'secrets:secret_list', - }) - - except IntegrityError as e: - form.add_error('csv', "Record {}: {}".format(len(new_secrets) + 1, e.__cause__)) - - else: - form = forms.SecretImportForm() - - return render(request, 'secrets/secret_import.html', { - 'form': form, - 'return_url': 'secrets:secret_list', - }) + return render(request, self.template_name, { + 'form': self._import_form(request.POST), + 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, + 'return_url': self.default_return_url, + }) class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): diff --git a/netbox/templates/circuits/circuit_import.html b/netbox/templates/circuits/circuit_import.html deleted file mode 100644 index 4b0c40b09..000000000 --- a/netbox/templates/circuits/circuit_import.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Circuit Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
Circuit IDAlphanumeric circuit identifierIC-603122
ProviderName of circuit providerTeliaSonera
TypeCircuit typeTransit
TenantName of tenant (optional)Strickland Propane
Install DateDate in YYYY-MM-DD format (optional)2016-02-23
Commit rateCommited rate in Kbps (optional)2000
DescriptionShort description (optional)Primary for voice
-

Example

-
IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice
-{% endblock %} diff --git a/netbox/templates/circuits/provider_import.html b/netbox/templates/circuits/provider_import.html deleted file mode 100644 index 2ab2e5efb..000000000 --- a/netbox/templates/circuits/provider_import.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Provider Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameProvider's proper nameLevel 3
SlugURL-friendly namelevel3
ASNAutonomous system number (optional)3356
AccountAccount number (optional)08931544
Portal URLCustomer service portal URL (optional)https://mylevel3.net
-

Example

-
Level 3,level3,3356,08931544,https://mylevel3.net
-{% endblock %} diff --git a/netbox/templates/dcim/console_connections_import.html b/netbox/templates/dcim/console_connections_import.html deleted file mode 100644 index 3dc0f96a4..000000000 --- a/netbox/templates/dcim/console_connections_import.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Console Connections Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
Console serverDevice name or {ID}abc1-cs3
Console server portFull CS port namePort 35
DeviceDevice name or {ID}abc1-switch7
Console PortConsole port nameConsole
Connection Status"planned" or "connected"planned
-

Example

-
abc1-cs3,Port 35,abc1-switch7,Console,planned
-{% endblock %} diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index 8a1cfa1a5..85ebfbbc6 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -1,103 +1,5 @@ -{% extends '_base.html' %} -{% load form_helpers %} +{% extends 'utilities/obj_import.html' %} -{% block title %}Device Import{% endblock %} - -{% block content %} -{% include 'dcim/inc/device_import_header.html' %} -
-
-
- {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel - {% endif %} -
-
-
-

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameDevice name (optional)rack101_sw1
Device roleFunctional role of deviceToR Switch
TenantName of tenant (optional)Pied Piper
Device manufacturerHardware manufacturerJuniper
Device modelHardware modelEX4300-48T
PlatformSoftware running on device (optional)Juniper Junos
Serial numberPhysical serial number (optional)CAB00577291
Asset tagUnique alphanumeric tag (optional)ABC123456
StatusCurrent statusActive
SiteSite nameAshburn-VA
RackRack name (optional)R101
Position (U)Lowest-numbered rack unit occupied by the device (optional)21
FaceRack face; front or rear (required if position is set)Rear
-

Example

-
rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Active,Ashburn-VA,R101,21,Rear
-
-
+{% block tabs %} + {% include 'dcim/inc/device_import_header.html' %} {% endblock %} diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html index 668a9c810..406d239d7 100644 --- a/netbox/templates/dcim/device_import_child.html +++ b/netbox/templates/dcim/device_import_child.html @@ -1,93 +1,5 @@ -{% extends '_base.html' %} -{% load form_helpers %} +{% extends 'utilities/obj_import.html' %} -{% block title %}Device Import{% endblock %} - -{% block content %} -{% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} -
-
-
- {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel - {% endif %} -
-
-
-

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameDevice name (optional)Blade12
Device roleFunctional role of deviceBlade Server
TenantName of tenant (optional)Pied Piper
Device manufacturerHardware manufacturerDell
Device modelHardware modelBS2000T
PlatformSoftware running on device (optional)Linux
Serial numberPhysical serial number (optional)CAB00577291
Asset tagUnique alphanumeric tag (optional)ABC123456
StatusCurrent statusActive
Parent deviceParent deviceServer101
Device bayDevice bay nameSlot 4
-

Example

-
Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Active,Server101,Slot4
-
-
+{% block tabs %} + {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} {% endblock %} diff --git a/netbox/templates/dcim/inc/device_import_header.html b/netbox/templates/dcim/inc/device_import_header.html index 57dd1b46e..2adc867b1 100644 --- a/netbox/templates/dcim/inc/device_import_header.html +++ b/netbox/templates/dcim/inc/device_import_header.html @@ -1,4 +1,3 @@ -

Device Import