Просмотр исходного кода

Merge branch 'develop' into develop-2.3

Jeremy Stretch 8 лет назад
Родитель
Сommit
73c64272d8
62 измененных файлов с 571 добавлено и 515 удалено
  1. 29 23
      CONTRIBUTING.md
  2. 12 4
      README.md
  3. 2 2
      netbox/circuits/forms.py
  4. 21 8
      netbox/circuits/models.py
  5. 13 26
      netbox/dcim/forms.py
  6. 69 43
      netbox/dcim/models.py
  7. 55 10
      netbox/dcim/tables.py
  8. 3 9
      netbox/dcim/views.py
  9. 10 8
      netbox/extras/forms.py
  10. 10 4
      netbox/extras/models.py
  11. 8 13
      netbox/ipam/forms.py
  12. 37 19
      netbox/ipam/models.py
  13. 20 4
      netbox/ipam/tables.py
  14. 1 1
      netbox/netbox/views.py
  15. 2 2
      netbox/secrets/forms.py
  16. 8 0
      netbox/secrets/models.py
  17. 4 9
      netbox/templates/circuits/circuit_list.html
  18. 4 8
      netbox/templates/circuits/circuittype_list.html
  19. 4 9
      netbox/templates/circuits/provider_list.html
  20. 3 5
      netbox/templates/dcim/console_connections_list.html
  21. 4 9
      netbox/templates/dcim/device_list.html
  22. 4 8
      netbox/templates/dcim/devicerole_list.html
  23. 4 9
      netbox/templates/dcim/devicetype_list.html
  24. 3 5
      netbox/templates/dcim/interface_connections_list.html
  25. 3 5
      netbox/templates/dcim/inventoryitem_list.html
  26. 4 9
      netbox/templates/dcim/manufacturer_list.html
  27. 4 8
      netbox/templates/dcim/platform_list.html
  28. 3 5
      netbox/templates/dcim/power_connections_list.html
  29. 4 9
      netbox/templates/dcim/rack_list.html
  30. 4 9
      netbox/templates/dcim/rackgroup_list.html
  31. 4 9
      netbox/templates/dcim/region_list.html
  32. 4 9
      netbox/templates/dcim/site_list.html
  33. 0 20
      netbox/templates/inc/export_button.html
  34. 4 9
      netbox/templates/ipam/aggregate_list.html
  35. 5 10
      netbox/templates/ipam/ipaddress_list.html
  36. 5 10
      netbox/templates/ipam/prefix_list.html
  37. 4 8
      netbox/templates/ipam/rir_list.html
  38. 4 8
      netbox/templates/ipam/role_list.html
  39. 5 10
      netbox/templates/ipam/vlan_list.html
  40. 4 8
      netbox/templates/ipam/vlangroup_list.html
  41. 4 10
      netbox/templates/ipam/vrf_list.html
  42. 2 4
      netbox/templates/secrets/secret_list.html
  43. 5 9
      netbox/templates/secrets/secretrole_list.html
  44. 4 9
      netbox/templates/tenancy/tenant_list.html
  45. 4 8
      netbox/templates/tenancy/tenantgroup_list.html
  46. 4 9
      netbox/templates/virtualization/cluster_list.html
  47. 4 8
      netbox/templates/virtualization/clustergroup_list.html
  48. 4 8
      netbox/templates/virtualization/clustertype_list.html
  49. 4 9
      netbox/templates/virtualization/virtualmachine_list.html
  50. 2 2
      netbox/tenancy/forms.py
  51. 12 4
      netbox/tenancy/models.py
  52. 3 7
      netbox/utilities/forms.py
  53. 3 0
      netbox/utilities/templates/buttons/add.html
  54. 19 0
      netbox/utilities/templates/buttons/export.html
  55. 3 0
      netbox/utilities/templates/buttons/import.html
  56. 26 0
      netbox/utilities/templatetags/buttons.py
  57. 34 1
      netbox/utilities/utils.py
  58. 11 20
      netbox/utilities/views.py
  59. 4 4
      netbox/virtualization/forms.py
  60. 23 9
      netbox/virtualization/models.py
  61. 3 2
      netbox/virtualization/tables.py
  62. 2 8
      netbox/virtualization/views.py

