From e1fe3ca14a3cdfdf0826c5db03e4e5d60c62615e Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Tue, 25 May 2021 11:09:33 -0400 Subject: [PATCH 01/33] CSV Upload as second field in existing form --- netbox/netbox/views/generic.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 8a2bae4fa..8228f6eff 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -7,7 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, FileField, CharField from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.html import escape @@ -665,7 +665,10 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): from_form=self.model_form, widget=Textarea(attrs=self.widget_attrs) ) - + Upload_CSV = FileField( + required=False + ) + # this is where the import form is created -- add csv upload here or create new form? return ImportForm(*args, **kwargs) def _save_obj(self, obj_form, request): @@ -690,6 +693,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): logger = logging.getLogger('netbox.views.BulkImportView') new_objs = [] form = self._import_form(request.POST) + # this is where the csv data is handled if form.is_valid(): logger.debug("Form validation was successful") @@ -697,6 +701,9 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): + # some prints trying to figure out how the form works + print(type(form["Upload_CSV"])) + print(form["Upload_CSV"].data) headers, records = form.cleaned_data['csv'] for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) From 0c9376039c84a24a2d38bb316a601bb155d326bc Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Thu, 27 May 2021 10:31:51 -0400 Subject: [PATCH 02/33] working csv upload first draft --- netbox/netbox/views/generic.py | 31 ++++++++++++++++--- .../templates/generic/object_bulk_import.html | 2 +- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 8228f6eff..c01f69783 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -701,10 +701,33 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - # some prints trying to figure out how the form works - print(type(form["Upload_CSV"])) - print(form["Upload_CSV"].data) - headers, records = form.cleaned_data['csv'] + + if len(request.FILES) != 0: + csv_file = request.FILES["Upload_CSV"] + csv_file.seek(0) + csv_str = csv_file.read().decode('utf-8') + + csv_list = csv_str.split('\n') + header_row = csv_list[0] + csv_list.pop(0) + header_list = header_row.split(',') + headers = {} + for elt in header_list: + headers[elt] = None + records = [] + for row in csv_list: + if row != "": + row_str = (',').join(row) + row_list = row.split(',') + row_dict = {} + for i, elt in enumerate(row_list): + if elt == '': + row_dict[header_list[i]] = None + else: + row_dict[header_list[i]] = elt + records.append(row_dict) + else: + headers, records = form.cleaned_data['csv'] for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) restrict_form_fields(obj_form, request.user) diff --git a/netbox/templates/generic/object_bulk_import.html b/netbox/templates/generic/object_bulk_import.html index 170cf3665..e9972e919 100644 --- a/netbox/templates/generic/object_bulk_import.html +++ b/netbox/templates/generic/object_bulk_import.html @@ -20,7 +20,7 @@
-
+ {% csrf_token %} {% render_form form %}
From 2bc68707b5e82c0b868cb65a951ed5d02b691962 Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Wed, 2 Jun 2021 10:12:26 -0400 Subject: [PATCH 03/33] csv parse using python csv library --- netbox/netbox/views/generic.py | 37 ++++++++++++++-------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index c01f69783..c665c3630 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -1,5 +1,6 @@ import logging import re +import csv from copy import deepcopy from django.contrib import messages @@ -7,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, FileField, CharField +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, FileField from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.html import escape @@ -668,7 +669,6 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): Upload_CSV = FileField( required=False ) - # this is where the import form is created -- add csv upload here or create new form? return ImportForm(*args, **kwargs) def _save_obj(self, obj_form, request): @@ -693,7 +693,6 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): logger = logging.getLogger('netbox.views.BulkImportView') new_objs = [] form = self._import_form(request.POST) - # this is where the csv data is handled if form.is_valid(): logger.debug("Form validation was successful") @@ -701,33 +700,27 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - if len(request.FILES) != 0: csv_file = request.FILES["Upload_CSV"] csv_file.seek(0) csv_str = csv_file.read().decode('utf-8') - - csv_list = csv_str.split('\n') - header_row = csv_list[0] - csv_list.pop(0) - header_list = header_row.split(',') + reader = csv.reader(csv_str.splitlines()) + headers_list = next(reader) headers = {} - for elt in header_list: - headers[elt] = None + for header in headers_list: + headers[header] = None records = [] - for row in csv_list: - if row != "": - row_str = (',').join(row) - row_list = row.split(',') - row_dict = {} - for i, elt in enumerate(row_list): - if elt == '': - row_dict[header_list[i]] = None - else: - row_dict[header_list[i]] = elt - records.append(row_dict) + for row in reader: + row_dict = {} + for i, elt in enumerate(row): + if elt == '': + row_dict[headers_list[i]] = None + else: + row_dict[headers_list[i]] = elt + records.append(row_dict) else: headers, records = form.cleaned_data['csv'] + print("headers:", headers, "records:", records) for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) restrict_form_fields(obj_form, request.user) From 6ff5a1db42df4c3232c2c7f7bb6475d22747af1a Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Thu, 3 Jun 2021 15:08:47 -0400 Subject: [PATCH 04/33] cleaned up csv parsing --- netbox/netbox/views/generic.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index c665c3630..0d1734c08 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -666,7 +666,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): from_form=self.model_form, widget=Textarea(attrs=self.widget_attrs) ) - Upload_CSV = FileField( + upload_csv = FileField( required=False ) return ImportForm(*args, **kwargs) @@ -692,7 +692,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def post(self, request): logger = logging.getLogger('netbox.views.BulkImportView') new_objs = [] - form = self._import_form(request.POST) + form = self._import_form(request.POST, request.FILES) if form.is_valid(): logger.debug("Form validation was successful") @@ -700,8 +700,8 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): - if len(request.FILES) != 0: - csv_file = request.FILES["Upload_CSV"] + if request.FILES: + csv_file = request.FILES["upload_csv"] csv_file.seek(0) csv_str = csv_file.read().decode('utf-8') reader = csv.reader(csv_str.splitlines()) From c2b2b059e6ac3dc5660d380f6d92528d73fd71dc Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Fri, 4 Jun 2021 10:27:19 -0400 Subject: [PATCH 05/33] CSV import implemented using CSVFileField --- netbox/netbox/views/generic.py | 27 +++--------- netbox/utilities/forms/fields.py | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 0d1734c08..24d459219 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -1,6 +1,5 @@ import logging import re -import csv from copy import deepcopy from django.contrib import messages @@ -8,7 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError -from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, FileField +from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.html import escape @@ -21,7 +20,7 @@ from extras.models import CustomField, ExportTemplate from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortTransaction from utilities.forms import ( - BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, + BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, CSVFileField ) from utilities.permissions import get_permission_for_model from utilities.tables import paginate_table @@ -666,7 +665,8 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): from_form=self.model_form, widget=Textarea(attrs=self.widget_attrs) ) - upload_csv = FileField( + upload_csv = CSVFileField( + from_form=self.model_form, required=False ) return ImportForm(*args, **kwargs) @@ -701,26 +701,9 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): if request.FILES: - csv_file = request.FILES["upload_csv"] - csv_file.seek(0) - csv_str = csv_file.read().decode('utf-8') - reader = csv.reader(csv_str.splitlines()) - headers_list = next(reader) - headers = {} - for header in headers_list: - headers[header] = None - records = [] - for row in reader: - row_dict = {} - for i, elt in enumerate(row): - if elt == '': - row_dict[headers_list[i]] = None - else: - row_dict[headers_list[i]] = elt - records.append(row_dict) + headers, records = form.cleaned_data['upload_csv'] else: headers, records = form.cleaned_data['csv'] - print("headers:", headers, "records:", records) for row, data in enumerate(records, start=1): obj_form = self.model_form(data, headers=headers) restrict_form_fields(obj_form, request.user) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 93b9e6c44..1d8e299e0 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -26,6 +26,7 @@ __all__ = ( 'CSVChoiceField', 'CSVContentTypeField', 'CSVDataField', + 'CSVFileField', 'CSVModelChoiceField', 'CSVTypedChoiceField', 'DynamicModelChoiceField', @@ -221,6 +222,77 @@ class CSVDataField(forms.CharField): return value +class CSVFileField(forms.FileField): + """ + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + def to_python(self, file): + + records = [] + file.seek(0) + csv_str = file.read().decode('utf-8') + reader = csv.reader(csv_str.splitlines()) + + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + + headers = {} + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + + return headers, records + + def validate(self, value): + headers, records = value + + # Validate provided column headers + for field, to_field in headers.items(): + if field not in self.fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(self.fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(self.fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields + for f in self.required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') + + return value + + class CSVChoiceField(forms.ChoiceField): """ Invert the provided set of choices to take the human-friendly label as input, and return the database value. From ecd84d7c430cf2c75f99440f5535bea7d367bdb1 Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Mon, 7 Jun 2021 11:52:31 -0400 Subject: [PATCH 06/33] edited docstring for CSVFileField --- netbox/utilities/forms/fields.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 1d8e299e0..a8383de6e 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -224,9 +224,10 @@ class CSVDataField(forms.CharField): class CSVFileField(forms.FileField): """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first - item is a dictionary of column headers, mapping field names to the attribute by which they match a related object - (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns + data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute + by which they match a related object (where applicable). The second item is a list of dictionaries, each + representing a discrete row of CSV data. :param from_form: The form from which the field derives its validation rules. """ From 3549fc07f6b0a681ba2f74d645e78f2441c4a3e4 Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Mon, 7 Jun 2021 14:29:38 -0400 Subject: [PATCH 07/33] removed unnecessary use of seek() --- netbox/utilities/forms/fields.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index a8383de6e..0f5b0b6dd 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -246,7 +246,6 @@ class CSVFileField(forms.FileField): def to_python(self, file): records = [] - file.seek(0) csv_str = file.read().decode('utf-8') reader = csv.reader(csv_str.splitlines()) From 55b7cf21ccc834598bdb9d1a4bfd6ac0d3bc80fd Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Thu, 10 Jun 2021 14:41:33 -0400 Subject: [PATCH 08/33] changed name of csv_file variable and started work on ValidationError --- netbox/netbox/views/generic.py | 12 +++++++--- netbox/utilities/forms/fields.py | 38 ++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 24d459219..7ff980750 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -665,10 +665,16 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): from_form=self.model_form, widget=Textarea(attrs=self.widget_attrs) ) - upload_csv = CSVFileField( + csv_file = CSVFileField( + label="CSV file", from_form=self.model_form, required=False ) + def used_both_methods(self): + if self.cleaned_data['csv_file'][1] and self.cleaned_data['csv'][1]: + raise ValidationError('') + return False + return ImportForm(*args, **kwargs) def _save_obj(self, obj_form, request): @@ -694,14 +700,14 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): new_objs = [] form = self._import_form(request.POST, request.FILES) - if form.is_valid(): + if form.is_valid() and not form.used_both_methods(): logger.debug("Form validation was successful") try: # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): if request.FILES: - headers, records = form.cleaned_data['upload_csv'] + headers, records = form.cleaned_data['csv_file'] else: headers, records = form.cleaned_data['csv'] for row, data in enumerate(records, start=1): diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 0f5b0b6dd..2954f9c73 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -246,35 +246,39 @@ class CSVFileField(forms.FileField): def to_python(self, file): records = [] - csv_str = file.read().decode('utf-8') - reader = csv.reader(csv_str.splitlines()) + if file: + csv_str = file.read().decode('utf-8') + reader = csv.reader(csv_str.splitlines()) # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional # "to" field specifying how the related object is being referenced. For example, importing a Device might use a # `site.slug` header, to indicate the related site is being referenced by its slug. headers = {} - for header in next(reader): - if '.' in header: - field, to_field = header.split('.', 1) - headers[field] = to_field - else: - headers[header] = None + if file: + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None - # Parse CSV rows into a list of dictionaries mapped from the column headers. - for i, row in enumerate(reader, start=1): - if len(row) != len(headers): - raise forms.ValidationError( - f"Row {i}: Expected {len(headers)} columns but found {len(row)}" - ) - row = [col.strip() for col in row] - record = dict(zip(headers.keys(), row)) - records.append(record) + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) return headers, records def validate(self, value): headers, records = value + if not headers and not records: + return value # Validate provided column headers for field, to_field in headers.items(): From 934543b595f4740397b76d836f79601ba688378f Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Fri, 11 Jun 2021 13:42:26 -0400 Subject: [PATCH 09/33] Caught and handled ValidationError --- netbox/netbox/views/generic.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 7ff980750..00841f4b9 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -670,9 +670,10 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): from_form=self.model_form, required=False ) - def used_both_methods(self): + + def used_both_csv_fields(self): if self.cleaned_data['csv_file'][1] and self.cleaned_data['csv'][1]: - raise ValidationError('') + return True return False return ImportForm(*args, **kwargs) @@ -700,10 +701,13 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): new_objs = [] form = self._import_form(request.POST, request.FILES) - if form.is_valid() and not form.used_both_methods(): + if form.is_valid(): logger.debug("Form validation was successful") try: + if form.used_both_csv_fields(): + form.add_error('csv_file', "Choose one of two import methods") + raise ValidationError("") # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): if request.FILES: From 0a661596b3ff3d9e18024d8c621099bfa3eac45d Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Mon, 14 Jun 2021 14:07:37 -0400 Subject: [PATCH 10/33] moved duplicated code in CSV Fields into functions in forms/utils.py --- netbox/utilities/forms/fields.py | 80 +++----------------------------- netbox/utilities/forms/utils.py | 53 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 74 deletions(-) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 2954f9c73..2b9b57ea3 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -17,7 +17,7 @@ from utilities.utils import content_type_name from utilities.validators import EnhancedURLValidator from . import widgets from .constants import * -from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern +from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv __all__ = ( 'CommentField', @@ -175,49 +175,13 @@ class CSVDataField(forms.CharField): 'in double quotes.' def to_python(self, value): - - records = [] reader = csv.reader(StringIO(value.strip())) - # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional - # "to" field specifying how the related object is being referenced. For example, importing a Device might use a - # `site.slug` header, to indicate the related site is being referenced by its slug. - headers = {} - for header in next(reader): - if '.' in header: - field, to_field = header.split('.', 1) - headers[field] = to_field - else: - headers[header] = None - - # Parse CSV rows into a list of dictionaries mapped from the column headers. - for i, row in enumerate(reader, start=1): - if len(row) != len(headers): - raise forms.ValidationError( - f"Row {i}: Expected {len(headers)} columns but found {len(row)}" - ) - row = [col.strip() for col in row] - record = dict(zip(headers.keys(), row)) - records.append(record) - - return headers, records + return parse_csv(reader) def validate(self, value): headers, records = value - - # Validate provided column headers - for field, to_field in headers.items(): - if field not in self.fields: - raise forms.ValidationError(f'Unexpected column header "{field}" found.') - if to_field and not hasattr(self.fields[field], 'to_field_name'): - raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') - if to_field and not hasattr(self.fields[field].queryset.model, to_field): - raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - - # Validate required fields - for f in self.required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') + validate_csv(headers, self.fields, self.required_fields) return value @@ -244,34 +208,14 @@ class CSVFileField(forms.FileField): super().__init__(*args, **kwargs) def to_python(self, file): - - records = [] if file: csv_str = file.read().decode('utf-8') reader = csv.reader(csv_str.splitlines()) - # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional - # "to" field specifying how the related object is being referenced. For example, importing a Device might use a - # `site.slug` header, to indicate the related site is being referenced by its slug. - headers = {} + records = [] if file: - for header in next(reader): - if '.' in header: - field, to_field = header.split('.', 1) - headers[field] = to_field - else: - headers[header] = None - - # Parse CSV rows into a list of dictionaries mapped from the column headers. - for i, row in enumerate(reader, start=1): - if len(row) != len(headers): - raise forms.ValidationError( - f"Row {i}: Expected {len(headers)} columns but found {len(row)}" - ) - row = [col.strip() for col in row] - record = dict(zip(headers.keys(), row)) - records.append(record) + headers, records = parse_csv(reader) return headers, records @@ -280,19 +224,7 @@ class CSVFileField(forms.FileField): if not headers and not records: return value - # Validate provided column headers - for field, to_field in headers.items(): - if field not in self.fields: - raise forms.ValidationError(f'Unexpected column header "{field}" found.') - if to_field and not hasattr(self.fields[field], 'to_field_name'): - raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') - if to_field and not hasattr(self.fields[field].queryset.model, to_field): - raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - - # Validate required fields - for f in self.required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') + validate_csv(headers, self.fields, self.required_fields) return value diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index dc001be1a..50a6a416e 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -14,6 +14,8 @@ __all__ = ( 'parse_alphanumeric_range', 'parse_numeric_range', 'restrict_form_fields', + 'parse_csv', + 'validate_csv', ) @@ -134,3 +136,54 @@ def restrict_form_fields(form, user, action='view'): for field in form.fields.values(): if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet): field.queryset = field.queryset.restrict(user, action) + + +def parse_csv(reader): + """ + Parse a csv_reader object into a headers dictionary and a list of records dictionaries. Raise an error + if the records are formatted incorrectly. Return headers and records as a tuple. + """ + records = [] + headers = {} + + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + return headers, records + + +def validate_csv(headers, fields, required_fields): + """ + Validate that parsed csv data conforms to the object's available fields. Raise validation errors + if parsed csv data contains invalid headers or does not contain required headers. + """ + # Validate provided column headers + for field, to_field in headers.items(): + if field not in fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields + for f in required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') From 1e7b76005c729489eb5f7d77302298fd3ce0b8cd Mon Sep 17 00:00:00 2001 From: Alyssa Bigley Date: Mon, 14 Jun 2021 15:23:42 -0400 Subject: [PATCH 11/33] cleaned up validation error method --- netbox/netbox/views/generic.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 00841f4b9..7616a8c06 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -672,9 +672,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): ) def used_both_csv_fields(self): - if self.cleaned_data['csv_file'][1] and self.cleaned_data['csv'][1]: - return True - return False + return self.cleaned_data['csv_file'][1] and self.cleaned_data['csv'][1] return ImportForm(*args, **kwargs) From e300fad3400bd0e7930bcde94f7ea1196ab42b92 Mon Sep 17 00:00:00 2001 From: Brian Ellwood Date: Thu, 22 Jul 2021 19:04:34 -0400 Subject: [PATCH 12/33] Add AC Hardwire option to PowerPortTypeChoices Resolves FR #6785 --- netbox/dcim/choices.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 63f44ea37..06d800623 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -341,6 +341,8 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_DC = 'dc-terminal' # Proprietary TYPE_SAF_D_GRID = 'saf-d-grid' + # Other + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -447,6 +449,9 @@ class PowerPortTypeChoices(ChoiceSet): ('Proprietary', ( (TYPE_SAF_D_GRID, 'Saf-D-Grid'), )), + ('Other', ( + (TYPE_OTHER, 'Other'), + )), ) From 2ff3d0d5a2b5bceafcb45b11e0b338d0806ef6de Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 23 Jul 2021 11:13:21 -0400 Subject: [PATCH 13/33] Fixes #6774: Fix A/Z assignment when swapping circuit terminations --- docs/release-notes/version-2.11.md | 1 + netbox/circuits/views.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 953f42d5c..2e5209fb0 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -6,6 +6,7 @@ * [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups * [#6773](https://github.com/netbox-community/netbox/issues/6773) - Add missing `display` field to rack unit serializer +* [#6774](https://github.com/netbox-community/netbox/issues/6774) - Fix A/Z assignment when swapping circuit terminations * [#6777](https://github.com/netbox-community/netbox/issues/6777) - Fix default value validation for custom text fields * [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location * [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 15e6bed2f..e7bb889e0 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -287,6 +287,10 @@ class CircuitSwapTerminations(generic.ObjectEditView): termination_z.save() termination_a.term_side = 'Z' termination_a.save() + circuit.refresh_from_db() + circuit.termination_a = termination_z + circuit.termination_z = termination_a + circuit.save() elif termination_a: termination_a.term_side = 'Z' termination_a.save() @@ -300,9 +304,6 @@ class CircuitSwapTerminations(generic.ObjectEditView): circuit.termination_z = None circuit.save() - print(f'term A: {circuit.termination_a}') - print(f'term Z: {circuit.termination_z}') - messages.success(request, f"Swapped terminations for circuit {circuit}.") return redirect('circuits:circuit', pk=circuit.pk) From cca76550d6534503cfea42331e0c6cca0e720c82 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 23 Jul 2021 11:18:50 -0400 Subject: [PATCH 14/33] Fixes #6794: Fix device name display on device status view --- docs/release-notes/version-2.11.md | 1 + netbox/templates/dcim/device/status.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 2e5209fb0..c2883a2a8 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -10,6 +10,7 @@ * [#6777](https://github.com/netbox-community/netbox/issues/6777) - Fix default value validation for custom text fields * [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location * [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs +* [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view --- diff --git a/netbox/templates/dcim/device/status.html b/netbox/templates/dcim/device/status.html index b7f8d0eaa..594fb0784 100644 --- a/netbox/templates/dcim/device/status.html +++ b/netbox/templates/dcim/device/status.html @@ -1,7 +1,7 @@ {% extends 'dcim/device/base.html' %} {% load static %} -{% block title %}{{ device }} - Status{% endblock %} +{% block title %}{{ object }} - Status{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} From d1af15037c06956d1a1d08540745b08a25644bbf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 23 Jul 2021 11:24:32 -0400 Subject: [PATCH 15/33] Fixes #6759: Fix assignment of parent interfaces for bulk import --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/forms.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index c2883a2a8..72cb90590 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups +* [#6759](https://github.com/netbox-community/netbox/issues/6759) - Fix assignment of parent interfaces for bulk import * [#6773](https://github.com/netbox-community/netbox/issues/6773) - Add missing `display` field to rack unit serializer * [#6774](https://github.com/netbox-community/netbox/issues/6774) - Fix A/Z assignment when swapping circuit terminations * [#6777](https://github.com/netbox-community/netbox/issues/6777) - Fix default value validation for custom text fields diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5bcf135a1..f2d005ed6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3378,13 +3378,18 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), type=InterfaceTypeChoices.TYPE_LAG ) + self.fields['parent'].queryset = Interface.objects.filter( + Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) + ) elif device: self.fields['lag'].queryset = Interface.objects.filter( device=device, type=InterfaceTypeChoices.TYPE_LAG ) + self.fields['parent'].queryset = Interface.objects.filter(device=device) else: self.fields['lag'].queryset = Interface.objects.none() + self.fields['parent'].queryset = Interface.objects.none() def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data From a8140d1f701dd32eb9ce290242aa79012cd86af9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 23 Jul 2021 11:34:24 -0400 Subject: [PATCH 16/33] Closes #6781: Disable database query caching by default --- docs/additional-features/caching.md | 5 ++++- docs/configuration/optional-settings.md | 4 ++-- docs/release-notes/version-2.11.md | 4 ++++ netbox/netbox/configuration.example.py | 4 ++-- netbox/netbox/settings.py | 10 ++-------- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md index 18c9dca68..ebe91f37d 100644 --- a/docs/additional-features/caching.md +++ b/docs/additional-features/caching.md @@ -1,6 +1,9 @@ # Caching -NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. +NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter. Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. + +!!! warning + In NetBox v2.11.10 and later queryset caching is disabled by default, and must be configured. If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 927bf9f37..6c62fc6d1 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -54,9 +54,9 @@ BASE_PATH = 'netbox/' ## CACHE_TIMEOUT -Default: 900 +Default: 0 (disabled) -The number of seconds that cache entries will be retained before expiring. +The number of seconds that cached database queries will be retained before expiring. --- diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 72cb90590..f7eb561b7 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -13,6 +13,10 @@ * [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs * [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view +### Other Changes + +* [#6781](https://github.com/netbox-community/netbox/issues/6781) - Database query caching is now disabled by default + --- ## v2.11.9 (2021-07-08) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 461d7f4cd..a5c5521f3 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -89,8 +89,8 @@ BANNER_LOGIN = '' # BASE_PATH = 'netbox/' BASE_PATH = '' -# Cache timeout in seconds. Set to 0 to dissable caching. Defaults to 900 (15 minutes) -CACHE_TIMEOUT = 900 +# Cache timeout in seconds. Defaults to zero (disabled). +CACHE_TIMEOUT = 0 # Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) CHANGELOG_RETENTION = 90 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7d4b570c2..dff38c530 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -75,7 +75,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only -CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 900) +CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 0) CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) @@ -417,13 +417,7 @@ else: 'ssl': CACHING_REDIS_SSL, 'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required', } - -if not CACHE_TIMEOUT: - CACHEOPS_ENABLED = False -else: - CACHEOPS_ENABLED = True - - +CACHEOPS_ENABLED = bool(CACHE_TIMEOUT) CACHEOPS_DEFAULTS = { 'timeout': CACHE_TIMEOUT } From 04d6a4a37136c3306ae0d96e2803ad225e80f8ad Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 23 Jul 2021 13:43:33 -0400 Subject: [PATCH 17/33] Introduce "adding models" section to development docs --- docs/development/adding-models.md | 85 +++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 86 insertions(+) create mode 100644 docs/development/adding-models.md diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md new file mode 100644 index 000000000..6b778d886 --- /dev/null +++ b/docs/development/adding-models.md @@ -0,0 +1,85 @@ +# Adding Models + +## 1. Define the model class + +Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. + +Each model should define, at a minimum: + +* A `__str__()` method returning a user-friendly string representation of the instance +* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`) +* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID) + +## 2. Define field choices + +If the model has one or more fields with static choices, define those choices in `choices.py` by subclassing `utilities.choices.ChoiceSet`. + +## 3. Generate database migrations + +Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations. + +!!! info + Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations. + +## 4. Add all standard views + +Most models will need view classes created in `views.py` to serve the following operations: + +* List view +* Detail view +* Edit view +* Delete view +* Bulk import +* Bulk edit +* Bulk delete + +## 5. Add URL paths + +Add the relevant URL path for each view created in the previous step to `urls.py`. + +## 6. Create the FilterSet + +Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class. + +Every model FilterSet should define a `q` filter to support general search queries. + +## 7. Create the table + +Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns. + +## 8. Create the object template + +Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`. + +## 9. Add the model to the navigation menu + +For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`. + +## 10. REST API components + +Create the following for each model: + +* Detailed (full) model serializer in `api/serializers.py` +* Nested serializer in `api/nested_serializers.py` +* API view in `api/views.py` +* Endpoint route in `api/urls.py` + +## 11. GraphQL API components (v3.0+) + +Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. + +Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. + +## 12. Add tests + +Add tests for the following: + +* UI views +* API views +* Filter sets + +## 13. Documentation + +Create a new documentation page for the model in `docs/models//.md`. Include this file under the "features" documentation where appropriate. + +Also add your model to the index in `docs/development/models.md`. diff --git a/mkdocs.yml b/mkdocs.yml index fb5cf1890..d26799caf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,6 +76,7 @@ nav: - Getting Started: 'development/getting-started.md' - Style Guide: 'development/style-guide.md' - Models: 'development/models.md' + - Adding Models: 'development/adding-models.md' - Extending Models: 'development/extending-models.md' - Application Registry: 'development/application-registry.md' - User Preferences: 'development/user-preferences.md' From f25649955eac44e284351aa919772f5ddb142fa5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 23 Jul 2021 13:45:56 -0400 Subject: [PATCH 18/33] Exclude NPM files from git (v3.0+) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 95e4ff702..1dea89c21 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.swp /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py +/netbox/project-static/.cache +/netbox/project-static/node_modules /netbox/reports/* !/netbox/reports/__init__.py /netbox/scripts/* From 7ab916b527d953e69dd2e5e81591642b2e6d7d5e Mon Sep 17 00:00:00 2001 From: tamaszl <85541319+tamaszl@users.noreply.github.com> Date: Sun, 25 Jul 2021 18:44:21 -0700 Subject: [PATCH 19/33] Update 6-ldap.md - AUTH_LDAP_USER_DN_TEMPLATE to none for windows 2012+ changed When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. to Windows Server 2012+ --- docs/installation/6-ldap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 27bdb0b40..86114dfb0 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -74,7 +74,7 @@ STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the ### User Authentication !!! info - When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. + When using Windows Server 2012+, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. ```python from django_auth_ldap.config import LDAPSearch From 4f6944424bcf3633940aaec657c09e49cc3417fb Mon Sep 17 00:00:00 2001 From: bluikko <14869000+bluikko@users.noreply.github.com> Date: Tue, 27 Jul 2021 00:26:46 +0700 Subject: [PATCH 20/33] Add dev server firewall configuration for EL distros (#6772) * Add dev server firewall configuration for EL distros * Fix typo in previous * Indent the firewall block in install docs --- docs/installation/3-netbox.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 2a493d835..e54bf6f3e 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -267,6 +267,13 @@ Starting development server at http://0.0.0.0:8000/ Quit the server with CONTROL-C. ``` +!!! note + By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts): + + ```no-highlight + firewall-cmd --zone=public --add-port=8000/tcp + ``` + Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. !!! danger From 1c38d63c50b90c3226d8374c5a15f257247c30ac Mon Sep 17 00:00:00 2001 From: Brian Ellwood Date: Mon, 26 Jul 2021 15:03:43 -0400 Subject: [PATCH 21/33] Update choices.py --- netbox/dcim/choices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 06d800623..a53f357f8 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -342,7 +342,7 @@ class PowerPortTypeChoices(ChoiceSet): # Proprietary TYPE_SAF_D_GRID = 'saf-d-grid' # Other - TYPE_OTHER = 'other' + TYPE_HARDWIRED = 'hardwired' CHOICES = ( ('IEC 60320', ( @@ -450,7 +450,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_SAF_D_GRID, 'Saf-D-Grid'), )), ('Other', ( - (TYPE_OTHER, 'Other'), + (TYPE_HARDWIRED, 'Hardwired'), )), ) From 8355270a1a6fa7ea87c907e89d8a31b00224784d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Jul 2021 16:04:51 -0400 Subject: [PATCH 22/33] Fixes #6822: Use consistent maximum value for interface MTU --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/constants.py | 2 +- netbox/dcim/forms.py | 12 ++++++------ netbox/dcim/models/device_components.py | 5 ++++- netbox/dcim/tests/test_views.py | 2 +- netbox/virtualization/forms.py | 14 ++++---------- netbox/virtualization/tests/test_views.py | 2 +- 7 files changed, 18 insertions(+), 20 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index f7eb561b7..4737c460c 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -12,6 +12,7 @@ * [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location * [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs * [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view +* [#6822](https://github.com/netbox-community/netbox/issues/6822) - Use consistent maximum value for interface MTU ### Other Changes diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 42ed7c7d0..2a4d368f4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -29,7 +29,7 @@ REARPORT_POSITIONS_MAX = 1024 # INTERFACE_MTU_MIN = 1 -INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer +INTERFACE_MTU_MAX = 65536 VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f2d005ed6..565a75c45 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -102,6 +102,12 @@ class InterfaceCommonForm(forms.Form): required=False, label='MAC address' ) + mtu = forms.IntegerField( + required=False, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, + label='MTU' + ) def clean(self): super().clean() @@ -3173,12 +3179,6 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): 'type': 'lag', } ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) mac_address = forms.CharField( required=False, label='MAC Address' diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index bd7f4ac55..6ac0d7753 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -483,7 +483,10 @@ class BaseInterface(models.Model): mtu = models.PositiveIntegerField( blank=True, null=True, - validators=[MinValueValidator(1), MaxValueValidator(65536)], + validators=[ + MinValueValidator(INTERFACE_MTU_MIN), + MaxValueValidator(INTERFACE_MTU_MAX) + ], verbose_name='MTU' ) mode = models.CharField( diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 94cf2c9b3..4942b27c2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1464,7 +1464,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'enabled': False, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), - 'mtu': 2000, + 'mtu': 65000, 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index f7b241c1a..0819882c3 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -16,10 +16,10 @@ from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, - ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, - StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, ConfirmationForm, + CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, + form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -680,12 +680,6 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldForm, InterfaceCommonForm 'virtual_machine_id': '$virtual_machine', } ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) mac_address = forms.CharField( required=False, label='MAC Address' diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 56c9cf280..86be5159f 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -263,7 +263,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name': 'Interface X', 'enabled': False, 'mac_address': EUI('01-02-03-04-05-06'), - 'mtu': 2000, + 'mtu': 65000, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, From e92f13977c435a34c2f95d5d490b7b12ce310af0 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Jul 2021 16:17:59 -0400 Subject: [PATCH 23/33] Changelog for #6785 --- docs/release-notes/version-2.11.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 4737c460c..93ad02de6 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -2,6 +2,10 @@ ## v2.11.10 (FUTURE) +### Enhancements + +* [#6785](https://github.com/netbox-community/netbox/issues/6785) - Add "hardwired" type for power port types + ### Bug Fixes * [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups From a1eb4dc807a5fb1ebc75dc301129288d84d5ad11 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 27 Jul 2021 16:21:56 -0400 Subject: [PATCH 24/33] Fixes #5627: Fix filtering of interface connections list --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/api/views.py | 4 +--- netbox/dcim/views.py | 6 +----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 93ad02de6..1b116366c 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -9,6 +9,7 @@ ### Bug Fixes * [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups +* [#5627](https://github.com/netbox-community/netbox/issues/5627) - Fix filtering of interface connections list * [#6759](https://github.com/netbox-community/netbox/issues/6759) - Fix assignment of parent interfaces for bulk import * [#6773](https://github.com/netbox-community/netbox/issues/6773) - Add missing `display` field to rack unit serializer * [#6774](https://github.com/netbox-community/netbox/issues/6774) - Fix A/Z assignment when swapping circuit terminations diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 47ab26828..744d16e0a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -592,11 +592,9 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet): queryset = Interface.objects.prefetch_related('device', '_path').filter( - # Avoid duplicate connections by only selecting the lower PK in a connected pair _path__destination_type__app_label='dcim', _path__destination_type__model='interface', - _path__destination_id__isnull=False, - pk__lt=F('_path__destination_id') + _path__destination_id__isnull=False ) serializer_class = serializers.InterfaceConnectionSerializer filterset_class = filtersets.InterfaceConnectionFilterSet diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 50b55ee3f..6b776ed87 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2564,11 +2564,7 @@ class PowerConnectionsListView(generic.ObjectListView): class InterfaceConnectionsListView(generic.ObjectListView): - queryset = Interface.objects.filter( - # Avoid duplicate connections by only selecting the lower PK in a connected pair - _path__isnull=False, - pk__lt=F('_path__destination_id') - ).order_by('device') + queryset = Interface.objects.filter(_path__isnull=False).order_by('device') filterset = filtersets.InterfaceConnectionFilterSet filterset_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable From 0c214932ba639f6695c09ebc054035056ca07594 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 09:55:40 -0400 Subject: [PATCH 25/33] Fixes #6812: Limit reported prefix utilization to 100% --- docs/release-notes/version-2.11.md | 1 + netbox/ipam/models/ip.py | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 1b116366c..cd92c755f 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -17,6 +17,7 @@ * [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location * [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs * [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view +* [#6812](https://github.com/netbox-community/netbox/issues/6812) - Limit reported prefix utilization to 100% * [#6822](https://github.com/netbox-community/netbox/issues/6822) - Use consistent maximum value for interface MTU ### Other Changes diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 1f3766e3a..cd5b89cfe 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -181,7 +181,9 @@ class Aggregate(PrimaryModel): """ queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) - return int(float(child_prefixes.size) / self.prefix.size * 100) + utilization = int(float(child_prefixes.size) / self.prefix.size * 100) + + return min(utilization, 100) @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') @@ -502,14 +504,16 @@ class Prefix(PrimaryModel): vrf=self.vrf ) child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) - return int(float(child_prefixes.size) / self.prefix.size * 100) + utilization = int(float(child_prefixes.size) / self.prefix.size * 100) else: # Compile an IPSet to avoid counting duplicate IPs child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size prefix_size = self.prefix.size if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool: prefix_size -= 2 - return int(float(child_count) / prefix_size * 100) + utilization = int(float(child_count) / prefix_size * 100) + + return min(utilization, 100) @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') From 78e282d406a9805468244b592c44d0737c7a8a93 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 10:25:52 -0400 Subject: [PATCH 26/33] Fixes #6771: Add count of inventory items to manufacturer view --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/views.py | 4 ++++ netbox/templates/dcim/manufacturer.html | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index cd92c755f..b5308e46d 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -22,6 +22,7 @@ ### Other Changes +* [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view * [#6781](https://github.com/netbox-community/netbox/issues/6781) - Database query caching is now disabled by default --- diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6b776ed87..5afae3ced 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -695,6 +695,9 @@ class ManufacturerView(generic.ObjectView): ).annotate( instance_count=count_related(Device, 'device_type') ) + inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter( + manufacturer=instance + ) devicetypes_table = tables.DeviceTypeTable(devicetypes) devicetypes_table.columns.hide('manufacturer') @@ -702,6 +705,7 @@ class ManufacturerView(generic.ObjectView): return { 'devicetypes_table': devicetypes_table, + 'inventory_item_count': inventory_items.count(), } diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index b2ecacbb1..9ea6f3fe1 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -29,6 +29,12 @@ {{ devicetypes_table.rows|length }} + + Inventory Items + + {{ inventory_item_count }} + +
{% plugin_left_page object %} From 72aaf76cf475245ef399ad076606b0b4be776ee3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 10:31:59 -0400 Subject: [PATCH 27/33] Closes #6702: Update reference nginx config to support IPv6 --- contrib/nginx.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contrib/nginx.conf b/contrib/nginx.conf index 1230f3ce4..34821cd52 100644 --- a/contrib/nginx.conf +++ b/contrib/nginx.conf @@ -1,5 +1,5 @@ server { - listen 443 ssl; + listen [::]:443 ssl ipv6only=off; # CHANGE THIS TO YOUR SERVER'S NAME server_name netbox.example.com; @@ -23,7 +23,7 @@ server { server { # Redirect HTTP traffic to HTTPS - listen 80; + listen [::]:80 ipv6only=off; server_name _; return 301 https://$host$request_uri; } From 8d9d3a9e7d9f40cbee7cad37a831c3384886a40d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 11:44:13 -0400 Subject: [PATCH 28/33] Changelog and cleanup for #6560 --- docs/release-notes/version-2.11.md | 3 +- netbox/netbox/views/generic.py | 18 +- .../templates/generic/object_bulk_import.html | 194 +++++++++--------- netbox/utilities/forms/fields.py | 18 +- netbox/utilities/forms/utils.py | 1 + 5 files changed, 122 insertions(+), 112 deletions(-) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index b5308e46d..6d6bd321b 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,6 +4,8 @@ ### Enhancements +* [#6560](https://github.com/netbox-community/netbox/issues/6560) - Enable CSV import via uploaded file +* [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view * [#6785](https://github.com/netbox-community/netbox/issues/6785) - Add "hardwired" type for power port types ### Bug Fixes @@ -22,7 +24,6 @@ ### Other Changes -* [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view * [#6781](https://github.com/netbox-community/netbox/issues/6781) - Database query caching is now disabled by default --- diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index a312441e5..bd3d6300a 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -20,7 +20,8 @@ from extras.models import CustomField, ExportTemplate from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ( - BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, CSVFileField + BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, TableConfigForm, + restrict_form_fields, ) from utilities.permissions import get_permission_for_model from utilities.tables import paginate_table @@ -673,8 +674,16 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): required=False ) - def used_both_csv_fields(self): - return self.cleaned_data['csv_file'][1] and self.cleaned_data['csv'][1] + def clean(self): + csv_rows = self.cleaned_data['csv'][1] + csv_file = self.files.get('csv_file') + + # Check that the user has not submitted both text data and a file + if csv_rows and csv_file: + raise ValidationError( + "Cannot process CSV text and file attachment simultaneously. Please choose only one import " + "method." + ) return ImportForm(*args, **kwargs) @@ -705,9 +714,6 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): logger.debug("Form validation was successful") try: - if form.used_both_csv_fields(): - form.add_error('csv_file', "Choose one of two import methods") - raise ValidationError("") # Iterate through CSV data and bind each row to a new model form instance. with transaction.atomic(): if request.FILES: diff --git a/netbox/templates/generic/object_bulk_import.html b/netbox/templates/generic/object_bulk_import.html index e9972e919..ec6dddf14 100644 --- a/netbox/templates/generic/object_bulk_import.html +++ b/netbox/templates/generic/object_bulk_import.html @@ -16,103 +16,107 @@
{% endif %} -
-
- - {% csrf_token %} - {% render_form form %} -
-
- - {% if return_url %} - Cancel - {% endif %} -
-
- -
-

