From 36090d9f02b957fa66bef509dd488d9bcb6f8c44 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 31 Jan 2018 11:15:26 -0500 Subject: [PATCH 01/13] 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 35f8264b8..25e5f304b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.2.9' +VERSION = '2.2.10-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From a954406d1f71a9e85cce18925f2d5708194fe9b7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Feb 2018 16:39:48 -0500 Subject: [PATCH 02/13] Changed IRC to Slack; added warning about noisy comments --- CONTRIBUTING.md | 52 +++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ce2d519a..4820e5a85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,24 +10,23 @@ We have established a Google Groups Mailing List for issues and general discussion. This is the best forum for obtaining assistance with NetBox installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss). -### Freenode IRC +### Slack -For real-time discussion, you can join the #netbox channel on [Freenode](https://freenode.net/). -You can connect to Freenode at irc.freenode.net using an IRC client, or you can -use their [webchat client](https://webchat.freenode.net/). +For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/). ## Reporting Bugs -* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of -NetBox. If you're running an older version, it's possible that the bug has +* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) +of NetBox. If you're running an older version, it's possible that the bug has already been fixed. -* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has already -been reported. If you think you may be experiencing a reported issue that -hasn't already been resolved, please click "add a reaction" in the top right -corner of the issue and add a thumbs up (+1). You mightalso want to add a -comment describing how it's affecting your installation. This will allow us to -prioritize bugs based on how many users are affected. +* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) +to see if the bug you've found has already been reported. If you think you may +be experiencing a reported issue that hasn't already been resolved, please +click "add a reaction" in the top right corner of the issue and add a thumbs +up (+1). You mightalso want to add a comment describing how it's affecting your +installation. This will allow us to prioritize bugs based on how many users are +affected. * If you haven't found an existing issue that describes your suspected bug, please inquire about it on the mailing list. **Do not** file an issue until you @@ -44,7 +43,7 @@ include: * Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title. The issue will be reviewed by a moderator after submission and the appropriate -labels will be applied. +labels will be applied for categorization. * Keep in mind that we prioritize bugs based on their severity and how much work is required to resolve them. It may take some time for someone to address @@ -52,15 +51,15 @@ your issue. ## Feature Requests -* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting -is already listed. (Be sure to search closed issues as well, since some -feature requests have been rejected.) If the feature you'd like to see has -already been requested and is open, click "add a reaction" in the top right -corner of the issue and add a thumbs up (+1). This ensures that the issue has -a better chance of receiving attention. Also feel free to add a comment with -any additional justification for the feature. (However, note that comments with -no substance other than a "+1" will be deleted. Please use GitHub's reactions -feature to indicate your support.) +* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) +to see if the feature you're requesting is already listed. (Be sure to search +closed issues as well, since some feature requests have been rejected.) If the +feature you'd like to see has already been requested and is open, click "add a +reaction" in the top right corner of the issue and add a thumbs up (+1). This +ensures that the issue has a better chance of receiving attention. Also feel +free to add a comment with any additional justification for the feature. +(However, note that comments with no substance other than a "+1" will be +deleted. Please use GitHub's reactions feature to indicate your support.) * Due to an excessive backlog of feature requests, we are not currently accepting any proposals which substantially extend NetBox's functionality @@ -88,7 +87,7 @@ following: * Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue title. The issue will be reviewed by a moderator after submission and the -appropriate labels will be applied. +appropriate labels will be applied for categorization. ## Submitting Pull Requests @@ -109,3 +108,10 @@ these checks): * All tests pass when run with `./manage.py test` * PEP 8 compliance is enforced, with the exception that lines may be greater than 80 characters in length + +## Commenting + +Only comment on an issue if you are sharing a relevant idea or constructive +feedback. **Do not** comment on an issue just to show your support (give the +top post a :+1: instead) or ask for an ETA. These comments will be deleted to +reduce noise in the discussion. From df10fa87d3acb33c554eb1b65631fd0a803738ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Feb 2018 16:52:24 -0500 Subject: [PATCH 03/13] Replaced IRC with Slack; formatting cleanup --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 26aa0ccfc..9d3bbe0e8 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,18 @@ ![NetBox](docs/netbox_logo.png "NetBox logo") -NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. +NetBox is an IP address management (IPAM) and data center infrastructure +management (DCIM) tool. Initially conceived by the network engineering team at +[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically +to address the needs of network and infrastructure engineers. -NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox). +NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) +Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a +complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox). The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). -Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**! +Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), +or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)! ### Build Status @@ -27,7 +33,9 @@ NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended. # Installation -Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`. +Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for +instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) +and run `upgrade.sh`. ## Alternative Installations From 59dcbce41719a2bba6815e2754b59694dc978cb6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 11:34:31 -0500 Subject: [PATCH 04/13] Refactored CSV export logic --- netbox/circuits/models.py | 11 +++--- netbox/dcim/models.py | 45 ++++++++++++------------ netbox/extras/models.py | 14 +++++--- netbox/ipam/models.py | 23 ++++++------- netbox/tenancy/models.py | 5 ++- netbox/utilities/csv.py | 61 +++++++++++++++++++++++++++++++++ netbox/utilities/utils.py | 27 --------------- netbox/utilities/views.py | 25 +++++--------- netbox/virtualization/models.py | 9 +++-- 9 files changed, 123 insertions(+), 97 deletions(-) create mode 100644 netbox/utilities/csv.py diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index fd8a2b2f6..3b50694ca 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,7 +9,6 @@ from dcim.fields import ASNField from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import * @@ -41,13 +40,13 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): return reverse('circuits:provider', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.asn, self.account, self.portal_url, - ]) + ) @python_2_unicode_compatible @@ -99,15 +98,15 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): return reverse('circuits:circuit', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.cid, self.provider.name, self.type.name, self.tenant.name if self.tenant else None, - self.install_date.isoformat() if self.install_date else None, + self.install_date, self.commit_rate, self.description, - ]) + ) def _get_termination(self, side): for ct in self.terminations.all(): diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2b1f403e7..956023061 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -22,7 +22,6 @@ from tenancy.models import Tenant from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import * from .fields import ASNField, MACAddressField from .querysets import InterfaceQuerySet @@ -57,11 +56,11 @@ class Region(MPTTModel): return "{}?region={}".format(reverse('dcim:site_list'), self.slug) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.parent.name if self.parent else None, - ]) + ) # @@ -111,7 +110,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): return reverse('dcim:site', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.region.name if self.region else None, @@ -121,7 +120,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): self.contact_name, self.contact_phone, self.contact_email, - ]) + ) @property def count_prefixes(self): @@ -182,11 +181,11 @@ class RackGroup(models.Model): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) def to_csv(self): - return csv_format([ + return ( self.site, self.name, self.slug, - ]) + ) @python_2_unicode_compatible @@ -292,7 +291,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): Device.objects.filter(rack=self).update(site_id=self.site.pk) def to_csv(self): - return csv_format([ + return ( self.site.name, self.group.name if self.group else None, self.name, @@ -304,7 +303,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): self.width, self.u_height, self.desc_units, - ]) + ) @property def units(self): @@ -493,10 +492,10 @@ class Manufacturer(models.Model): return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, - ]) + ) @python_2_unicode_compatible @@ -562,7 +561,7 @@ class DeviceType(models.Model, CustomFieldModel): return reverse('dcim:devicetype', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.manufacturer.name, self.model, self.slug, @@ -574,7 +573,7 @@ class DeviceType(models.Model, CustomFieldModel): self.is_network_device, self.get_subdevice_role_display() if self.subdevice_role else None, self.get_interface_ordering_display(), - ]) + ) def clean(self): @@ -989,7 +988,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack) def to_csv(self): - return csv_format([ + return ( self.name or '', self.device_role.name, self.tenant.name if self.tenant else None, @@ -1004,7 +1003,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): self.rack.name if self.rack else None, self.position, self.get_face_display(), - ]) + ) @property def display_name(self): @@ -1078,13 +1077,13 @@ class ConsolePort(models.Model): # Used for connections export def to_csv(self): - return csv_format([ + return ( self.cs_port.device.identifier if self.cs_port else None, self.cs_port.name if self.cs_port else None, self.device.identifier, self.name, self.get_connection_status_display(), - ]) + ) # @@ -1155,13 +1154,13 @@ class PowerPort(models.Model): # Used for connections export def to_csv(self): - return csv_format([ + return ( self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.name if self.power_outlet else None, self.device.identifier, self.name, self.get_connection_status_display(), - ]) + ) # @@ -1384,13 +1383,13 @@ class InterfaceConnection(models.Model): # Used for connections export def to_csv(self): - return csv_format([ + return ( self.interface_a.device.identifier, self.interface_a.name, self.interface_b.device.identifier, self.interface_b.name, self.get_connection_status_display(), - ]) + ) # @@ -1464,7 +1463,7 @@ class InventoryItem(models.Model): return self.name def to_csv(self): - return csv_format([ + return ( self.device.name or '{' + self.device.pk + '}', self.name, self.manufacturer.name if self.manufacturer else None, @@ -1472,4 +1471,4 @@ class InventoryItem(models.Model): self.serial, self.asset_tag, self.description - ]) + ) diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 9e6624fc4..aa30f8cdc 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -223,19 +223,25 @@ class ExportTemplate(models.Model): def __str__(self): return '{}: {}'.format(self.content_type, self.name) - def to_response(self, context_dict, filename): + def render_to_response(self, queryset): """ Render the template to an HTTP response, delivered as a named file attachment """ template = Template(self.template_code) mime_type = 'text/plain' if not self.mime_type else self.mime_type - output = template.render(Context(context_dict)) + output = template.render(Context({'queryset': queryset})) + # Replace CRLF-style line terminators output = output.replace('\r\n', '\n') + + # Build the response response = HttpResponse(output, content_type=mime_type) - if self.file_extension: - filename += '.{}'.format(self.file_extension) + filename = 'netbox_{}{}'.format( + queryset.model._meta.verbose_name_plural, + '.{}'.format(self.file_extension) if self.file_extension else '' + ) response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 9b30586f2..c238a4ec0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,7 +14,6 @@ from dcim.models import Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @@ -49,13 +48,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): return reverse('ipam:vrf', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.name, self.rd, self.tenant.name if self.tenant else None, self.enforce_unique, self.description, - ]) + ) @property def display_name(self): @@ -147,12 +146,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): super(Aggregate, self).save(*args, **kwargs) def to_csv(self): - return csv_format([ + return ( self.prefix, self.rir.name, - self.date_added.isoformat() if self.date_added else None, + self.date_added, self.description, - ]) + ) def get_utilization(self): """ @@ -262,7 +261,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): super(Prefix, self).save(*args, **kwargs) def to_csv(self): - return csv_format([ + return ( self.prefix, self.vrf.rd if self.vrf else None, self.tenant.name if self.tenant else None, @@ -273,7 +272,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): self.role.name if self.role else None, self.is_pool, self.description, - ]) + ) def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] @@ -461,7 +460,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): else: is_primary = False - return csv_format([ + return ( self.address, self.vrf.rd if self.vrf else None, self.tenant.name if self.tenant else None, @@ -472,7 +471,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): self.interface.name if self.interface else None, is_primary, self.description, - ]) + ) @property def device(self): @@ -577,7 +576,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): }) def to_csv(self): - return csv_format([ + return ( self.site.name if self.site else None, self.group.name if self.group else None, self.vid, @@ -586,7 +585,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): self.get_status_display(), self.role.name if self.role else None, self.description, - ]) + ) @property def display_name(self): diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index a43327a06..f908463dc 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -7,7 +7,6 @@ from django.utils.encoding import python_2_unicode_compatible from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format @python_2_unicode_compatible @@ -53,9 +52,9 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): return reverse('tenancy:tenant', args=[self.slug]) def to_csv(self): - return csv_format([ + return ( self.name, self.slug, self.group.name if self.group else None, self.description, - ]) + ) diff --git a/netbox/utilities/csv.py b/netbox/utilities/csv.py new file mode 100644 index 000000000..6e1a91f6f --- /dev/null +++ b/netbox/utilities/csv.py @@ -0,0 +1,61 @@ +from __future__ import unicode_literals + +import datetime +import six + +from django.http import HttpResponse + + +def csv_format(data): + """ + Encapsulate any data which contains a comma within double quotes. + """ + csv = [] + for value in data: + + # Represent None or False with empty string + if value in [None, False]: + csv.append('') + continue + + # Convert dates to ISO format + if isinstance(value, (datetime.date, datetime.datetime)): + value = value.isoformat() + + # Force conversion to string first so we can check for any commas + if not isinstance(value, six.string_types): + value = '{}'.format(value) + + # Double-quote the value if it contains a comma + if ',' in value: + csv.append('"{}"'.format(value)) + else: + csv.append('{}'.format(value)) + + return ','.join(csv) + + +def queryset_to_csv(queryset): + """ + Export a queryset of objects as CSV, using the model's to_csv() method. + """ + output = [] + + # Start with the column headers + headers = ','.join(queryset.model.csv_headers) + output.append(headers) + + # Iterate through the queryset + for obj in queryset: + data = csv_format(obj.to_csv()) + output.append(data) + + # Build the HTTP response + response = HttpResponse( + '\n'.join(output), + content_type='text/csv' + ) + filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + + return response diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 72da778a5..a85e36cdb 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,32 +1,5 @@ from __future__ import unicode_literals -import six - - -def csv_format(data): - """ - Encapsulate any data which contains a comma within double quotes. - """ - csv = [] - for value in data: - - # Represent None or False with empty string - if value in [None, False]: - csv.append('') - continue - - # Force conversion to string first so we can check for any commas - if not isinstance(value, six.string_types): - value = '{}'.format(value) - - # Double-quote the value if it contains a comma - if ',' in value: - csv.append('"{}"'.format(value)) - else: - csv.append('{}'.format(value)) - - return ','.join(csv) - def foreground_color(bg_color): """ diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index d37332bef..927972ca7 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -10,7 +10,6 @@ from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import ProtectedError from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField -from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError from django.urls import reverse @@ -21,6 +20,7 @@ from django.views.generic import View from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction +from utilities.csv import queryset_to_csv from utilities.forms import BootstrapMixin, CSVDataField from .error_handlers import handle_protectederror from .forms import ConfirmationForm @@ -95,24 +95,15 @@ class ObjectListView(View): et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset try: - response = et.to_response(context_dict={'queryset': queryset}, - filename='netbox_{}'.format(model._meta.verbose_name_plural)) - return response + return et.render_to_response(queryset) except TemplateSyntaxError: - messages.error(request, "There was an error rendering the selected export template ({})." - .format(et.name)) - # Fall back to built-in CSV export + messages.error( + request, + "There was an error rendering the selected export template ({}).".format(et.name) + ) + # Fall back to built-in CSV export if no template was specified elif 'export' in request.GET and hasattr(model, 'to_csv'): - headers = getattr(model, 'csv_headers', None) - output = ','.join(headers) + '\n' if headers else '' - output += '\n'.join([obj.to_csv() for obj in self.queryset]) - response = HttpResponse( - output, - content_type='text/csv' - ) - response['Content-Disposition'] = 'attachment; filename="netbox_{}.csv"'\ - .format(self.queryset.model._meta.verbose_name_plural) - return response + return queryset_to_csv(self.queryset) # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list self.queryset = self.alter_queryset(request) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 8152c5d57..7eca5b1b8 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -10,7 +10,6 @@ from django.utils.encoding import python_2_unicode_compatible from dcim.models import Device from extras.models import CustomFieldModel, CustomFieldValue from utilities.models import CreatedUpdatedModel -from utilities.utils import csv_format from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES @@ -135,13 +134,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): }) def to_csv(self): - return csv_format([ + return ( self.name, self.type.name, self.group.name if self.group else None, self.site.name if self.site else None, self.comments, - ]) + ) # @@ -243,7 +242,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): return reverse('virtualization:virtualmachine', args=[self.pk]) def to_csv(self): - return csv_format([ + return ( self.name, self.get_status_display(), self.cluster.name, @@ -253,7 +252,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): self.memory, self.disk, self.comments, - ]) + ) def get_status_class(self): return VM_STATUS_CLASSES[self.status] From 60c03a646c7d746156505d64f3560cc4a7f013db Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 13:32:16 -0500 Subject: [PATCH 05/13] Fixes #1859: Implemented support for line breaks within CSV fields --- netbox/utilities/csv.py | 61 --------------------------------------- netbox/utilities/forms.py | 10 ++----- netbox/utilities/utils.py | 60 ++++++++++++++++++++++++++++++++++++++ netbox/utilities/views.py | 2 +- 4 files changed, 64 insertions(+), 69 deletions(-) delete mode 100644 netbox/utilities/csv.py diff --git a/netbox/utilities/csv.py b/netbox/utilities/csv.py deleted file mode 100644 index 6e1a91f6f..000000000 --- a/netbox/utilities/csv.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import unicode_literals - -import datetime -import six - -from django.http import HttpResponse - - -def csv_format(data): - """ - Encapsulate any data which contains a comma within double quotes. - """ - csv = [] - for value in data: - - # Represent None or False with empty string - if value in [None, False]: - csv.append('') - continue - - # Convert dates to ISO format - if isinstance(value, (datetime.date, datetime.datetime)): - value = value.isoformat() - - # Force conversion to string first so we can check for any commas - if not isinstance(value, six.string_types): - value = '{}'.format(value) - - # Double-quote the value if it contains a comma - if ',' in value: - csv.append('"{}"'.format(value)) - else: - csv.append('{}'.format(value)) - - return ','.join(csv) - - -def queryset_to_csv(queryset): - """ - Export a queryset of objects as CSV, using the model's to_csv() method. - """ - output = [] - - # Start with the column headers - headers = ','.join(queryset.model.csv_headers) - output.append(headers) - - # Iterate through the queryset - for obj in queryset: - data = csv_format(obj.to_csv()) - output.append(data) - - # Build the HTTP response - response = HttpResponse( - '\n'.join(output), - content_type='text/csv' - ) - filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - - return response diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1817cd9a9..a20825d13 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import csv -import itertools +from io import StringIO import re from django import forms @@ -245,14 +245,10 @@ class CSVDataField(forms.CharField): 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()) + reader = csv.reader(StringIO(value)) - # Consume and valdiate the first line of CSV data as column headers + # Consume and validate the first line of CSV data as column headers headers = next(reader) for f in self.required_fields: if f not in headers: diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index a85e36cdb..84d6d444a 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,5 +1,65 @@ from __future__ import unicode_literals +import datetime +import six + +from django.http import HttpResponse + + +def csv_format(data): + """ + Encapsulate any data which contains a comma within double quotes. + """ + csv = [] + for value in data: + + # Represent None or False with empty string + if value in [None, False]: + csv.append('') + continue + + # Convert dates to ISO format + if isinstance(value, (datetime.date, datetime.datetime)): + value = value.isoformat() + + # Force conversion to string first so we can check for any commas + if not isinstance(value, six.string_types): + value = '{}'.format(value) + + # Double-quote the value if it contains a comma + if ',' in value: + csv.append('"{}"'.format(value)) + else: + csv.append('{}'.format(value)) + + return ','.join(csv) + + +def queryset_to_csv(queryset): + """ + Export a queryset of objects as CSV, using the model's to_csv() method. + """ + output = [] + + # Start with the column headers + headers = ','.join(queryset.model.csv_headers) + output.append(headers) + + # Iterate through the queryset + for obj in queryset: + data = csv_format(obj.to_csv()) + output.append(data) + + # Build the HTTP response + response = HttpResponse( + '\n'.join(output), + content_type='text/csv' + ) + filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + + return response + def foreground_color(bg_color): """ diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 927972ca7..917cf3002 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -20,7 +20,7 @@ from django.views.generic import View from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction -from utilities.csv import queryset_to_csv +from utilities.utils import queryset_to_csv from utilities.forms import BootstrapMixin, CSVDataField from .error_handlers import handle_protectederror from .forms import ConfirmationForm From 12e6fe1d504a0303d3362b46c521723410e21a74 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 14:26:16 -0500 Subject: [PATCH 06/13] Standardized declaration of csv_headers on models --- netbox/circuits/forms.py | 4 +-- netbox/circuits/models.py | 16 +++++++-- netbox/dcim/forms.py | 39 +++++++------------- netbox/dcim/models.py | 63 +++++++++++++++++++++++---------- netbox/ipam/forms.py | 21 +++++------ netbox/ipam/models.py | 27 ++++++++++++++ netbox/secrets/forms.py | 4 +-- netbox/secrets/models.py | 8 +++++ netbox/tenancy/forms.py | 4 +-- netbox/tenancy/models.py | 9 +++++ netbox/virtualization/forms.py | 8 ++--- netbox/virtualization/models.py | 23 +++++++++--- 12 files changed, 153 insertions(+), 73 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 8acad4bb9..7afd1476e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -43,7 +43,7 @@ class ProviderCSVForm(forms.ModelForm): class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments'] + fields = Provider.csv_headers help_texts = { 'name': 'Provider name', 'asn': '32-bit autonomous system number', @@ -89,7 +89,7 @@ class CircuitTypeCSVForm(forms.ModelForm): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = CircuitType.csv_headers help_texts = { 'name': 'Name of circuit type', } diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3b50694ca..e3a688ee5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -28,7 +28,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url'] + csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] class Meta: ordering = ['name'] @@ -46,6 +46,9 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): self.asn, self.account, self.portal_url, + self.noc_contact, + self.admin_contact, + self.comments, ) @@ -58,6 +61,8 @@ class CircuitType(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -67,6 +72,12 @@ class CircuitType(models.Model): def get_absolute_url(self): return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + @python_2_unicode_compatible class Circuit(CreatedUpdatedModel, CustomFieldModel): @@ -85,7 +96,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] + csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] class Meta: ordering = ['provider', 'cid'] @@ -106,6 +117,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): self.install_date, self.commit_rate, self.description, + self.comments, ) def _get_termination(self, side): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 98c075cd3..c756d821f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -72,9 +72,7 @@ class RegionCSVForm(forms.ModelForm): class Meta: model = Region - fields = [ - 'name', 'slug', 'parent', - ] + fields = Region.csv_headers help_texts = { 'name': 'Region name', 'slug': 'URL-friendly slug', @@ -136,10 +134,7 @@ class SiteCSVForm(forms.ModelForm): class Meta: model = Site - fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', - ] + fields = Site.csv_headers help_texts = { 'name': 'Site name', 'slug': 'URL-friendly slug', @@ -196,9 +191,7 @@ class RackGroupCSVForm(forms.ModelForm): class Meta: model = RackGroup - fields = [ - 'site', 'name', 'slug', - ] + fields = RackGroup.csv_headers help_texts = { 'name': 'Name of rack group', 'slug': 'URL-friendly slug', @@ -226,7 +219,7 @@ class RackRoleCSVForm(forms.ModelForm): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = RackRole.csv_headers help_texts = { 'name': 'Name of rack role', 'color': 'RGB color in hexadecimal (e.g. 00ff00)' @@ -313,10 +306,7 @@ class RackCSVForm(forms.ModelForm): class Meta: model = Rack - fields = [ - 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height', - 'desc_units', - ] + fields = Rack.csv_headers help_texts = { 'name': 'Rack name', 'u_height': 'Height in rack units', @@ -444,9 +434,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): class ManufacturerCSVForm(forms.ModelForm): class Meta: model = Manufacturer - fields = [ - 'name', 'slug' - ] + fields = Manufacturer.csv_headers help_texts = { 'name': 'Manufacturer name', 'slug': 'URL-friendly slug', @@ -492,8 +480,7 @@ class DeviceTypeCSVForm(forms.ModelForm): class Meta: model = DeviceType - fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments'] + fields = DeviceType.csv_headers help_texts = { 'model': 'Model name', 'slug': 'URL-friendly slug', @@ -658,7 +645,7 @@ class DeviceRoleCSVForm(forms.ModelForm): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = DeviceRole.csv_headers help_texts = { 'name': 'Name of device role', 'color': 'RGB color in hexadecimal (e.g. 00ff00)' @@ -682,7 +669,7 @@ class PlatformCSVForm(forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'napalm_driver'] + fields = Platform.csv_headers help_texts = { 'name': 'Platform name', } @@ -932,7 +919,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', + 'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments', ] def clean(self): @@ -981,7 +968,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay_name', 'cluster', + 'parent', 'device_bay_name', 'cluster', 'comments', ] def clean(self): @@ -1808,7 +1795,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm): class Meta: model = InterfaceConnection - fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] + fields = InterfaceConnection.csv_headers def clean_interface_a(self): @@ -1951,7 +1938,7 @@ class InventoryItemCSVForm(forms.ModelForm): class Meta: model = InventoryItem - fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] + fields = InventoryItem.csv_headers class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 956023061..2b33ba6fe 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -42,9 +42,7 @@ class Region(MPTTModel): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - csv_headers = [ - 'name', 'slug', 'parent', - ] + csv_headers = ['name', 'slug', 'parent'] class MPTTMeta: order_insertion_by = ['name'] @@ -97,7 +95,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel): objects = SiteManager() csv_headers = [ - '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 Meta: @@ -117,9 +116,12 @@ class Site(CreatedUpdatedModel, CustomFieldModel): self.tenant.name if self.tenant else None, self.facility, self.asn, + self.physical_address, + self.shipping_address, self.contact_name, self.contact_phone, self.contact_email, + self.comments, ) @property @@ -163,9 +165,7 @@ class RackGroup(models.Model): slug = models.SlugField() site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE) - csv_headers = [ - 'site', 'name', 'slug', - ] + csv_headers = ['site', 'name', 'slug'] class Meta: ordering = ['site', 'name'] @@ -197,6 +197,8 @@ class RackRole(models.Model): slug = models.SlugField(unique=True) color = ColorField() + csv_headers = ['name', 'slug', 'color'] + class Meta: ordering = ['name'] @@ -206,6 +208,13 @@ class RackRole(models.Model): def get_absolute_url(self): return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + self.color, + ) + class RackManager(NaturalOrderByManager): @@ -241,7 +250,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height', - 'desc_units', + 'desc_units', 'comments', ] class Meta: @@ -303,6 +312,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): self.width, self.u_height, self.desc_units, + self.comments, ) @property @@ -478,9 +488,7 @@ class Manufacturer(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - csv_headers = [ - 'name', 'slug', - ] + csv_headers = ['name', 'slug'] class Meta: ordering = ['name'] @@ -538,7 +546,7 @@ class DeviceType(models.Model, CustomFieldModel): csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', + 'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments', ] class Meta: @@ -573,6 +581,7 @@ class DeviceType(models.Model, CustomFieldModel): self.is_network_device, self.get_subdevice_role_display() if self.subdevice_role else None, self.get_interface_ordering_display(), + self.comments, ) def clean(self): @@ -753,6 +762,8 @@ class DeviceRole(models.Model): help_text="Virtual machines may be assigned to this role" ) + csv_headers = ['name', 'slug', 'color', 'vm_role'] + class Meta: ordering = ['name'] @@ -762,6 +773,14 @@ class DeviceRole(models.Model): def get_absolute_url(self): return "{}?role={}".format(reverse('dcim:device_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + self.color, + self.vm_role, + ) + @python_2_unicode_compatible class Platform(models.Model): @@ -777,6 +796,8 @@ class Platform(models.Model): rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='Legacy RPC client') + csv_headers = ['name', 'slug', 'napalm_driver'] + class Meta: ordering = ['name'] @@ -786,6 +807,13 @@ class Platform(models.Model): def get_absolute_url(self): return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + self.napalm_driver, + ) + class DeviceManager(NaturalOrderByManager): @@ -847,7 +875,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack_name', 'position', 'face', + 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', ] class Meta: @@ -1003,6 +1031,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel): self.rack.name if self.rack else None, self.position, self.get_face_display(), + self.comments, ) @property @@ -1075,7 +1104,6 @@ class ConsolePort(models.Model): def __str__(self): return self.name - # Used for connections export def to_csv(self): return ( self.cs_port.device.identifier if self.cs_port else None, @@ -1152,7 +1180,6 @@ class PowerPort(models.Model): def __str__(self): return self.name - # Used for connections export def to_csv(self): return ( self.power_outlet.device.identifier if self.power_outlet else None, @@ -1381,7 +1408,6 @@ class InterfaceConnection(models.Model): except ObjectDoesNotExist: pass - # Used for connections export def to_csv(self): return ( self.interface_a.device.identifier, @@ -1452,7 +1478,7 @@ class InventoryItem(models.Model): description = models.CharField(max_length=100, blank=True) csv_headers = [ - 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', ] class Meta: @@ -1470,5 +1496,6 @@ class InventoryItem(models.Model): self.part_id, self.serial, self.asset_tag, - self.description + self.discovered, + self.description, ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index c67921e3e..afd533906 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -57,7 +57,7 @@ class VRFCSVForm(forms.ModelForm): class Meta: model = VRF - fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = VRF.csv_headers help_texts = { 'name': 'VRF name', } @@ -102,7 +102,7 @@ class RIRCSVForm(forms.ModelForm): class Meta: model = RIR - fields = ['name', 'slug', 'is_private'] + fields = RIR.csv_headers help_texts = { 'name': 'RIR name', } @@ -144,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm): class Meta: model = Aggregate - fields = ['prefix', 'rir', 'date_added', 'description'] + fields = Aggregate.csv_headers class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -185,7 +185,7 @@ class RoleCSVForm(forms.ModelForm): class Meta: model = Role - fields = ['name', 'slug'] + fields = Role.csv_headers help_texts = { 'name': 'Role name', } @@ -299,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm): class Meta: model = Prefix - fields = [ - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', - ] + fields = Prefix.csv_headers def clean(self): @@ -609,10 +607,7 @@ class IPAddressCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = [ - 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', - 'description', - ] + fields = IPAddress.csv_headers def clean(self): @@ -759,7 +754,7 @@ class VLANGroupCSVForm(forms.ModelForm): class Meta: model = VLANGroup - fields = ['site', 'name', 'slug'] + fields = VLANGroup.csv_headers help_texts = { 'name': 'Name of VLAN group', } @@ -849,7 +844,7 @@ class VLANCSVForm(forms.ModelForm): class Meta: model = VLAN - fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + fields = VLAN.csv_headers help_texts = { 'vid': 'Numeric VLAN ID (1-4095)', 'name': 'VLAN name', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index c238a4ec0..33da470b0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -74,6 +74,8 @@ class RIR(models.Model): is_private = models.BooleanField(default=False, verbose_name='Private', help_text='IP space managed by this RIR is considered private') + csv_headers = ['name', 'slug', 'is_private'] + class Meta: ordering = ['name'] verbose_name = 'RIR' @@ -85,6 +87,13 @@ class RIR(models.Model): def get_absolute_url(self): return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + self.is_private, + ) + @python_2_unicode_compatible class Aggregate(CreatedUpdatedModel, CustomFieldModel): @@ -172,12 +181,21 @@ class Role(models.Model): slug = models.SlugField(unique=True) weight = models.PositiveSmallIntegerField(default=1000) + csv_headers = ['name', 'slug', 'weight'] + class Meta: ordering = ['weight', 'name'] def __str__(self): return self.name + def to_csv(self): + return ( + self.name, + self.slug, + self.weight, + ) + @property def count_prefixes(self): return self.prefixes.count() @@ -501,6 +519,8 @@ class VLANGroup(models.Model): slug = models.SlugField() site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True) + csv_headers = ['name', 'slug', 'site'] + class Meta: ordering = ['site', 'name'] unique_together = [ @@ -516,6 +536,13 @@ class VLANGroup(models.Model): def get_absolute_url(self): return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) + def to_csv(self): + return ( + self.name, + self.slug, + self.site.name if self.site else None, + ) + def get_next_available_vid(self): """ Return the first available VLAN ID (1-4094) in the group. diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index af0ad92cc..bcc79e2a5 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -47,7 +47,7 @@ class SecretRoleCSVForm(forms.ModelForm): class Meta: model = SecretRole - fields = ['name', 'slug'] + fields = SecretRole.csv_headers help_texts = { 'name': 'Name of secret role', } @@ -98,7 +98,7 @@ class SecretCSVForm(forms.ModelForm): class Meta: model = Secret - fields = ['device', 'role', 'name', 'plaintext'] + fields = Secret.csv_headers help_texts = { 'name': 'Name or username', } diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index d4e9874b3..e1f367d03 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -239,6 +239,8 @@ class SecretRole(models.Model): users = models.ManyToManyField(User, related_name='secretroles', blank=True) groups = models.ManyToManyField(Group, related_name='secretroles', blank=True) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -248,6 +250,12 @@ class SecretRole(models.Model): def get_absolute_url(self): return "{}?role={}".format(reverse('secrets:secret_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + def has_member(self, user): """ Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles. diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 00194d4e8..4ea6c57ba 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -27,7 +27,7 @@ class TenantGroupCSVForm(forms.ModelForm): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = TenantGroup.csv_headers help_texts = { 'name': 'Group name', } @@ -60,7 +60,7 @@ class TenantCSVForm(forms.ModelForm): class Meta: model = Tenant - fields = ['name', 'slug', 'group', 'description', 'comments'] + fields = Tenant.csv_headers help_texts = { 'name': 'Tenant name', 'comments': 'Free-form comments' diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index f908463dc..f83e0abc9 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -17,6 +17,8 @@ class TenantGroup(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -26,6 +28,12 @@ class TenantGroup(models.Model): def get_absolute_url(self): return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + @python_2_unicode_compatible class Tenant(CreatedUpdatedModel, CustomFieldModel): @@ -57,4 +65,5 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): self.slug, self.group.name if self.group else None, self.description, + self.comments, ) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index d697de755..34e3fd5cc 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -41,7 +41,7 @@ class ClusterTypeCSVForm(forms.ModelForm): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = ClusterType.csv_headers help_texts = { 'name': 'Name of cluster type', } @@ -64,7 +64,7 @@ class ClusterGroupCSVForm(forms.ModelForm): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = ClusterGroup.csv_headers help_texts = { 'name': 'Name of cluster group', } @@ -112,7 +112,7 @@ class ClusterCSVForm(forms.ModelForm): class Meta: model = Cluster - fields = ['name', 'type', 'group', 'site', 'comments'] + fields = Cluster.csv_headers class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -306,7 +306,7 @@ class VirtualMachineCSVForm(forms.ModelForm): class Meta: model = VirtualMachine - fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] + fields = VirtualMachine.csv_headers class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 7eca5b1b8..5552fb089 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -30,6 +30,8 @@ class ClusterType(models.Model): unique=True ) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -39,6 +41,12 @@ class ClusterType(models.Model): def get_absolute_url(self): return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + # # Cluster groups @@ -57,6 +65,8 @@ class ClusterGroup(models.Model): unique=True ) + csv_headers = ['name', 'slug'] + class Meta: ordering = ['name'] @@ -66,6 +76,12 @@ class ClusterGroup(models.Model): def get_absolute_url(self): return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug) + def to_csv(self): + return ( + self.name, + self.slug, + ) + # # Clusters @@ -108,9 +124,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel): object_id_field='obj_id' ) - csv_headers = [ - 'name', 'type', 'group', 'site', 'comments', - ] + csv_headers = ['name', 'type', 'group', 'site', 'comments'] class Meta: ordering = ['name'] @@ -229,7 +243,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): ) csv_headers = [ - 'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ] class Meta: @@ -245,6 +259,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel): return ( self.name, self.get_status_display(), + self.role.name if self.role else None, self.cluster.name, self.tenant.name if self.tenant else None, self.platform.name if self.platform else None, From b96e3af6c71981981c6959699c4ead707ac411bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 16:12:57 -0500 Subject: [PATCH 07/13] Closes #1714: Standardized CSV export functionality for all object lists --- netbox/templates/circuits/circuit_list.html | 13 +++------- .../templates/circuits/circuittype_list.html | 12 +++------ netbox/templates/circuits/provider_list.html | 13 +++------- .../dcim/console_connections_list.html | 8 +++--- netbox/templates/dcim/device_list.html | 13 +++------- netbox/templates/dcim/devicerole_list.html | 12 +++------ netbox/templates/dcim/devicetype_list.html | 13 +++------- .../dcim/interface_connections_list.html | 8 +++--- netbox/templates/dcim/inventoryitem_list.html | 8 +++--- netbox/templates/dcim/manufacturer_list.html | 13 +++------- netbox/templates/dcim/platform_list.html | 12 +++------ .../dcim/power_connections_list.html | 8 +++--- netbox/templates/dcim/rack_list.html | 13 +++------- netbox/templates/dcim/rackgroup_list.html | 13 +++------- netbox/templates/dcim/region_list.html | 13 +++------- netbox/templates/dcim/site_list.html | 13 +++------- netbox/templates/inc/export_button.html | 20 -------------- netbox/templates/ipam/aggregate_list.html | 13 +++------- netbox/templates/ipam/ipaddress_list.html | 15 ++++------- netbox/templates/ipam/prefix_list.html | 15 ++++------- netbox/templates/ipam/rir_list.html | 12 +++------ netbox/templates/ipam/role_list.html | 12 +++------ netbox/templates/ipam/vlan_list.html | 15 ++++------- netbox/templates/ipam/vlangroup_list.html | 12 +++------ netbox/templates/ipam/vrf_list.html | 14 +++------- netbox/templates/secrets/secret_list.html | 6 ++--- netbox/templates/secrets/secretrole_list.html | 14 ++++------ netbox/templates/tenancy/tenant_list.html | 13 +++------- .../templates/tenancy/tenantgroup_list.html | 12 +++------ .../virtualization/cluster_list.html | 13 +++------- .../virtualization/clustergroup_list.html | 12 +++------ .../virtualization/clustertype_list.html | 12 +++------ .../virtualization/virtualmachine_list.html | 13 +++------- netbox/utilities/templates/buttons/add.html | 3 +++ .../utilities/templates/buttons/export.html | 19 ++++++++++++++ .../utilities/templates/buttons/import.html | 3 +++ netbox/utilities/templatetags/buttons.py | 26 +++++++++++++++++++ netbox/utilities/views.py | 6 ++--- 38 files changed, 180 insertions(+), 285 deletions(-) delete mode 100644 netbox/templates/inc/export_button.html create mode 100644 netbox/utilities/templates/buttons/add.html create mode 100644 netbox/utilities/templates/buttons/export.html create mode 100644 netbox/utilities/templates/buttons/import.html create mode 100644 netbox/utilities/templatetags/buttons.py diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html index 418459a15..de9922313 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.circuits.add_circuit %} - - - Add a circuit - - - - Import circuits - + {% add_button 'circuits:circuit_add' %} + {% import_button 'circuits:circuit_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='circuits' %} + {% export_button content_type %}