+ 29 - 23
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.

+ 12 - 4
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
 

+ 2 - 2
netbox/circuits/forms.py

@@ -44,7 +44,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',
@@ -90,7 +90,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
 
     class Meta:
         model = CircuitType
-        fields = ['name', 'slug']
+        fields = CircuitType.csv_headers
         help_texts = {
             'name': 'Name of circuit type',
         }

+ 21 - 8
netbox/circuits/models.py

@@ -10,7 +10,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 CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 
 
@@ -30,7 +29,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']
@@ -42,13 +41,16 @@ 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,
-        ])
+            self.noc_contact,
+            self.admin_contact,
+            self.comments,
+        )
 
 
 @python_2_unicode_compatible
@@ -60,6 +62,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']
 
@@ -69,6 +73,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):
@@ -88,7 +98,9 @@ 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', 'status', 'tenant', 'install_date', 'commit_rate', 'description']
+    csv_headers = [
+        'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
+    ]
 
     class Meta:
         ordering = ['provider', 'cid']
@@ -101,16 +113,17 @@ 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.get_status_display(),
             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,
-        ])
+            self.comments,
+        )
 
     def get_status_class(self):
         return STATUS_CLASSES[self.status]

+ 13 - 26
netbox/dcim/forms.py

@@ -83,9 +83,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',
@@ -153,10 +151,7 @@ class SiteCSVForm(forms.ModelForm):
 
     class Meta:
         model = Site
-        fields = [
-            'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'description', 'physical_address',
-            'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone', 'comments',
-        ]
+        fields = Site.csv_headers
         help_texts = {
             'name': 'Site name',
             'slug': 'URL-friendly slug',
@@ -224,9 +219,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',
@@ -254,7 +247,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)'
@@ -341,10 +334,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',
@@ -478,9 +468,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',
@@ -526,8 +514,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',
@@ -692,7 +679,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)'
@@ -716,7 +703,7 @@ class PlatformCSVForm(forms.ModelForm):
 
     class Meta:
         model = Platform
-        fields = ['name', 'slug', 'manufacturer', 'napalm_driver']
+        fields = Platform.csv_headers
         help_texts = {
             'name': 'Platform name',
             'manufacturer': 'Manufacturer name',
@@ -970,7 +957,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):
@@ -1019,7 +1006,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):
@@ -2096,7 +2083,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):
 
@@ -2238,7 +2225,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):

+ 69 - 43
netbox/dcim/models.py

@@ -23,7 +23,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
@@ -44,9 +43,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']
@@ -58,11 +55,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,
-        ])
+        )
 
 
 #
@@ -102,8 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
 
     csv_headers = [
-        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'contact_name',
-        'contact_phone', 'contact_email',
+        'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+        'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
     ]
 
     class Meta:
@@ -116,7 +113,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.get_status_display(),
@@ -126,10 +123,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
             self.asn,
             self.time_zone,
             self.description,
+            self.physical_address,
+            self.shipping_address,
             self.contact_name,
             self.contact_phone,
             self.contact_email,
-        ])
+            self.comments,
+        )
 
     def get_status_class(self):
         return STATUS_CLASSES[self.status]
@@ -175,9 +175,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']
@@ -193,11 +191,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
@@ -209,6 +207,8 @@ class RackRole(models.Model):
     slug = models.SlugField(unique=True)
     color = ColorField()
 
+    csv_headers = ['name', 'slug', 'color']
+
     class Meta:
         ordering = ['name']
 
@@ -218,6 +218,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):
 
@@ -253,7 +260,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,7 +310,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,
@@ -315,7 +322,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             self.width,
             self.u_height,
             self.desc_units,
-        ])
+            self.comments,
+        )
 
     @property
     def units(self):
@@ -491,9 +499,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']
@@ -505,10 +511,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
@@ -551,7 +557,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:
@@ -574,7 +580,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,
@@ -586,7 +592,8 @@ 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):
 
