From d7f594f7fbade51b18ec6557b95573221c3511a5 Mon Sep 17 00:00:00 2001 From: bellwood Date: Thu, 13 Apr 2017 17:03:58 -0400 Subject: [PATCH 01/36] Enhance LDAP documentation Incorporating @marvnrawley's enhancements from #518 --- docs/installation/ldap.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index 6a4994a5c..9231e422f 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -49,6 +49,8 @@ AUTH_LDAP_BIND_PASSWORD = "demo" # ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 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 @@ -70,6 +72,8 @@ AUTH_LDAP_USER_ATTR_MAP = { "last_name": "sn" } ``` +!!! info + When using Windows Server 2012 AUTH_LDAP_USER_DN_TEMPLATE should be set to None. # User Groups for Permissions @@ -99,3 +103,17 @@ AUTH_LDAP_FIND_GROUP_PERMS = True AUTH_LDAP_CACHE_GROUPS = True AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 ``` + +!!! info + "is_active" - you must map all users to at least this group if you want their account to be treated as enabled. Without this, your 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 necessarily imply additional privileges, which still needed to be assigned via a group, or on a per-user basis. + +"is_superuser" - users mapped to this group in addition to the "is_staff" group will be assumed to have full permissions to all modules. Without also being mapped to "is_staff", this group observably has no impact to your effective permissions. + +!!! info + It is also possible map user attributes to Django attributes: +AUTH_LDAP_USER_ATTR_MAP = { +"first_name": "givenName", +"last_name": "sn" +} From ecab1ef2e9e0044113439f112edc16ef48731899 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 25 May 2017 14:46:34 -0400 Subject: [PATCH 02/36] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c6d48454a..7eec50475 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.0.4' +VERSION = '2.0.5-dev' # Import local configuration ALLOWED_HOSTS = DATABASE = SECRET_KEY = None From ff964882b4898e0cab908988b86e3b8ff413022a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 May 2017 09:55:22 -0400 Subject: [PATCH 03/36] Tweaked upgrade script to prefer pip3/python3 if present --- upgrade.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/upgrade.sh b/upgrade.sh index 53e0c1db6..f8ce22a6f 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -20,9 +20,11 @@ COMMAND="${PREFIX}find . -name \"*.pyc\" -delete" echo "Cleaning up stale Python bytecode ($COMMAND)..." eval $COMMAND -# Fall back to pip3 if pip is missing -PIP="pip" -type $PIP >/dev/null 2>&1 || PIP="pip3" +# Prefer python3/pip3 +PYTHON="python3" +type $PYTHON >/dev/null 2>&1 || PYTHON="python" +PIP="pip3" +type $PIP >/dev/null 2>&1 || PIP="pip" # Install any new Python packages COMMAND="${PREFIX}${PIP} install -r requirements.txt --upgrade" @@ -30,11 +32,11 @@ echo "Updating required Python packages ($COMMAND)..." eval $COMMAND # Apply any database migrations -COMMAND="./netbox/manage.py migrate" +COMMAND="${PYTHON} netbox/manage.py migrate" echo "Applying database migrations ($COMMAND)..." eval $COMMAND # Collect static files -COMMAND="./netbox/manage.py collectstatic --no-input" +COMMAND="${PYTHON} netbox/manage.py collectstatic --no-input" echo "Collecting static files ($COMMAND)..." eval $COMMAND From f28f462277f47e3679788f15a4501e24bc73d8fe Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 26 May 2017 10:08:03 -0400 Subject: [PATCH 04/36] Fixes #1225: Fixed border on empty circuits table on provider view --- netbox/templates/circuits/provider.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 3cdeea36f..35562a7a3 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -181,7 +181,7 @@ {% empty %} - None + None {% endfor %} From e2d5f934b247bdbdd0c30ea12371001105970afd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 May 2017 13:03:25 -0400 Subject: [PATCH 05/36] Fixes #1235: Fix permission name for adding/editing inventory items --- netbox/templates/dcim/inc/inventoryitem.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 01281c317..6aa77d1c2 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -5,10 +5,10 @@ {{ item.part_id }} {{ item.serial }} - {% if perms.dcim.change_inventory_item %} + {% if perms.dcim.change_inventoryitem %} {% endif %} - {% if perms.dcim.delete_inventory_item %} + {% if perms.dcim.delete_inventoryitem %} {% endif %} From fc0ae93f9d6a2f1a1804aa47db790201313a1d09 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 May 2017 13:13:01 -0400 Subject: [PATCH 06/36] Fixes #1236: Truncate rack names in elevations list; add facility ID --- netbox/templates/dcim/rack_elevation_list.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index c5c060940..9f6c4df83 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -14,7 +14,8 @@ {% for rack in page %}
-