{% block title %}Circuits{% endblock %}

diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html index ce9cdf385..af48ecd0c 100644 --- a/netbox/templates/circuits/circuittype_list.html +++ b/netbox/templates/circuits/circuittype_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.circuits.add_circuittype %} - - - Add a circuit type - - - - Import circuit types - + {% add_button 'circuits:circuittype_add' %} + {% import_button 'circuits:circuittype_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Circuit Types{% endblock %}

diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html index 9ba8bb838..cb7aab406 100644 --- a/netbox/templates/circuits/provider_list.html +++ b/netbox/templates/circuits/provider_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.circuits.add_provider %} - - - Add a provider - - - - Import providers - + {% add_button 'circuits:provider_add' %} + {% import_button 'circuits:provider_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='providers' %} + {% export_button content_type %}

{% block title %}Providers{% endblock %}

diff --git a/netbox/templates/dcim/console_connections_list.html b/netbox/templates/dcim/console_connections_list.html index 57d3435e5..89eb0822d 100644 --- a/netbox/templates/dcim/console_connections_list.html +++ b/netbox/templates/dcim/console_connections_list.html @@ -1,14 +1,12 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.change_consoleport %} - - - Import connections - + {% import_button 'dcim:console_connections_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='connections' %} + {% export_button content_type %}

{% block title %}Console Connections{% endblock %}

diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 34b143fc5..cccdfe4c0 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_device %} - - - Add a device - - - - Import devices - + {% add_button 'dcim:device_add' %} + {% import_button 'dcim:device_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='devices' %} + {% export_button content_type %}

{% block title %}Devices{% endblock %}

diff --git a/netbox/templates/dcim/devicerole_list.html b/netbox/templates/dcim/devicerole_list.html index 871e62806..cf58d2b1d 100644 --- a/netbox/templates/dcim/devicerole_list.html +++ b/netbox/templates/dcim/devicerole_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_devicerole %} - - - Add a device role - - - - Import device roles - + {% add_button 'dcim:devicerole_add' %} + {% import_button 'dcim:devicerole_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Device Roles{% endblock %}

diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index 69807fcff..e0f365786 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_devicetype %} - - - Add a device type - - - - Import device types - + {% add_button 'dcim:devicetype_add' %} + {% import_button 'dcim:devicetype_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='device types' %} + {% export_button content_type %}

{% block title %}Device Types{% endblock %}

diff --git a/netbox/templates/dcim/interface_connections_list.html b/netbox/templates/dcim/interface_connections_list.html index a7355a449..950eb2f0b 100644 --- a/netbox/templates/dcim/interface_connections_list.html +++ b/netbox/templates/dcim/interface_connections_list.html @@ -1,14 +1,12 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_interfaceconnection %} - - - Import connections - + {% import_button 'dcim:interface_connections_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='connections' %} + {% export_button content_type %}

{% block title %}Interface Connections{% endblock %}

diff --git a/netbox/templates/dcim/inventoryitem_list.html b/netbox/templates/dcim/inventoryitem_list.html index 612534d98..5662b51d9 100644 --- a/netbox/templates/dcim/inventoryitem_list.html +++ b/netbox/templates/dcim/inventoryitem_list.html @@ -1,15 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_devicetype %} - - - Import inventory items - + {% import_button 'dcim:inventoryitem_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='inventory items' %} + {% export_button content_type %}

{% block title %}Inventory Items{% endblock %}

diff --git a/netbox/templates/dcim/manufacturer_list.html b/netbox/templates/dcim/manufacturer_list.html index ff6025af5..0ca9c40b3 100644 --- a/netbox/templates/dcim/manufacturer_list.html +++ b/netbox/templates/dcim/manufacturer_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_manufacturer %} - - - Add a manufacturer - - - - Import manufacturers - + {% add_button 'dcim:manufacturer_add' %} + {% import_button 'dcim:manufacturer_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='manufacturers' %} + {% export_button content_type %}

{% block title %}Manufacturers{% endblock %}

diff --git a/netbox/templates/dcim/platform_list.html b/netbox/templates/dcim/platform_list.html index dc8d43660..66dce9252 100644 --- a/netbox/templates/dcim/platform_list.html +++ b/netbox/templates/dcim/platform_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_platform %} - - - Add a platform - - - - Import platforms - + {% add_button 'dcim:platform_add' %} + {% import_button 'dcim:platform_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Platforms{% endblock %}

diff --git a/netbox/templates/dcim/power_connections_list.html b/netbox/templates/dcim/power_connections_list.html index 50e983bec..4e351eb6a 100644 --- a/netbox/templates/dcim/power_connections_list.html +++ b/netbox/templates/dcim/power_connections_list.html @@ -1,14 +1,12 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.change_powerport %} - - - Import connections - + {% import_button 'dcim:power_connections_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='connections' %} + {% export_button content_type %}

{% block title %}Power Connections{% endblock %}

diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html index 88b5a2f9d..eb00800ec 100644 --- a/netbox/templates/dcim/rack_list.html +++ b/netbox/templates/dcim/rack_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_rack %} - - - Add a rack - - - - Import racks - + {% add_button 'dcim:rack_add' %} + {% import_button 'dcim:rack_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='racks' %} + {% export_button content_type %}

{% block title %}Racks{% endblock %}

diff --git a/netbox/templates/dcim/rackgroup_list.html b/netbox/templates/dcim/rackgroup_list.html index d5853f11c..51989db0f 100644 --- a/netbox/templates/dcim/rackgroup_list.html +++ b/netbox/templates/dcim/rackgroup_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_rackgroup %} - - - Add a rack group - - - - Import rack groups - + {% add_button 'dcim:rackgroup_add' %} + {% import_button 'dcim:rackgroup_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='rack groups' %} + {% export_button content_type %}

{% block title %}Rack Groups{% endblock %}

diff --git a/netbox/templates/dcim/region_list.html b/netbox/templates/dcim/region_list.html index 4d61b4acb..d6b9f1c5a 100644 --- a/netbox/templates/dcim/region_list.html +++ b/netbox/templates/dcim/region_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.dcim.add_region %} - - - Add a region - - - - Import regions - + {% add_button 'dcim:region_add' %} + {% import_button 'dcim:region_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='regions' %} + {% export_button content_type %}

{% block title %}Regions{% endblock %}

diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html index 6d3e2f7f7..7baa76dad 100644 --- a/netbox/templates/dcim/site_list.html +++ b/netbox/templates/dcim/site_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.dcim.add_site %} - - - Add a site - - - - Import sites - + {% add_button 'dcim:site_add' %} + {% import_button 'dcim:site_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='sites' %} + {% export_button content_type %}

{% block title %}Sites{% endblock %}

diff --git a/netbox/templates/inc/export_button.html b/netbox/templates/inc/export_button.html deleted file mode 100644 index 8851fe1f7..000000000 --- a/netbox/templates/inc/export_button.html +++ /dev/null @@ -1,20 +0,0 @@ -{% if export_templates %} -
- - -
-{% else %} - - - Export {{ obj_type }} - -{% endif %} diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index df8e4772e..7b15479f6 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -1,20 +1,15 @@ {% extends '_base.html' %} +{% load buttons %} {% load humanize %} {% load helpers %} {% block content %}
{% if perms.ipam.add_aggregate %} - - - Add an aggregate - - - - Import aggregates - + {% add_button 'ipam:aggregate_add' %} + {% import_button 'ipam:aggregate_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='aggregates' %} + {% export_button content_type %}

{% block title %}Aggregates{% endblock %}

diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html index 2d273145d..9e378de54 100644 --- a/netbox/templates/ipam/ipaddress_list.html +++ b/netbox/templates/ipam/ipaddress_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.ipam.add_ipaddress %} - - - Add an IP - - - - Import IPs - - {% endif %} - {% include 'inc/export_button.html' with obj_type='IPs' %} + {% add_button 'ipam:ipaddress_add' %} + {% import_button 'ipam:ipaddress_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}IP Addresses{% endblock %}

diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 4747731f8..8e6d28d49 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% load form_helpers %} @@ -9,16 +10,10 @@ Expand
{% if perms.ipam.add_prefix %} - - - Add a prefix - - - - Import prefixes - - {% endif %} - {% include 'inc/export_button.html' with obj_type='prefixes' %} + {% add_button 'ipam:prefix_add' %} + {% import_button 'ipam:prefix_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}Prefixes{% endblock %}

diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index bd2a30c2a..40a21fc25 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load buttons %} {% load humanize %} {% load helpers %} @@ -16,15 +17,10 @@ {% endif %} {% if perms.ipam.add_rir %} - - - Add a RIR - - - - Import RIRs - + {% add_button 'ipam:rir_add' %} + {% import_button 'ipam:rir_import' %} {% endif %} + {% export_button content_type %}

{% block title %}RIRs{% endblock %}

diff --git a/netbox/templates/ipam/role_list.html b/netbox/templates/ipam/role_list.html index 54b0a8925..bc493e15b 100644 --- a/netbox/templates/ipam/role_list.html +++ b/netbox/templates/ipam/role_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.ipam.add_role %} - - - Add a role - - - - Import roles - + {% add_button 'ipam:role_add' %} + {% import_button 'ipam:role_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Prefix/VLAN Roles{% endblock %}

diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html index b5db84e52..29fc6a79d 100644 --- a/netbox/templates/ipam/vlan_list.html +++ b/netbox/templates/ipam/vlan_list.html @@ -1,20 +1,15 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% load form_helpers %} {% block content %}
{% if perms.ipam.add_vlan %} - - - Add a VLAN - - - - Import VLANs - - {% endif %} - {% include 'inc/export_button.html' with obj_type='VLANs' %} + {% add_button 'ipam:vlan_add' %} + {% import_button 'ipam:vlan_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}VLANs{% endblock %}

diff --git a/netbox/templates/ipam/vlangroup_list.html b/netbox/templates/ipam/vlangroup_list.html index 77d8e9b30..6eb63afdc 100644 --- a/netbox/templates/ipam/vlangroup_list.html +++ b/netbox/templates/ipam/vlangroup_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.ipam.add_vlangroup %} - - - Add a VLAN group - - - - Import VLAN groups - + {% add_button 'ipam:vlangroup_add' %} + {% import_button 'ipam:vlangroup_import' %} {% endif %} + {% export_button content_type %}