@@ -766,6 +773,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']
 
@@ -775,6 +784,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):
@@ -805,6 +822,8 @@ class Platform(models.Model):
         verbose_name="Legacy RPC client"
     )
 
+    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
+
     class Meta:
         ordering = ['name']
 
@@ -814,6 +833,14 @@ 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.manufacturer.name if self.manufacturer else None,
+            self.napalm_driver,
+        )
+
 
 class DeviceManager(NaturalOrderByManager):
 
@@ -892,7 +919,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:
@@ -1049,7 +1076,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,
@@ -1064,7 +1091,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             self.rack.name if self.rack else None,
             self.position,
             self.get_face_display(),
-        ])
+            self.comments,
+        )
 
     @property
     def display_name(self):
@@ -1158,15 +1186,14 @@ class ConsolePort(models.Model):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
-    # 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(),
-        ])
+        )
 
 
 #
@@ -1241,15 +1268,14 @@ class PowerPort(models.Model):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
 
-    # 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(),
-        ])
+        )
 
 
 #
@@ -1501,15 +1527,14 @@ class InterfaceConnection(models.Model):
         except ObjectDoesNotExist:
             pass
 
-    # 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(),
-        ])
+        )
 
 
 #
@@ -1575,7 +1600,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:
@@ -1589,15 +1614,16 @@ class InventoryItem(models.Model):
         return self.device.get_absolute_url()
 
     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,
             self.part_id,
             self.serial,
             self.asset_tag,
-            self.description
-        ])
+            self.discovered,
+            self.description,
+        )
 
 
 #
@@ -1632,4 +1658,4 @@ class VirtualChassis(models.Model):
         if self.pk and self.master not in self.members.all():
             raise ValidationError({
                 'master': "The selected master is not assigned to this virtual chassis."
-            })
+            })

+ 55 - 10
netbox/dcim/tables.py

@@ -66,6 +66,10 @@ RACK_ROLE = """
 {% endif %}
 """
 
+RACK_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
+"""
+
 RACKRESERVATION_ACTIONS = """
 {% if perms.dcim.change_rackreservation %}
     <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -84,6 +88,22 @@ MANUFACTURER_ACTIONS = """
 {% endif %}
 """
 
+DEVICEROLE_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
+DEVICEROLE_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
+PLATFORM_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
+"""
+
+PLATFORM_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
+"""
+
 PLATFORM_ACTIONS = """
 {% if perms.dcim.change_platform %}
     <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -211,12 +231,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',
         )
 
 
@@ -357,12 +381,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
@@ -375,10 +412,18 @@ 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,
+        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'}},

+ 3 - 9
netbox/dcim/views.py

@@ -321,7 +321,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
@@ -763,10 +763,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'
 
@@ -804,10 +801,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'
 

+ 10 - 8
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()

+ 10 - 4
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
 
 

+ 8 - 13
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',

+ 37 - 19
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):
@@ -75,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'
@@ -86,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):
@@ -147,12 +155,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):
         """
@@ -173,19 +181,20 @@ 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
 
-    @property
-    def count_prefixes(self):
-        return self.prefixes.count()
-
-    @property
-    def count_vlans(self):
-        return self.vlans.count()
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.weight,
+        )
 
 
 @python_2_unicode_compatible
@@ -262,7 +271,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 +282,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 +470,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 +481,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             self.interface.name if self.interface else None,
             is_primary,
             self.description,
-        ])
+        )
 
     @property
     def device(self):
@@ -502,6 +511,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 = [
@@ -517,6 +528,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.
@@ -577,7 +595,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 +604,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
             self.get_status_display(),
             self.role.name if self.role else None,
             self.description,
-        ])
+        )
 
     @property
     def display_name(self):

+ 20 - 4
netbox/ipam/tables.py

@@ -37,6 +37,14 @@ UTILIZATION_GRAPH = """
 {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
 """
 