{{ rack.name }}

+ {{ rack.name|truncatechars:"25" }} +

{{ rack.facility_id|truncatechars:"30" }}

{% if face_id %} {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %} @@ -23,7 +24,8 @@ {% endif %}
-

{{ rack.name }}

+ {{ rack.name|truncatechars:"25" }} +

{{ rack.facility_id|truncatechars:"30" }}

{% endfor %} From 6a4e7695a61797b4b181702c4243e594138d6258 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 30 May 2017 23:24:21 -0400 Subject: [PATCH 07/36] Closes #1237: Enabled setting limit=0 to disable pagination in API requests; added MAX_PAGE_SIZE configuration setting --- docs/api/overview.md | 5 +++ docs/configuration/optional-settings.md | 8 ++++ netbox/netbox/configuration.example.py | 5 +++ netbox/netbox/settings.py | 3 +- netbox/utilities/api.py | 49 +++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/api/overview.md b/docs/api/overview.md index 5f8e43973..a9ad115f8 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 ed5d2c03c..6c68ca386 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/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index f185a68c7..bc255bac3 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -79,6 +79,11 @@ LOGIN_REQUIRED = False # 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 7eec50475..acdf8e38b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -48,6 +48,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', False) 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 @@ REST_FRAMEWORK = { '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/utilities/api.py b/netbox/utilities/api.py index a587c67d1..6fcfc6949 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -5,6 +5,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import authentication, exceptions from rest_framework.exceptions import APIException +from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS from rest_framework.serializers import Field, ValidationError @@ -105,3 +106,51 @@ class WritableSerializerMixin(object): if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'): return self.write_serializer_class return self.serializer_class + + +class OptionalLimitOffsetPagination(LimitOffsetPagination): + """ + Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects + matching a query, but retains the same format as a paginated request. The limit can only be disabled if + MAX_PAGE_SIZE has been set to 0 or None. + """ + + def paginate_queryset(self, queryset, request, view=None): + + try: + self.count = queryset.count() + except (AttributeError, TypeError): + self.count = len(queryset) + self.limit = self.get_limit(request) + self.offset = self.get_offset(request) + self.request = request + + if self.limit and self.count > self.limit and self.template is not None: + self.display_page_controls = True + + if self.count == 0 or self.offset > self.count: + return list() + + if self.limit: + return list(queryset[self.offset:self.offset + self.limit]) + else: + return list(queryset[self.offset:]) + + def get_limit(self, request): + + if self.limit_query_param: + try: + limit = int(request.query_params[self.limit_query_param]) + if limit < 0: + raise ValueError() + # Enforce maximum page size, if defined + if settings.MAX_PAGE_SIZE: + if limit == 0: + return settings.MAX_PAGE_SIZE + else: + return min(limit, settings.MAX_PAGE_SIZE) + return limit + except (KeyError, ValueError): + pass + + return self.default_limit From 64424a497db7813ade86e23cc529770592d08a53 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 May 2017 11:50:03 -0400 Subject: [PATCH 08/36] Fixes #1239: Fix server error when creating VLANGroup via API --- netbox/ipam/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 096f1d232..f4493719f 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -137,7 +137,7 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): # 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) From 4686b266a672c2f908f9dccae38ae13174ccc049 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 May 2017 14:09:57 -0400 Subject: [PATCH 09/36] Fixes #1226: Improve validation for custom field values submitted via the API --- netbox/extras/api/customfields.py | 25 +++++++++++++++++++++---- netbox/extras/models.py | 6 +++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5bd221893..da15ce1aa 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 rest_framework.exceptions import ValidationError 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 @@ class CustomFieldsSerializer(serializers.BaseSerializer): 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 @@ class CustomFieldModelSerializer(serializers.ModelSerializer): 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/models.py b/netbox/extras/models.py index ea92fae0c..bea8a664b 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -139,7 +139,11 @@ class CustomField(models.Model): 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) From cac3d5a6e645af3491852625bd922ed8593c51ba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 May 2017 17:40:11 -0400 Subject: [PATCH 10/36] Initial work on #655: CSV import headers --- netbox/templates/tenancy/tenant_import.html | 37 --------- netbox/templates/utilities/obj_import.html | 17 +++++ netbox/tenancy/forms.py | 21 +++--- netbox/tenancy/views.py | 6 +- netbox/utilities/forms.py | 56 +++++++++++++- netbox/utilities/views.py | 83 ++++++++++++++++++++- 6 files changed, 168 insertions(+), 52 deletions(-) diff --git a/netbox/templates/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html index 2e5196a88..4d0e97f48 100644 --- a/netbox/templates/tenancy/tenant_import.html +++ b/netbox/templates/tenancy/tenant_import.html @@ -1,40 +1,3 @@ {% extends 'utilities/obj_import.html' %} {% block title %}Tenant Import{% endblock %} - -{% block instructions %} -