{% block title %}VLAN Groups{% endblock %}

diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html index 029426a14..479947554 100644 --- a/netbox/templates/ipam/vrf_list.html +++ b/netbox/templates/ipam/vrf_list.html @@ -5,16 +5,10 @@ {% block content %}
{% if perms.ipam.add_vrf %} - - - Add a VRF - - - - Import VRFs - - {% endif %} - {% include 'inc/export_button.html' with obj_type='VRFs' %} + {% add_button 'ipam:vrf_add' %} + {% import_button 'ipam:vrf_import' %} + {% endif %} + {% export_button content_type %}

{% block title %}VRFs{% endblock %}

diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html index 5293b5a6d..4e2aa9cb9 100644 --- a/netbox/templates/secrets/secret_list.html +++ b/netbox/templates/secrets/secret_list.html @@ -1,13 +1,11 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.secrets.add_secret %} - - - Import secrets - + {% import_button 'secrets:secret_import' %} {% endif %}

{% block title %}Secrets{% endblock %}

diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html index ccaa20730..c76c8f748 100644 --- a/netbox/templates/secrets/secretrole_list.html +++ b/netbox/templates/secrets/secretrole_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
- {% if perms.dcim.add_devicerole %} - - - Add a secret role - - - - Import secret roles - + {% if perms.secrets.add_secretrole %} + {% add_button 'secrets:secretrole_add' %} + {% import_button 'secrets:secretrole_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Secret Roles{% endblock %}

diff --git a/netbox/templates/tenancy/tenant_list.html b/netbox/templates/tenancy/tenant_list.html index afc1a154f..c2181f1b8 100644 --- a/netbox/templates/tenancy/tenant_list.html +++ b/netbox/templates/tenancy/tenant_list.html @@ -1,19 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.tenancy.add_tenant %} - - - Add a tenant - - - - Import tenants - + {% add_button 'tenancy:tenant_add' %} + {% import_button 'tenancy:tenant_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='tenants' %} + {% export_button content_type %}

{% block title %}Tenants{% endblock %}

diff --git a/netbox/templates/tenancy/tenantgroup_list.html b/netbox/templates/tenancy/tenantgroup_list.html index 3bffb5c6b..26bbb86bd 100644 --- a/netbox/templates/tenancy/tenantgroup_list.html +++ b/netbox/templates/tenancy/tenantgroup_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.tenancy.add_tenantgroup %} - - - Add a tenant group - - - - Import tenant groups - + {% add_button 'tenancy:tenantgroup_add' %} + {% import_button 'tenancy:tenantgroup_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Tenant Groups{% endblock %}

diff --git a/netbox/templates/virtualization/cluster_list.html b/netbox/templates/virtualization/cluster_list.html index dad7a9e49..08f62e6ba 100644 --- a/netbox/templates/virtualization/cluster_list.html +++ b/netbox/templates/virtualization/cluster_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.virtualization.add_cluster %} - - - Add a cluster - - - - Import clusters - + {% add_button 'virtualization:cluster_add' %} + {% import_button 'virtualization:cluster_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='clusters' %} + {% export_button content_type %}

{% block title %}Clusters{% endblock %}

diff --git a/netbox/templates/virtualization/clustergroup_list.html b/netbox/templates/virtualization/clustergroup_list.html index 6d28400c0..a5d042f65 100644 --- a/netbox/templates/virtualization/clustergroup_list.html +++ b/netbox/templates/virtualization/clustergroup_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.virtualization.add_clustergroup %} - - - Add a cluster group - - - - Import cluster groups - + {% add_button 'virtualization:clustergroup_add' %} + {% import_button 'virtualization:clustergroup_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Cluster Groups{% endblock %}

diff --git a/netbox/templates/virtualization/clustertype_list.html b/netbox/templates/virtualization/clustertype_list.html index 7d905ba06..b05ae9afe 100644 --- a/netbox/templates/virtualization/clustertype_list.html +++ b/netbox/templates/virtualization/clustertype_list.html @@ -1,18 +1,14 @@ {% extends '_base.html' %} +{% load buttons %} {% load helpers %} {% block content %}
{% if perms.virtualization.add_clustertype %} - - - Add a cluster type - - - - Import cluster types - + {% add_button 'virtualization:clustertype_add' %} + {% import_button 'virtualization:clustertype_import' %} {% endif %} + {% export_button content_type %}

{% block title %}Cluster Types{% endblock %}

diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index a771dfbd9..30ed76dae 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -1,18 +1,13 @@ {% extends '_base.html' %} +{% load buttons %} {% block content %}
{% if perms.virtualization.add_virtualmachine %} - - - Add a virtual machine - - - - Import virtual machines - + {% add_button 'virtualization:virtualmachine_add' %} + {% import_button 'virtualization:virtualmachine_import' %} {% endif %} - {% include 'inc/export_button.html' with obj_type='virtual machines' %} + {% export_button content_type %}

{% block title %}Virtual Machines{% endblock %}

diff --git a/netbox/utilities/templates/buttons/add.html b/netbox/utilities/templates/buttons/add.html new file mode 100644 index 000000000..a5278ae12 --- /dev/null +++ b/netbox/utilities/templates/buttons/add.html @@ -0,0 +1,3 @@ + + Add + diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html new file mode 100644 index 000000000..ee76dae6c --- /dev/null +++ b/netbox/utilities/templates/buttons/export.html @@ -0,0 +1,19 @@ +{% if export_templates %} +
+ + +
+{% else %} + + Export + +{% endif %} \ No newline at end of file diff --git a/netbox/utilities/templates/buttons/import.html b/netbox/utilities/templates/buttons/import.html new file mode 100644 index 000000000..67be77871 --- /dev/null +++ b/netbox/utilities/templates/buttons/import.html @@ -0,0 +1,3 @@ + + Import + diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py new file mode 100644 index 000000000..3090f4538 --- /dev/null +++ b/netbox/utilities/templatetags/buttons.py @@ -0,0 +1,26 @@ +from __future__ import unicode_literals + +from django import template + +from extras.models import ExportTemplate + +register = template.Library() + + +@register.inclusion_tag('buttons/add.html') +def add_button(url): + return {'add_url': url} + + +@register.inclusion_tag('buttons/import.html') +def import_button(url): + return {'import_url': url} + + +@register.inclusion_tag('buttons/export.html', takes_context=True) +def export_button(context, content_type=None): + export_templates = ExportTemplate.objects.filter(content_type=content_type) + return { + 'url_params': context['request'].GET, + 'export_templates': export_templates, + } diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 917cf3002..7e1a34e19 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -79,7 +79,7 @@ class ObjectListView(View): def get(self, request): model = self.queryset.model - object_ct = ContentType.objects.get_for_model(model) + content_type = ContentType.objects.get_for_model(model) if self.filter: self.queryset = self.filter(request.GET, self.queryset).qs @@ -92,7 +92,7 @@ class ObjectListView(View): # Check for export template rendering if request.GET.get('export'): - et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) + et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export')) queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset try: return et.render_to_response(queryset) @@ -125,10 +125,10 @@ class ObjectListView(View): RequestConfig(request, paginate).configure(table) context = { + 'content_type': content_type, 'table': table, 'permissions': permissions, 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None, - 'export_templates': ExportTemplate.objects.filter(content_type=object_ct), } context.update(self.extra_context()) From a9fefbec5cb1d4363b94b7223d3b6c958ab999c6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 16:23:07 -0500 Subject: [PATCH 08/13] Added missing CSV header --- netbox/tenancy/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index f83e0abc9..1fea2ceaf 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -48,7 +48,7 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') - csv_headers = ['name', 'slug', 'group', 'description'] + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] class Meta: ordering = ['group', 'name'] From 1890e710cb62ce5a0fcf79619287a6aa518bb3fc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 16:31:23 -0500 Subject: [PATCH 09/13] Fixed quoting of line breaks inside a CSV field --- netbox/utilities/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 84d6d444a..c08bfef8c 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -27,7 +27,7 @@ def csv_format(data): value = '{}'.format(value) # Double-quote the value if it contains a comma - if ',' in value: + if ',' in value or '\n' in value: csv.append('"{}"'.format(value)) else: csv.append('{}'.format(value)) From 7f5a3fffd34060725994bc0423858705f124d74c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 16:49:38 -0500 Subject: [PATCH 10/13] Fixed related object links for platform/role tables --- netbox/dcim/tables.py | 14 ++++++++++---- netbox/ipam/tables.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 0349396fa..79a356085 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -83,6 +83,14 @@ MANUFACTURER_ACTIONS = """ {% endif %} """ +PLATFORM_DEVICE_COUNT = """ +{{ value }} +""" + +PLATFORM_VM_COUNT = """ +{{ value }} +""" + PLATFORM_ACTIONS = """ {% if perms.dcim.change_platform %} @@ -380,10 +388,8 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - device_count = tables.Column(verbose_name='Devices') - vm_count = tables.Column(verbose_name='VMs') - slug = tables.Column(verbose_name='Slug') + device_count = tables.TemplateColumn(template_code=PLATFORM_DEVICE_COUNT, verbose_name='Devices') + vm_count = tables.TemplateColumn(template_code=PLATFORM_VM_COUNT, verbose_name='VMs') actions = tables.TemplateColumn( template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 32f04c223..9912cc566 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -37,6 +37,14 @@ UTILIZATION_GRAPH = """ {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %} """ +ROLE_PREFIX_COUNT = """ +{{ value }} +""" + +ROLE_VLAN_COUNT = """ +{{ value }} +""" + ROLE_ACTIONS = """ {% if perms.ipam.change_role %} @@ -220,9 +228,8 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() - name = tables.Column(verbose_name='Name') - prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes') - vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs') + prefix_count = tables.TemplateColumn(accessor=Accessor('count_prefixes'), template_code=ROLE_PREFIX_COUNT, orderable=False, verbose_name='Prefixes') + vlan_count = tables.TemplateColumn(accessor=Accessor('count_vlans'), template_code=ROLE_VLAN_COUNT, orderable=False, verbose_name='VLANs') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') From 835d13542f32b75135ff018bac6bf71f32073e64 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 17:11:46 -0500 Subject: [PATCH 11/13] Fixes #1858: Include device/CM count for cluster list in global search results --- netbox/netbox/views.py | 2 +- netbox/virtualization/tables.py | 5 +++-- netbox/virtualization/views.py | 5 +---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index e5fb311db..0f240fff3 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -119,7 +119,7 @@ SEARCH_TYPES = OrderedDict(( }), # Virtualization ('cluster', { - 'queryset': Cluster.objects.all(), + 'queryset': Cluster.objects.select_related('type', 'group'), 'filter': ClusterFilter, 'table': ClusterTable, 'url': 'virtualization:cluster_list', diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 2ace86d77..4d38a3fe5 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -80,8 +80,9 @@ class ClusterGroupTable(BaseTable): class ClusterTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - device_count = tables.Column(verbose_name='Devices') - vm_count = tables.Column(verbose_name='VMs') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) + device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices') + vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs') class Meta(BaseTable.Meta): model = Cluster diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4f2981748..6874efd16 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -99,10 +99,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ClusterListView(ObjectListView): - queryset = Cluster.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = Cluster.objects.select_related('type', 'group') table = tables.ClusterTable filter = filters.ClusterFilter filter_form = forms.ClusterFilterForm From d25d8c21f603ff7a4f55f6b54672e92fde4aefdd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 17:46:23 -0500 Subject: [PATCH 12/13] Eliminated queries for distinct related object counts for better performance --- netbox/dcim/tables.py | 55 +++++++++++++++++++++++++++++----- netbox/dcim/views.py | 12 ++------ netbox/ipam/models.py | 8 ----- netbox/ipam/tables.py | 15 ++++++++-- netbox/virtualization/views.py | 5 +--- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 79a356085..b9efb3a28 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -65,6 +65,10 @@ RACK_ROLE = """ {% endif %} """ +RACK_DEVICE_COUNT = """ +{{ value }} +""" + RACKRESERVATION_ACTIONS = """ {% if perms.dcim.change_rackreservation %} @@ -83,6 +87,14 @@ MANUFACTURER_ACTIONS = """ {% endif %} """ +DEVICEROLE_DEVICE_COUNT = """ +{{ value }} +""" + +DEVICEROLE_VM_COUNT = """ +{{ value }} +""" + PLATFORM_DEVICE_COUNT = """ {{ value }} """ @@ -226,12 +238,16 @@ class RackTable(BaseTable): class RackDetailTable(RackTable): - devices = tables.Column(accessor=Accessor('device_count')) + device_count = tables.TemplateColumn( + template_code=RACK_DEVICE_COUNT, + verbose_name='Devices' + ) get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') class Meta(RackTable.Meta): fields = ( - 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization' + 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', + 'get_utilization', ) @@ -370,12 +386,25 @@ class DeviceBayTemplateTable(BaseTable): class DeviceRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') - device_count = tables.Column(verbose_name='Devices') - vm_count = tables.Column(verbose_name='VMs') + device_count = tables.TemplateColumn( + template_code=DEVICEROLE_DEVICE_COUNT, + accessor=Accessor('devices.count'), + orderable=False, + verbose_name='Devices' + ) + vm_count = tables.TemplateColumn( + template_code=DEVICEROLE_VM_COUNT, + accessor=Accessor('virtual_machines.count'), + orderable=False, + verbose_name='VMs' + ) color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + actions = tables.TemplateColumn( + template_code=DEVICEROLE_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = DeviceRole @@ -388,8 +417,18 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() - device_count = tables.TemplateColumn(template_code=PLATFORM_DEVICE_COUNT, verbose_name='Devices') - vm_count = tables.TemplateColumn(template_code=PLATFORM_VM_COUNT, verbose_name='VMs') + device_count = tables.TemplateColumn( + template_code=PLATFORM_DEVICE_COUNT, + accessor=Accessor('devices.count'), + orderable=False, + verbose_name='Devices' + ) + vm_count = tables.TemplateColumn( + template_code=PLATFORM_VM_COUNT, + accessor=Accessor('virtual_machines.count'), + orderable=False, + verbose_name='VMs' + ) actions = tables.TemplateColumn( template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}}, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a66cee593..6ac52f58d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -276,7 +276,7 @@ class RackListView(ObjectListView): ).prefetch_related( 'devices__device_type' ).annotate( - device_count=Count('devices', distinct=True) + device_count=Count('devices') ) filter = filters.RackFilter filter_form = forms.RackFilterForm @@ -715,10 +715,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceRoleListView(ObjectListView): - queryset = DeviceRole.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable template_name = 'dcim/devicerole_list.html' @@ -756,10 +753,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class PlatformListView(ObjectListView): - queryset = Platform.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = Platform.objects.all() table = tables.PlatformTable template_name = 'dcim/platform_list.html' diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 33da470b0..3ed673c78 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -196,14 +196,6 @@ class Role(models.Model): self.weight, ) - @property - def count_prefixes(self): - return self.prefixes.count() - - @property - def count_vlans(self): - return self.vlans.count() - @python_2_unicode_compatible class Prefix(CreatedUpdatedModel, CustomFieldModel): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 9912cc566..bfc65dacc 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -228,9 +228,18 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() - prefix_count = tables.TemplateColumn(accessor=Accessor('count_prefixes'), template_code=ROLE_PREFIX_COUNT, orderable=False, verbose_name='Prefixes') - vlan_count = tables.TemplateColumn(accessor=Accessor('count_vlans'), template_code=ROLE_VLAN_COUNT, orderable=False, verbose_name='VLANs') - slug = tables.Column(verbose_name='Slug') + prefix_count = tables.TemplateColumn( + accessor=Accessor('prefixes.count'), + template_code=ROLE_PREFIX_COUNT, + orderable=False, + verbose_name='Prefixes' + ) + vlan_count = tables.TemplateColumn( + accessor=Accessor('vlans.count'), + template_code=ROLE_VLAN_COUNT, + orderable=False, + verbose_name='VLANs' + ) actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') class Meta(BaseTable.Meta): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 6874efd16..119388dd9 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -159,10 +159,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView): class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_cluster' cls = Cluster - queryset = Cluster.objects.annotate( - device_count=Count('devices', distinct=True), - vm_count=Count('virtual_machines', distinct=True) - ) + queryset = Cluster.objects.all() table = tables.ClusterTable default_return_url = 'virtualization:cluster_list' From 594ef71027630d07b0c02866c03bf55f1d23b527 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 2 Feb 2018 21:30:16 -0500 Subject: [PATCH 13/13] Fixes #1860: Do not populate initial values for custom fields when editing objects in bulk --- netbox/extras/forms.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index e39754ae0..22a604dd0 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -22,10 +22,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F for cf in custom_fields: field_name = 'cf_{}'.format(str(cf.name)) + initial = cf.default if not bulk_edit else None # Integer if cf.type == CF_TYPE_INTEGER: - field = forms.IntegerField(required=cf.required, initial=cf.default) + field = forms.IntegerField(required=cf.required, initial=initial) # Boolean elif cf.type == CF_TYPE_BOOLEAN: @@ -34,18 +35,19 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F (1, 'True'), (0, 'False'), ) - if cf.default.lower() in ['true', 'yes', '1']: + if initial.lower() in ['true', 'yes', '1']: initial = 1 - elif cf.default.lower() in ['false', 'no', '0']: + elif initial.lower() in ['false', 'no', '0']: initial = 0 else: initial = None - field = forms.NullBooleanField(required=cf.required, initial=initial, - widget=forms.Select(choices=choices)) + field = forms.NullBooleanField( + required=cf.required, initial=initial, widget=forms.Select(choices=choices) + ) # Date elif cf.type == CF_TYPE_DATE: - field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD") + field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD") # Select elif cf.type == CF_TYPE_SELECT: @@ -56,11 +58,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F # URL elif cf.type == CF_TYPE_URL: - field = LaxURLField(required=cf.required, initial=cf.default) + field = LaxURLField(required=cf.required, initial=initial) # Text else: - field = forms.CharField(max_length=255, required=cf.required, initial=cf.default) + field = forms.CharField(max_length=255, required=cf.required, initial=initial) field.model = cf field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()