+ROLE_PREFIX_COUNT = """
+<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
+ROLE_VLAN_COUNT = """
+<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
 ROLE_ACTIONS = """
 {% if perms.ipam.change_role %}
     <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -220,10 +228,18 @@ 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')
-    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):

+ 1 - 1
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',

+ 2 - 2
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',
         }

+ 8 - 0
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.

+ 4 - 9
netbox/templates/circuits/circuit_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.circuits.add_circuit %}
-		<a href="{% url 'circuits:circuit_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a circuit
-		</a>
-        <a href="{% url 'circuits:circuit_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import circuits
-        </a>
+        {% add_button 'circuits:circuit_add' %}
+        {% import_button 'circuits:circuit_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='circuits' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Circuits{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/circuits/circuittype_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.circuits.add_circuittype %}
-        <a href="{% url 'circuits:circuittype_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a circuit type
-        </a>
-        <a href="{% url 'circuits:circuittype_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import circuit types
-        </a>
+        {% add_button 'circuits:circuittype_add' %}
+        {% import_button 'circuits:circuittype_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Circuit Types{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/circuits/provider_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.circuits.add_provider %}
-		<a href="{% url 'circuits:provider_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a provider
-		</a>
-        <a href="{% url 'circuits:provider_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import providers
-        </a>
+        {% add_button 'circuits:provider_add' %}
+        {% import_button 'circuits:provider_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='providers' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Providers{% endblock %}</h1>
 <div class="row">

+ 3 - 5
netbox/templates/dcim/console_connections_list.html

@@ -1,14 +1,12 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.change_consoleport %}
-        <a href="{% url 'dcim:console_connections_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import connections
-        </a>
+        {% import_button 'dcim:console_connections_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Console Connections{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/device_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_device %}
-        <a href="{% url 'dcim:device_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a device
-        </a>
-        <a href="{% url 'dcim:device_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import devices
-        </a>
+        {% add_button 'dcim:device_add' %}
+        {% import_button 'dcim:device_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='devices' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Devices{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/dcim/devicerole_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_devicerole %}
-        <a href="{% url 'dcim:devicerole_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a device role
-        </a>
-        <a href="{% url 'dcim:devicerole_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import device roles
-        </a>
+        {% add_button 'dcim:devicerole_add' %}
+        {% import_button 'dcim:devicerole_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Device Roles{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/devicetype_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_devicetype %}
-        <a href="{% url 'dcim:devicetype_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a device type
-        </a>
-        <a href="{% url 'dcim:devicetype_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import device types
-        </a>
+        {% 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 %}
 </div>
 <h1>{% block title %}Device Types{% endblock %}</h1>
 <div class="row">

+ 3 - 5
netbox/templates/dcim/interface_connections_list.html

@@ -1,14 +1,12 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_interfaceconnection %}
-        <a href="{% url 'dcim:interface_connections_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import connections
-        </a>
+        {% import_button 'dcim:interface_connections_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Interface Connections{% endblock %}</h1>
 <div class="row">

+ 3 - 5
netbox/templates/dcim/inventoryitem_list.html

@@ -1,15 +1,13 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_devicetype %}
-        <a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import inventory items
-        </a>
+        {% import_button 'dcim:inventoryitem_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='inventory items' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Inventory Items{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/manufacturer_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_manufacturer %}
-        <a href="{% url 'dcim:manufacturer_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a manufacturer
-        </a>
-        <a href="{% url 'dcim:manufacturer_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import manufacturers
-        </a>
+        {% add_button 'dcim:manufacturer_add' %}
+        {% import_button 'dcim:manufacturer_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='manufacturers' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Manufacturers{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/dcim/platform_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_platform %}
-        <a href="{% url 'dcim:platform_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a platform
-        </a>
-        <a href="{% url 'dcim:platform_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import platforms
-        </a>
+        {% add_button 'dcim:platform_add' %}
+        {% import_button 'dcim:platform_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Platforms{% endblock %}</h1>
 <div class="row">

+ 3 - 5
netbox/templates/dcim/power_connections_list.html

@@ -1,14 +1,12 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.change_powerport %}
-        <a href="{% url 'dcim:power_connections_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import connections
-        </a>
+        {% import_button 'dcim:power_connections_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Power Connections{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/rack_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_rack %}
-        <a href="{% url 'dcim:rack_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a rack
-        </a>
-        <a href="{% url 'dcim:rack_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import racks
-        </a>
+        {% add_button 'dcim:rack_add' %}
+        {% import_button 'dcim:rack_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='racks' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Racks{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/rackgroup_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_rackgroup %}
-        <a href="{% url 'dcim:rackgroup_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a rack group
-        </a>
-        <a href="{% url 'dcim:rackgroup_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import rack groups
-        </a>
+        {% 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 %}
 </div>
 <h1>{% block title %}Rack Groups{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/region_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_region %}
-        <a href="{% url 'dcim:region_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a region
-        </a>
-        <a href="{% url 'dcim:region_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import regions
-        </a>
+        {% add_button 'dcim:region_add' %}
+        {% import_button 'dcim:region_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='regions' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Regions{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/dcim/site_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_site %}
-		<a href="{% url 'dcim:site_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a site
-		</a>
-        <a href="{% url 'dcim:site_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import sites
-        </a>
+        {% add_button 'dcim:site_add' %}
+        {% import_button 'dcim:site_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='sites' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Sites{% endblock %}</h1>
 <div class="row">

+ 0 - 20
netbox/templates/inc/export_button.html

@@ -1,20 +0,0 @@
-{% if export_templates %}
-    <div class="btn-group">
-        <button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-            <span class="fa fa-upload" aria-hidden="true"></span>
-            Export {{ obj_type }} <span class="caret"></span>
-        </button>
-        <ul class="dropdown-menu">
-            <li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export">CSV (default)</a></li>
-            <li class="divider"></li>
-            {% for et in export_templates %}
-                <li><a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
-            {% endfor %}
-        </ul>
-    </div>
-{% else %}
-    <a href="?{% if request.GET %}{{ request.GET.urlencode }}&{% endif %}export" class="btn btn-success">
-        <span class="fa fa-upload" aria-hidden="true"></span>
-        Export {{ obj_type }}
-    </a>
-{% endif %}

+ 4 - 9
netbox/templates/ipam/aggregate_list.html

@@ -1,20 +1,15 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load humanize %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_aggregate %}
-		<a href="{% url 'ipam:aggregate_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add an aggregate
-		</a>
-        <a href="{% url 'ipam:aggregate_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import aggregates
-        </a>
+        {% add_button 'ipam:aggregate_add' %}
+        {% import_button 'ipam:aggregate_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='aggregates' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Aggregates{% endblock %}</h1>
 <div class="row">

+ 5 - 10
netbox/templates/ipam/ipaddress_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_ipaddress %}
-		<a href="{% url 'ipam:ipaddress_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add an IP
-		</a>
-		<a href="{% url 'ipam:ipaddress_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import IPs
-		</a>
-	{% 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 %}
 </div>
 <h1>{% block title %}IP Addresses{% endblock %}</h1>
 <div class="row">

+ 5 - 10
netbox/templates/ipam/prefix_list.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load form_helpers %}
 
@@ -9,16 +10,10 @@
         <a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a>
     </div>
     {% if perms.ipam.add_prefix %}
-		<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a prefix
-		</a>
-		<a href="{% url 'ipam:prefix_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import prefixes
-		</a>
-	{% 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 %}
 </div>
 <h1>{% block title %}Prefixes{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/ipam/rir_list.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load humanize %}
 {% load helpers %}
 
@@ -16,15 +17,10 @@
         </a>
     {% endif %}
     {% if perms.ipam.add_rir %}
-        <a href="{% url 'ipam:rir_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a RIR
-        </a>
-        <a href="{% url 'ipam:rir_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import RIRs
-        </a>
+        {% add_button 'ipam:rir_add' %}
+        {% import_button 'ipam:rir_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}RIRs{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/ipam/role_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_role %}
-        <a href="{% url 'ipam:role_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a role
-        </a>
-        <a href="{% url 'ipam:role_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import roles
-        </a>
+        {% add_button 'ipam:role_add' %}
+        {% import_button 'ipam:role_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1>
 <div class="row">

+ 5 - 10
netbox/templates/ipam/vlan_list.html

@@ -1,20 +1,15 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load form_helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_vlan %}
-		<a href="{% url 'ipam:vlan_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a VLAN
-		</a>
-		<a href="{% url 'ipam:vlan_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import VLANs
-		</a>
-	{% 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 %}
 </div>
 <h1>{% block title %}VLANs{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/ipam/vlangroup_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_vlangroup %}
-        <a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a VLAN group
-        </a>
-        <a href="{% url 'ipam:vlangroup_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import VLAN groups
-        </a>
+        {% add_button 'ipam:vlangroup_add' %}
+        {% import_button 'ipam:vlangroup_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}VLAN Groups{% endblock %}</h1>
 <div class="row">

+ 4 - 10
netbox/templates/ipam/vrf_list.html

@@ -5,16 +5,10 @@
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_vrf %}
-		<a href="{% url 'ipam:vrf_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a VRF
-		</a>
-		<a href="{% url 'ipam:vrf_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import VRFs
-		</a>
-	{% 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 %}
 </div>
 <h1>{% block title %}VRFs{% endblock %}</h1>
 <div class="row">

+ 2 - 4
netbox/templates/secrets/secret_list.html

@@ -1,13 +1,11 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.secrets.add_secret %}
-        <a href="{% url 'secrets:secret_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import secrets
-        </a>
+        {% import_button 'secrets:secret_import' %}
     {% endif %}
 </div>
 <h1>{% block title %}Secrets{% endblock %}</h1>

+ 5 - 9
netbox/templates/secrets/secretrole_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
-    {% if perms.dcim.add_devicerole %}
-        <a href="{% url 'secrets:secretrole_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a secret role
-        </a>
-        <a href="{% url 'secrets:secretrole_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import secret roles
-        </a>
+    {% if perms.secrets.add_secretrole %}
+        {% add_button 'secrets:secretrole_add' %}
+        {% import_button 'secrets:secretrole_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Secret Roles{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/tenancy/tenant_list.html

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.tenancy.add_tenant %}
-		<a href="{% url 'tenancy:tenant_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a tenant
-		</a>
-        <a href="{% url 'tenancy:tenant_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import tenants
-        </a>
+        {% add_button 'tenancy:tenant_add' %}
+        {% import_button 'tenancy:tenant_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='tenants' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Tenants{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/tenancy/tenantgroup_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.tenancy.add_tenantgroup %}
-        <a href="{% url 'tenancy:tenantgroup_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a tenant group
-        </a>
-        <a href="{% url 'tenancy:tenantgroup_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import tenant groups
-        </a>
+        {% add_button 'tenancy:tenantgroup_add' %}
+        {% import_button 'tenancy:tenantgroup_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Tenant Groups{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/virtualization/cluster_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.virtualization.add_cluster %}
-		<a href="{% url 'virtualization:cluster_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a cluster
-		</a>
-        <a href="{% url 'virtualization:cluster_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import clusters
-        </a>
+        {% add_button 'virtualization:cluster_add' %}
+        {% import_button 'virtualization:cluster_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='clusters' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Clusters{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/virtualization/clustergroup_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.virtualization.add_clustergroup %}
-        <a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a cluster group
-        </a>
-        <a href="{% url 'virtualization:clustergroup_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import cluster groups
-        </a>
+        {% add_button 'virtualization:clustergroup_add' %}
+        {% import_button 'virtualization:clustergroup_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Cluster Groups{% endblock %}</h1>
 <div class="row">

+ 4 - 8
netbox/templates/virtualization/clustertype_list.html

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.virtualization.add_clustertype %}
-        <a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-primary">
-            <span class="fa fa-plus" aria-hidden="true"></span>
-            Add a cluster type
-        </a>
-        <a href="{% url 'virtualization:clustertype_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import cluster types
-        </a>
+        {% add_button 'virtualization:clustertype_add' %}
+        {% import_button 'virtualization:clustertype_import' %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Cluster Types{% endblock %}</h1>
 <div class="row">

+ 4 - 9
netbox/templates/virtualization/virtualmachine_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.virtualization.add_virtualmachine %}
-		<a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a virtual machine
-		</a>
-        <a href="{% url 'virtualization:virtualmachine_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import virtual machines
-        </a>
+        {% 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 %}
 </div>
 <h1>{% block title %}Virtual Machines{% endblock %}</h1>
 <div class="row">

+ 2 - 2
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'

+ 12 - 4
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
@@ -18,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']
 
@@ -27,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):
@@ -41,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']
@@ -53,9 +60,10 @@ 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,
-        ])
+            self.comments,
+        )

+ 3 - 7
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:

+ 3 - 0
netbox/utilities/templates/buttons/add.html

@@ -0,0 +1,3 @@
+<a href="{% url add_url %}" class="btn btn-primary">
+    <span class="fa fa-plus" aria-hidden="true"></span> Add
+</a>

+ 19 - 0
netbox/utilities/templates/buttons/export.html

@@ -0,0 +1,19 @@
+{% if export_templates %}
+    <div class="btn-group">
+        <button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            <span class="fa fa-upload" aria-hidden="true"></span>
+            Export <span class="caret"></span>
+        </button>
+        <ul class="dropdown-menu">
+            <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export">CSV (default)</a></li>
+            <li class="divider"></li>
+            {% for et in export_templates %}
+                <li><a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export={{ et.name }}"{% if et.description %} title="{{ et.description }}"{% endif %}>{{ et.name }}</a></li>
+            {% endfor %}
+        </ul>
+    </div>
+{% else %}
+    <a href="?{% if url_params %}{{ url_params.urlencode }}&{% endif %}export" class="btn btn-success">
+        <span class="fa fa-upload" aria-hidden="true"></span> Export
+    </a>
+{% endif %}

+ 3 - 0
netbox/utilities/templates/buttons/import.html

@@ -0,0 +1,3 @@
+<a href="{% url import_url %}" class="btn btn-info">
+    <span class="fa fa-download" aria-hidden="true"></span> Import
+</a>

+ 26 - 0
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,
+    }

+ 34 - 1
netbox/utilities/utils.py

@@ -1,7 +1,10 @@
 from __future__ import unicode_literals
 
+import datetime
 import six
 
+from django.http import HttpResponse
+
 
 def csv_format(data):
     """
