From 5ff4e3b19470e27e8a72efc008e12720157dbaae 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 bc18d241e8314f3d43877e6a5e7e0c0f987816b1 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 834c396a2204ff639c47a6765cb870294b387760 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 b5a1b692bd8a5b66198d6e828a12467736925d07 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 d5016c713311d65a5070b08ce8f0481e5d6556c1 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 6d908d3e798557c32e7db688d9d550b8e966dc82 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 6aae8aee5b00b894b731a877ebbb3a1438039a5f 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 f03a378ce0f438b6228d7fc52a5271dd19c6fe27 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 293dbd8a8b2c291d1ae9efc7b2f2137fc896833b 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 a598f0e632255d1e2777c365c88e2b9707177503 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 95fdb549d7b557852646668eb994ffaf6f3f26dd 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 583830c652c2b46c44df21ff0779dad6101932cc 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 4a8147f8a51759a8575957e229502cb71e8d5328 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 7e660d4d8eea15e8d3291b4aba309285dee8361f 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