diff --git a/docs/api/overview.md b/docs/api/overview.md index 5f8e43973a7..a9ad115f897 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -136,3 +136,8 @@ The response will return devices 1 through 100. The URL provided in the `next` a "results": [...] } ``` + +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings/#max_page_size) setting, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. + +!!! warning + Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index ed5d2c03c59..6c68ca386b1 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -99,6 +99,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever --- +## MAX_PAGE_SIZE + +Default: 1000 + +An API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This setting defines the maximum limit. Setting it to `0` or `None` will allow an API consumer to request all objects by specifying `?limit=0`. + +--- + ## NETBOX_USERNAME ## NETBOX_PASSWORD diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index 6a4994a5c93..2555ccb1254 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -1,5 +1,4 @@ -This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to -built-in Django users in the event of a failure. +This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to built-in Django users in the event of a failure. # Requirements @@ -50,6 +49,9 @@ AUTH_LDAP_BIND_PASSWORD = "demo" LDAP_IGNORE_CERT_ERRORS = True ``` +!!! info + When using Windows Server 2012 you may need to specify a port on AUTH_LDAP_SERVER_URI - 3269 for secure, 3268 for non-secure. + ## User Authentication ```python @@ -71,6 +73,9 @@ AUTH_LDAP_USER_ATTR_MAP = { } ``` +!!! info + When using Windows Server 2012 AUTH_LDAP_USER_DN_TEMPLATE should be set to None. + # User Groups for Permissions ```python @@ -99,3 +104,17 @@ AUTH_LDAP_FIND_GROUP_PERMS = True AUTH_LDAP_CACHE_GROUPS = True AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 ``` + +* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in. +* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions. +* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions. + +!!! info + It is also possible map user attributes to Django attributes: + + ```no-highlight + AUTH_LDAP_USER_ATTR_MAP = { + "first_name": "givenName", + "last_name": "sn" + } + ``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 46b2252f7e5..6d0db728d99 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -64,7 +64,7 @@ This script: Your models have changes that are not yet reflected in a migration, and so won't be applied. Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them. - This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are inentionally modifying the database schema. + This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema. # Restart the WSGI Service diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 79cad0a6b23..46472b228a6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -8,8 +8,8 @@ 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 Meta: } -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 Meta: } -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 eed612a33e5..35e37f4c537 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 9e1cc657d2f..6aef5808c92 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,7 +5,6 @@ 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.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 Meta: } -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 Meta: } -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 @@ def __init__(self, *args, **kwargs): 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 @@ def clean(self): 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 Meta(BaseDeviceFromCSVForm.Meta): 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 @@ def clean(self): # 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'])) - - # 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'])) + def clean_console_port(self): + console_port_name = self.cleaned_data.get('console_port') + if not self.cleaned_data.get('device') or not console_port_name: + return None -class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ConsoleConnectionCSVForm) + 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 + )) - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return - - connection_list = [] - - 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)) + self.instance = consoleport + return consoleport + + def clean_cs_port(self): + + cs_port_name = self.cleaned_data.get('cs_port') + if not self.cleaned_data.get('console_server') or not cs_port_name: + return None + + 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'])) - - # 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'])) + def clean_power_port(self): + power_port_name = self.cleaned_data.get('power_port') + if not self.cleaned_data.get('device') or not power_port_name: + return None -class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=PowerConnectionCSVForm) + 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 + )) - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return - - connection_list = [] - - 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)) + self.instance = powerport + return powerport - self.cleaned_data['csv'] = connection_list + def clean_power_outlet(self): + + power_outlet_name = self.cleaned_data.get('power_outlet') + if not self.cleaned_data.get('pdu') or not power_outlet_name: + return None + + 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 + )) + + return power_outlet class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): @@ -1536,94 +1599,79 @@ def __init__(self, device_a, *args, **kwargs): ] -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 + )) + return interface -class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=InterfaceConnectionCSVForm) + def clean_interface_b(self): - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return - - connection_list = [] - occupied_interfaces = [] - - 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)) + interface_name = self.cleaned_data.get('interface_b') + if not interface_name: + return None + + 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 043df10dc01..bbc27b8d9a0 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -346,7 +346,7 @@ class Meta: ] 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 be80233e0f1..c9a5ecdc905 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 f6e00be04b8..01957ccbbb9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -29,8 +29,8 @@ 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/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5bd22189320..da15ce1aa5b 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from datetime import datetime from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -6,7 +7,9 @@ from django.contrib.contenttypes.models import ContentType from django.db import transaction -from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue +from extras.models import ( + CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue, +) # @@ -25,16 +28,30 @@ def to_internal_value(self, data): for field_name, value in data.items(): + cf = custom_fields[field_name] + # Validate custom field name if field_name not in custom_fields: raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name)) + # Validate boolean + if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]: + raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value)) + + # Validate date + if cf.type == CF_TYPE_DATE: + try: + datetime.strptime(value, '%Y-%m-%d') + except ValueError: + raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format( + field_name, value + )) + # Validate selected choice - cf = custom_fields[field_name] if cf.type == CF_TYPE_SELECT: valid_choices = [c.pk for c in cf.choices.all()] if value not in valid_choices: - raise ValidationError("Invalid choice ({}) for field {}".format(value, field_name)) + raise ValidationError("Invalid choice for field {}: {}".format(field_name, value)) # Check for missing required fields missing_fields = [] @@ -87,7 +104,7 @@ def _save_custom_fields(self, instance, custom_fields): field=custom_field, obj_type=content_type, obj_id=instance.pk, - defaults={'serialized_value': value}, + defaults={'serialized_value': custom_field.serialize_value(value)}, ) def create(self, validated_data): diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index e44fb86e9e0..6bd5f3737a4 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -32,7 +32,7 @@ def filter(self, queryset, value): pass return queryset.filter( custom_field_values__field__name=self.name, - custom_field_values__serialized_value=value, + custom_field_values__serialized_value__icontains=value, ) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ea92fae0ca3..bea8a664b77 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -139,7 +139,11 @@ def serialize_value(self, value): if self.type == CF_TYPE_BOOLEAN: return str(int(bool(value))) if self.type == CF_TYPE_DATE: - return value.strftime('%Y-%m-%d') + # Could be date/datetime object or string + try: + return value.strftime('%Y-%m-%d') + except AttributeError: + return value if self.type == CF_TYPE_SELECT: # Could be ModelChoiceField or TypedChoiceField return str(value.id) if hasattr(value, 'id') else str(value) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 096f1d23271..f4493719f96 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -137,7 +137,7 @@ def validate(self, data): # Validate uniqueness of name and slug if a site has been assigned. if data.get('site', None): for field in ['name', 'slug']: - validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('site', field)) + validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field)) validator.set_context(self) validator(data) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 54146e91a35..11c19b7eebf 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -85,7 +85,7 @@ def search(self, queryset, name, value): try: prefix = str(IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) - except AddrFormatError: + except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -172,7 +172,7 @@ def search(self, queryset, name, value): try: prefix = str(IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) - except AddrFormatError: + except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -183,7 +183,7 @@ def search_by_parent(self, queryset, name, value): try: query = str(IPNetwork(value).cidr) return queryset.filter(prefix__net_contained_or_equal=query) - except AddrFormatError: + except (AddrFormatError, ValueError): return queryset.none() def filter_mask_length(self, queryset, name, value): @@ -259,7 +259,7 @@ def search(self, queryset, name, value): try: ipaddress = str(IPNetwork(value.strip())) qs_filter |= Q(address__net_host=ipaddress) - except AddrFormatError: + except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -270,7 +270,7 @@ def search_by_parent(self, queryset, name, value): try: query = str(IPNetwork(value.strip()).cidr) return queryset.filter(address__net_host_contained=query) - except AddrFormatError: + except (AddrFormatError, ValueError): return queryset.none() def filter_mask_length(self, queryset, name, value): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3bc8124eae9..82e489fa840 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 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 Meta: } -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 Meta: } -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 @@ def __init__(self, *args, **kwargs): 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: + # Validate VLAN + if vlan_group and vlan_vid: try: - vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name) - except VLANGroup.DoesNotExist: + self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid) + except VLAN.DoesNotExist: if site: - self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name)) + raise forms.ValidationError("VLAN {} not found in site {} group {}".format( + vlan_vid, site, vlan_group + )) else: - self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name)) - - # Validate VLAN - if vlan_vid: + 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=vlan_group, vid=vlan_vid) + self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, 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 {}".format(vlan_vid, site)) + else: + raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid)) class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -513,16 +541,46 @@ def __init__(self, *args, **kwargs): 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 Meta: 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 @@ def clean(self): # 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 @@ def save(self, *args, **kwargs): 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 Meta: } -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 01cdd406dd1..04f193dd486 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -498,9 +498,7 @@ class Meta: 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 a51f47b6ea4..f8fe0535a5a 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/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index f185a68c785..bc255bac360 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -79,6 +79,11 @@ # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False +# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. +# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request +# all objects by specifying "?limit=0". +MAX_PAGE_SIZE = 1000 + # Credentials that NetBox will use to access live devices (future use). NETBOX_USERNAME = '' NETBOX_PASSWORD = '' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c6d48454a52..4228f1b2730 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.0.4' +VERSION = '2.0.5' # Import local configuration ALLOWED_HOSTS = DATABASE = SECRET_KEY = None @@ -48,6 +48,7 @@ BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) +MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) @@ -208,7 +209,7 @@ 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework.filters.DjangoFilterBackend', ), - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( 'utilities.api.TokenPermissions', ), diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 4bc7b56cd93..49cc03c176e 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -32,7 +32,7 @@ class SecretFilter(django_filters.FilterSet): label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( - name='device', + name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b8e16580430..aafa9c4b11d 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -7,7 +7,7 @@ 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 @@ def clean(self): }) -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 4961b2c827d..b28198a2ff3 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -16,7 +16,7 @@ # 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 d2427dd7304..e046f1dbc32 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -12,7 +12,9 @@ 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): - - session_key = request.COOKIES.get('session_key', None) +class SecretBulkImportView(BulkImportView): + permission_required = 'ipam.add_vlan' + model_form = forms.SecretCSVForm + table = tables.SecretTable + default_return_url = 'secrets:secret_list' - if request.method == 'POST': - form = forms.SecretImportForm(request.POST) + master_key = None - if session_key is None: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") + 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 form.is_valid(): + def post(self, request): - new_secrets = [] + # Grab the session key from cookies. + session_key = request.COOKIES.get('session_key') + if session_key: - 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) - - table = tables.SecretTable(new_secrets) - messages.success(request, "Imported {} new secrets.".format(len(new_secrets))) - - 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__)) + messages.error(request, "Invalid private key! Unable to encrypt secret data.") - else: - form = forms.SecretImportForm() + else: + messages.error(request, "No session key was provided with the request. Unable to encrypt secret data.") - 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 4b0c40b093f..00000000000 --- 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.html b/netbox/templates/circuits/provider.html index 3cdeea36f9b..35562a7a30f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -181,7 +181,7 @@

{{ provider }}

{% empty %} - None + None {% endfor %} diff --git a/netbox/templates/circuits/provider_import.html b/netbox/templates/circuits/provider_import.html deleted file mode 100644 index 2ab2e5efb6e..00000000000 --- 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 3dc0f96a4dd..00000000000 --- 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 8a1cfa1a56f..85ebfbbc6bc 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 668a9c81019..406d239d735 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 57dd1b46eae..2adc867b192 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