@@ -15,12 +18,16 @@ def csv_format(data):
             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:
+        if ',' in value or '\n' in value:
             csv.append('"{}"'.format(value))
         else:
             csv.append('{}'.format(value))
@@ -28,6 +35,32 @@ def csv_format(data):
     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):
     """
     Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.

+ 11 - 20
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
-from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template.exceptions 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.utils import queryset_to_csv
 from utilities.forms import BootstrapMixin, CSVDataField
 from .constants import M2M_FIELD_TYPES
 from .error_handlers import handle_protectederror
@@ -80,7 +80,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
@@ -93,27 +93,18 @@ 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:
-                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)
@@ -135,10 +126,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())
 

+ 4 - 4
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):

+ 23 - 9
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 DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
 
@@ -31,6 +30,8 @@ class ClusterType(models.Model):
         unique=True
     )
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
         ordering = ['name']
 
@@ -40,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
@@ -58,6 +65,8 @@ class ClusterGroup(models.Model):
         unique=True
     )
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
         ordering = ['name']
 
@@ -67,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
@@ -109,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']
@@ -135,13 +148,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,
-        ])
+        )
 
 
 #
@@ -230,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:
@@ -243,9 +256,10 @@ 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.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,
@@ -253,7 +267,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
             self.memory,
             self.disk,
             self.comments,
-        ])
+        )
 
     def get_status_class(self):
         return VM_STATUS_CLASSES[self.status]

+ 3 - 2
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

+ 2 - 8
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
@@ -162,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'