- {% if fields %} -
-
- CSV Field Options -
- - - - - - - - {% for name, field in fields.items %} - - - - - - - {% endfor %} -
FieldRequiredAccessorDescription
- {{ name }} - - {% if field.required %} - - {% else %} - - {% endif %} - - {% if field.to_field_name %} - {{ field.to_field_name }} - {% else %} - - {% endif %} - - {% if field.STATIC_CHOICES %} - - - {% endif %} - {% if field.help_text %} - {{ field.help_text }}
- {% elif field.label %} - {{ field.label }}
- {% endif %} - {% if field|widget_type == 'dateinput' %} - Format: YYYY-MM-DD - {% elif field|widget_type == 'checkboxinput' %} - Specify "true" or "false" - {% endif %} -
-
-

- Required fields must be specified for all - objects. -

-

- Related objects may be referenced by any unique attribute. - For example, vrf.rd would identify a VRF by its route distinguisher. -

- {% endif %} +
+ {% csrf_token %} +
+
+ {% render_field form.csv %} +
+
+ {% render_field form.csv_file %} +
-
+
+
+ + {% if return_url %} + Cancel + {% endif %} +
+
+ +
+

+ {% if fields %} +
+
+ CSV Field Options +
+ + + + + + + + {% for name, field in fields.items %} + + + + + + + {% endfor %} +
FieldRequiredAccessorDescription
+ {{ name }} + + {% if field.required %} + + {% else %} + + {% endif %} + + {% if field.to_field_name %} + {{ field.to_field_name }} + {% else %} + + {% endif %} + + {% if field.STATIC_CHOICES %} + + + {% endif %} + {% if field.help_text %} + {{ field.help_text }}
+ {% elif field.label %} + {{ field.label }}
+ {% endif %} + {% if field|widget_type == 'dateinput' %} + Format: YYYY-MM-DD + {% elif field|widget_type == 'checkboxinput' %} + Specify "true" or "false" + {% endif %} +
+
+