CSV Format

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDescriptionExample
NameTenant nameWIDG01
SlugURL-friendly namewidg01
GroupTenant group (optional)Customers
DescriptionLong-form name or other text (optional)Widgets Inc.
-

Example

-
WIDG01,widg01,Customers,Widgets Inc.
-{% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 2d69be048..325cfa304 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -28,6 +28,23 @@
{% block instructions %}{% endblock %} + {% if fields %} +

CSV Format

+ + + + + + + {% for name, field in fields.items %} + + + + + + {% endfor %} +
FieldRequiredDescription
{{ name }}{% if field.required %}{% endif %}{{ field.help_text }}
+ {% endif %}
{% endblock %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index df4f05b95..32e8263c2 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -5,8 +5,7 @@ from django.db.models import Count from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, - FilterChoiceField, SlugField, + APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, SlugField, ) from .models import Tenant, TenantGroup @@ -36,17 +35,19 @@ class TenantForm(BootstrapMixin, CustomFieldForm): fields = ['name', 'slug', 'group', 'description', 'comments'] -class TenantFromCSVForm(forms.ModelForm): - group = forms.ModelChoiceField(TenantGroup.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Group not found.'}) +class TenantCSVForm(forms.ModelForm): + group = forms.ModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + to_field_name='name', + error_messages={ + 'invalid_choice': 'Group not found.' + } + ) class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description'] - - -class TenantImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=TenantFromCSVForm) + fields = ['name', 'slug', 'group', 'description', 'comments'] class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 3b5ad9b37..27afed269 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -10,7 +10,7 @@ from circuits.models import Circuit from dcim.models import Site, Rack, Device from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, BulkImportView2, ObjectDeleteView, ObjectEditView, ObjectListView, ) from .models import Tenant, TenantGroup from . import filters, forms, tables @@ -95,9 +95,9 @@ class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'tenancy:tenant_list' -class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): +class TenantBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'tenancy.add_tenant' - form = forms.TenantImportForm + model_form = forms.TenantCSVForm table = tables.TenantTable template_name = 'tenancy/tenant_import.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 37101a56e..fcdaf2c53 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -256,6 +256,60 @@ class CSVDataField(forms.CharField): return records +class CSVDataField2(forms.CharField): + """ + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns a list of dictionaries mapping + column headers to values. Each dictionary represents an individual record. + """ + widget = forms.Textarea + + def __init__(self, fields, required_fields=[], *args, **kwargs): + + self.fields = fields + self.required_fields = required_fields + + super(CSVDataField2, self).__init__(*args, **kwargs) + + self.strip = False + if not self.label: + self.label = 'CSV Data' + if not self.initial: + self.initial = ','.join(required_fields) + '\n' + if not self.help_text: + self.help_text = 'Enter one line per record. Use commas to separate values.' + + def to_python(self, value): + + # Python 2's csv module has problems with Unicode + if not isinstance(value, str): + value = value.encode('utf-8') + + records = [] + reader = csv.reader(value.splitlines()) + + # Consume and valdiate the first line of CSV data as column headers + headers = reader.next() + for f in self.required_fields: + if f not in headers: + raise forms.ValidationError('Required column header "{}" not found.'.format(f)) + for f in headers: + if f not in self.fields: + raise forms.ValidationError('Unexpected column header "{}" found.'.format(f)) + + # Parse CSV data + for i, row in enumerate(reader, start=1): + if row: + if len(row) != len(headers): + raise forms.ValidationError( + "Row {}: Expected {} columns but found {}".format(i, len(headers), len(row)) + ) + row = [col.strip() for col in row] + record = dict(zip(headers, row)) + records.append(record) + + return records + + class ExpandableNameField(forms.CharField): """ A field which allows for numeric range expansion @@ -488,7 +542,7 @@ class BulkEditForm(forms.Form): class BulkImportForm(forms.Form): def clean(self): - records = self.cleaned_data.get('csv') + fields, records = self.cleaned_data.get('csv').split('\n', 1) if not records: return diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 9c7a4b55e..7791e69b7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -6,9 +6,10 @@ from django_tables2 import RequestConfig from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import ProtectedError -from django.forms import CharField, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField +from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, TypedChoiceField from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError @@ -19,6 +20,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction +from utilities.forms import BootstrapMixin, CSVDataField2 from .error_handlers import handle_protectederror from .forms import ConfirmationForm from .paginator import EnhancedPaginator @@ -422,6 +424,85 @@ class BulkImportView(View): obj.save() +class BulkImportView2(View): + """ + Import objects in bulk (CSV format). + + model_form: The form used to create each imported object + table: The django-tables2 Table used to render the list of imported objects + template_name: The name of the template + default_return_url: The name of the URL to use for the cancel button + """ + model_form = None + table = None + template_name = None + default_return_url = None + + def _import_form(self, *args, **kwargs): + + fields = self.model_form().fields.keys() + required_fields = [name for name, field in self.model_form().fields.items() if field.required] + + class ImportForm(BootstrapMixin, Form): + csv = CSVDataField2(fields=fields, required_fields=required_fields) + + return ImportForm(*args, **kwargs) + + def get(self, request): + + return render(request, self.template_name, { + 'form': self._import_form(), + 'fields': self.model_form().fields, + 'return_url': self.default_return_url, + }) + + def post(self, request): + + new_objs = [] + form = self._import_form(request.POST) + + if form.is_valid(): + + try: + + # Iterate through CSV data and bind each row to a new model form instance. + with transaction.atomic(): + for row, data in enumerate(form.cleaned_data['csv'], start=1): + obj_form = self.model_form(data) + if obj_form.is_valid(): + obj = obj_form.save() + new_objs.append(obj) + else: + for field, err in obj_form.errors.items(): + form.add_error('csv', "Row {} {}: {}".format(row, field, err[0])) + raise ValidationError("") + + # Compile a table containing the imported objects + obj_table = self.table(new_objs) + + if new_objs: + msg = 'Imported {} {}'.format(len(new_objs), new_objs[0]._meta.verbose_name_plural) + messages.success(request, msg) + UserAction.objects.log_import(request.user, ContentType.objects.get_for_model(new_objs[0]), msg) + + return render(request, "import_success.html", { + 'table': obj_table, + 'return_url': self.default_return_url, + }) + + except ValidationError: + pass + + return render(request, self.template_name, { + 'form': form, + 'fields': self.model_form().fields, + 'return_url': self.default_return_url, + }) + + def save_obj(self, obj): + obj.save() + + class BulkEditView(View): """ Edit objects in bulk. From 0ceb667aedb6d5080abb8222e6f040f2a0db334a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Jun 2017 16:13:07 -0400 Subject: [PATCH 11/36] Fixes #1243: Catch ValueError in IP-based object filters --- netbox/ipam/filters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 54146e91a..11c19b7ee 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -85,7 +85,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): 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 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): 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 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): 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 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): 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 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): 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): From 87880907d1ec68027f7079109de27d561f192954 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Jun 2017 16:57:32 -0400 Subject: [PATCH 12/36] #1190: Allow partial string matching when searching on custom fields --- netbox/extras/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index e44fb86e9..6bd5f3737 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -32,7 +32,7 @@ class CustomFieldFilter(django_filters.Filter): pass return queryset.filter( custom_field_values__field__name=self.name, - custom_field_values__serialized_value=value, + custom_field_values__serialized_value__icontains=value, ) From 83a50f45b547ff982513f9100162868590e83ce7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Jun 2017 13:40:52 -0400 Subject: [PATCH 13/36] Converted circuits import views to new scheme --- netbox/circuits/forms.py | 57 ++++++++++++------- netbox/circuits/views.py | 12 ++-- netbox/templates/circuits/circuit_import.html | 55 ------------------ .../templates/circuits/provider_import.html | 45 --------------- netbox/templates/tenancy/tenant_import.html | 3 - netbox/templates/utilities/obj_import.html | 5 +- netbox/tenancy/forms.py | 6 ++ netbox/tenancy/views.py | 1 - netbox/utilities/forms.py | 4 +- netbox/utilities/views.py | 4 +- 10 files changed, 57 insertions(+), 135 deletions(-) delete mode 100644 netbox/templates/circuits/circuit_import.html delete mode 100644 netbox/templates/circuits/provider_import.html delete mode 100644 netbox/templates/tenancy/tenant_import.html diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 79cad0a6b..444974240 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,17 @@ 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': 'Autonomous system number', + 'comments': 'Free-form comments' + } class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -102,21 +104,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='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Invalid circuit type.' + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of circuit type', + 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..144007211 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -12,7 +12,7 @@ from django.views.generic import View from extras.models import Graph, GRAPH_TYPE_PROVIDER from utilities.forms import ConfirmationForm from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView2, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z @@ -63,11 +63,10 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'circuits:provider_list' -class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): +class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView2): 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' @@ -161,11 +160,10 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'circuits:circuit_list' -class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): +class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView2): 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/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/tenancy/tenant_import.html b/netbox/templates/tenancy/tenant_import.html deleted file mode 100644 index 4d0e97f48..000000000 --- a/netbox/templates/tenancy/tenant_import.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends 'utilities/obj_import.html' %} - -{% block title %}Tenant Import{% endblock %} diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 325cfa304..25b737175 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -1,8 +1,9 @@ {% extends '_base.html' %} +{% load helpers %} {% load form_helpers %} {% block content %} -