+ Required fields must be specified for all + objects. +

+

+ Related objects may be referenced by any unique attribute. + For example, vrf.rd would identify a VRF by its route distinguisher. +

+ {% endif %}
{% endblock %} diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 2b9b57ea3..7cb2cd705 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -208,22 +208,20 @@ class CSVFileField(forms.FileField): super().__init__(*args, **kwargs) def to_python(self, file): - if file: - csv_str = file.read().decode('utf-8') - reader = csv.reader(csv_str.splitlines()) + if file is None: + return None - headers = {} - records = [] - if file: - headers, records = parse_csv(reader) + csv_str = file.read().decode('utf-8').strip() + reader = csv.reader(csv_str.splitlines()) + headers, records = parse_csv(reader) return headers, records def validate(self, value): - headers, records = value - if not headers and not records: - return value + if value is None: + return None + headers, records = value validate_csv(headers, self.fields, self.required_fields) return value diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 50a6a416e..503a2e8a0 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -166,6 +166,7 @@ def parse_csv(reader): row = [col.strip() for col in row] record = dict(zip(headers.keys(), row)) records.append(record) + return headers, records From 95783cc128ef58bb77d5e9f825b370da7b082a59 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 11:54:25 -0400 Subject: [PATCH 29/33] Closes #6644: Add 6P/4P pass-through port types --- docs/release-notes/version-2.11.md | 1 + netbox/dcim/choices.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6d6bd321b..4ba284d0b 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -5,6 +5,7 @@ ### Enhancements * [#6560](https://github.com/netbox-community/netbox/issues/6560) - Enable CSV import via uploaded file +* [#6644](https://github.com/netbox-community/netbox/issues/6644) - Add 6P/4P pass-through port types * [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view * [#6785](https://github.com/netbox-community/netbox/issues/6785) - Add "hardwired" type for power port types diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a53f357f8..9a12e6a19 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -922,6 +922,11 @@ class PortTypeChoices(ChoiceSet): TYPE_8P6C = '8p6c' TYPE_8P4C = '8p4c' TYPE_8P2C = '8p2c' + TYPE_6P6C = '6p6c' + TYPE_6P4C = '6p4c' + TYPE_6P2C = '6p2c' + TYPE_4P4C = '4p4c' + TYPE_4P2C = '4p2c' TYPE_GG45 = 'gg45' TYPE_TERA4P = 'tera-4p' TYPE_TERA2P = 'tera-2p' @@ -953,6 +958,11 @@ class PortTypeChoices(ChoiceSet): (TYPE_8P6C, '8P6C'), (TYPE_8P4C, '8P4C'), (TYPE_8P2C, '8P2C'), + (TYPE_6P6C, '6P6C'), + (TYPE_6P4C, '6P4C'), + (TYPE_6P2C, '6P2C'), + (TYPE_4P4C, '4P4C'), + (TYPE_4P2C, '4P2C'), (TYPE_GG45, 'GG45'), (TYPE_TERA4P, 'TERA 4P'), (TYPE_TERA2P, 'TERA 2P'), From 49a596073eb6c0a9fe4d6b87a609caf2ee7d61bd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 15:07:46 -0400 Subject: [PATCH 30/33] Tweak GitHub repo icon & name in docs --- mkdocs.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index d26799caf..d6e60b255 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,11 +1,14 @@ site_name: NetBox Documentation site_url: https://netbox.readthedocs.io/ +repo_name: netbox-community/netbox repo_url: https://github.com/netbox-community/netbox python: install: - requirements: docs/requirements.txt theme: - name: material + name: material + icon: + repo: fontawesome/brands/github extra_css: - extra.css markdown_extensions: From 78ebf04be0a0c7f0604f03be571efda2d037bc78 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 15:12:17 -0400 Subject: [PATCH 31/33] Shrink NetBox logo on docs main page --- docs/index.md | 2 +- mkdocs.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 5cdf871d9..0fc0dc0b7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -![NetBox](netbox_logo.svg "NetBox logo") +![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} # What is NetBox? diff --git a/mkdocs.yml b/mkdocs.yml index d6e60b255..16b345b96 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ extra_css: - extra.css markdown_extensions: - admonition + - attr_list - markdown_include.include: headingOffset: 1 - pymdownx.emoji: From eae45027085742e09f267d85402ed0a044690fbb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 15:17:45 -0400 Subject: [PATCH 32/33] Release v2.11.10 --- .github/ISSUE_TEMPLATE/bug_report.yaml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yaml | 2 +- docs/release-notes/version-2.11.md | 2 +- netbox/netbox/settings.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 97b55b285..ef31324fe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: What version of NetBox are you currently running? (If you don't have access to the most recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v2.11.9 + placeholder: v2.11.10 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bfb0cc7aa..319538cda 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v2.11.9 + placeholder: v2.11.10 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 4ba284d0b..736807e2c 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -1,6 +1,6 @@ # NetBox v2.11 -## v2.11.10 (FUTURE) +## v2.11.10 (2021-07-28) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index dff38c530..f28f72a27 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.11.10-dev' +VERSION = '2.11.10' # Hostname HOSTNAME = platform.node() From 18a4232783ae436f1bd976140a3631a9b4ed4a0a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 28 Jul 2021 16:00:38 -0400 Subject: [PATCH 33/33] PRVB --- 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 f28f72a27..d44d87b23 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.11.10' +VERSION = '2.11.11-dev' # Hostname HOSTNAME = platform.node()