{% block title %}{% endblock %}

+

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

{% if form.non_field_errors %} @@ -40,7 +41,7 @@ {{ name }} {% if field.required %}{% endif %} - {{ field.help_text }} + {{ field.help_text|default:field.label }} {% endfor %} diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 32e8263c2..9950abfc2 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -36,10 +36,12 @@ class TenantForm(BootstrapMixin, CustomFieldForm): class TenantCSVForm(forms.ModelForm): + slug = SlugField() group = forms.ModelChoiceField( queryset=TenantGroup.objects.all(), required=False, to_field_name='name', + help_text='Name of parent group', error_messages={ 'invalid_choice': 'Group not found.' } @@ -48,6 +50,10 @@ class TenantCSVForm(forms.ModelForm): class Meta: model = Tenant fields = ['name', 'slug', 'group', 'description', 'comments'] + help_texts = { + 'name': 'Tenant name', + 'comments': 'Free-form comments' + } class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 27afed269..feaec38be 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -99,7 +99,6 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView2): permission_required = 'tenancy.add_tenant' model_form = forms.TenantCSVForm table = tables.TenantTable - template_name = 'tenancy/tenant_import.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index fcdaf2c53..6c07aad7f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -276,7 +276,9 @@ class CSVDataField2(forms.CharField): if not self.initial: self.initial = ','.join(required_fields) + '\n' if not self.help_text: - self.help_text = 'Enter one line per record. Use commas to separate values.' + self.help_text = 'Enter the list of column headers followed by one line per record to be imported. Use ' \ + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ + 'in double quotes.' def to_python(self, value): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 7791e69b7..e06b900c0 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -435,8 +435,8 @@ class BulkImportView2(View): """ model_form = None table = None - template_name = None default_return_url = None + template_name = 'utilities/obj_import.html' def _import_form(self, *args, **kwargs): @@ -453,6 +453,7 @@ class BulkImportView2(View): return render(request, self.template_name, { 'form': self._import_form(), 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.default_return_url, }) @@ -496,6 +497,7 @@ class BulkImportView2(View): return render(request, self.template_name, { 'form': form, 'fields': self.model_form().fields, + 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.default_return_url, }) From 95377c42c3015bf6012643402c44da07227ca2e7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Jun 2017 14:49:25 -0400 Subject: [PATCH 14/36] Converted site/rack/device import views to new scheme --- netbox/dcim/forms.py | 188 ++++++++++++------ netbox/dcim/views.py | 24 +-- netbox/templates/dcim/device_import.html | 104 +--------- .../templates/dcim/device_import_child.html | 94 +-------- .../dcim/inc/device_import_header.html | 1 - netbox/templates/dcim/rack_import.html | 70 ------- netbox/templates/dcim/site_import.html | 81 -------- netbox/templates/utilities/obj_import.html | 1 + 8 files changed, 140 insertions(+), 423 deletions(-) delete mode 100644 netbox/templates/dcim/rack_import.html delete mode 100644 netbox/templates/dcim/site_import.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9e1cc657d..f1737b366 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -107,29 +107,34 @@ 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) - - class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) @@ -217,35 +222,62 @@ 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.'}) +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 = forms.ModelChoiceField( + queryset=RackGroup.objects.all(), + to_field_name='name', + required=False, + help_text='Name of parent group', + error_messages={ + 'invalid_choice': 'Rack group 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.', + } + ) + 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 = forms.CharField(required=False) class Meta: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units'] + fields = [ + 'site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + ] - def clean(self): + def clean_group(self): site = self.cleaned_data.get('site') - group = self.cleaned_data.get('group_name') + group = self.cleaned_data.get('group') - # Validate rack group - if site and group: - try: - self.instance.group = RackGroup.objects.get(site=site, name=group) - except RackGroup.DoesNotExist: - self.add_error('group_name', "Invalid rack group ({})".format(group)) + if group and group.site != site: + raise ValidationError("Invalid group for site {}: {}".format(site, group)) def clean_type(self): + rack_type = self.cleaned_data['type'] + if not rack_type: return None try: @@ -258,10 +290,6 @@ class RackFromCSVForm(forms.ModelForm): )) -class RackImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=RackFromCSVForm) - - class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') @@ -663,25 +691,47 @@ 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='Manufacturer name', + error_messages={ + 'invalid_choice': 'Invalid manufacturer.', + } + ) + model_name = forms.CharField( + help_text='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 = forms.CharField( + help_text='Status name' ) - status = forms.CharField() class Meta: fields = [] @@ -707,16 +757,25 @@ class BaseDeviceFromCSVForm(forms.ModelForm): raise ValidationError("Invalid status: {}".format(self.cleaned_data['status'])) -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_name = forms.CharField( + required=False, + help_text='Name of parent rack' + ) + face = forms.CharField( + required=False, + help_text='Mounted rack face (front or rear)' + ) - 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', @@ -724,7 +783,7 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): def clean(self): - super(DeviceFromCSVForm, self).clean() + super(DeviceCSVForm, self).clean() site = self.cleaned_data.get('site') rack_name = self.cleaned_data.get('rack_name') @@ -749,18 +808,20 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm): raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face)) -class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): +class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', - required=False, + help_text='Name 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 +829,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') @@ -778,20 +839,15 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): 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)) + self.add_error( + 'device_bay_name', "Device bay ({} {}) is already occupied".format(parent, device_bay_name) + ) else: self.instance.parent_bay = device_bay 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) + self.add_error( + 'device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name) + ) class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f6e00be04..888a54aaf 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -23,14 +23,14 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, BulkImportView2, ObjectDeleteView, ObjectEditView, ObjectListView, ) 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, ) @@ -217,11 +217,10 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:site_list' -class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): +class SiteBulkImportView(PermissionRequiredMixin, BulkImportView2): 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' @@ -388,11 +387,10 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:rack_list' -class RackBulkImportView(PermissionRequiredMixin, BulkImportView): +class RackBulkImportView(PermissionRequiredMixin, BulkImportView2): 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' @@ -864,17 +862,17 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): default_return_url = 'dcim:device_list' -class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): +class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView2): 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' -class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): +class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView2): 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' 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