Explorar o código

Merge branch 'develop' into develop-2.3

Jeremy Stretch %!s(int64=8) %!d(string=hai) anos
pai
achega
73c64272d8
Modificáronse 62 ficheiros con 571 adicións e 515 borrados
  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
 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).
 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
 ## 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.
 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,
 * 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
 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.
 * 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
 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
 * 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
 work is required to resolve them. It may take some time for someone to address
@@ -52,15 +51,15 @@ your issue.
 
 
 ## Feature Requests
 ## 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
 * Due to an excessive backlog of feature requests, we are not currently
 accepting any proposals which substantially extend NetBox's functionality
 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
 * 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
 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
 ## Submitting Pull Requests
 
 
@@ -109,3 +108,10 @@ these checks):
     * All tests pass when run with `./manage.py test`
     * All tests pass when run with `./manage.py test`
     * PEP 8 compliance is enforced, with the exception that lines may be
     * PEP 8 compliance is enforced, with the exception that lines may be
       greater than 80 characters in length
       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](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/).
 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
 ### Build Status
 
 
@@ -27,7 +33,9 @@ NetBox is built against both Python 2.7 and 3.5.  Python 3.5 is recommended.
 
 
 # Installation
 # 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
 ## Alternative Installations
 
 

+ 2 - 2
netbox/circuits/forms.py

@@ -44,7 +44,7 @@ class ProviderCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments']
+        fields = Provider.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Provider name',
             'name': 'Provider name',
             'asn': '32-bit autonomous system number',
             'asn': '32-bit autonomous system number',
@@ -90,7 +90,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ['name', 'slug']
+        fields = CircuitType.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of circuit type',
             '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 extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 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)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     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:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -42,13 +41,16 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
         return reverse('circuits:provider', args=[self.slug])
         return reverse('circuits:provider', args=[self.slug])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.slug,
             self.slug,
             self.asn,
             self.asn,
             self.account,
             self.account,
             self.portal_url,
             self.portal_url,
-        ])
+            self.noc_contact,
+            self.admin_contact,
+            self.comments,
+        )
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
@@ -60,6 +62,8 @@ class CircuitType(models.Model):
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
 
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -69,6 +73,12 @@ class CircuitType(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
 
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class Circuit(CreatedUpdatedModel, CustomFieldModel):
 class Circuit(CreatedUpdatedModel, CustomFieldModel):
@@ -88,7 +98,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     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:
     class Meta:
         ordering = ['provider', 'cid']
         ordering = ['provider', 'cid']
@@ -101,16 +113,17 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
         return reverse('circuits:circuit', args=[self.pk])
         return reverse('circuits:circuit', args=[self.pk])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.cid,
             self.cid,
             self.provider.name,
             self.provider.name,
             self.type.name,
             self.type.name,
             self.get_status_display(),
             self.get_status_display(),
             self.tenant.name if self.tenant else None,
             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.commit_rate,
             self.description,
             self.description,
-        ])
+            self.comments,
+        )
 
 
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CLASSES[self.status]
         return STATUS_CLASSES[self.status]

+ 13 - 26
netbox/dcim/forms.py

@@ -83,9 +83,7 @@ class RegionCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
-        fields = [
-            'name', 'slug', 'parent',
-        ]
+        fields = Region.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Region name',
             'name': 'Region name',
             'slug': 'URL-friendly slug',
             'slug': 'URL-friendly slug',
@@ -153,10 +151,7 @@ class SiteCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Site
         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 = {
         help_texts = {
             'name': 'Site name',
             'name': 'Site name',
             'slug': 'URL-friendly slug',
             'slug': 'URL-friendly slug',
@@ -224,9 +219,7 @@ class RackGroupCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
-        fields = [
-            'site', 'name', 'slug',
-        ]
+        fields = RackGroup.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of rack group',
             'name': 'Name of rack group',
             'slug': 'URL-friendly slug',
             'slug': 'URL-friendly slug',
@@ -254,7 +247,7 @@ class RackRoleCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
-        fields = ['name', 'slug', 'color']
+        fields = RackRole.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of rack role',
             'name': 'Name of rack role',
             'color': 'RGB color in hexadecimal (e.g. 00ff00)'
             'color': 'RGB color in hexadecimal (e.g. 00ff00)'
@@ -341,10 +334,7 @@ class RackCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = [
-            'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
-            'desc_units',
-        ]
+        fields = Rack.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Rack name',
             'name': 'Rack name',
             'u_height': 'Height in rack units',
             'u_height': 'Height in rack units',
@@ -478,9 +468,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm):
 class ManufacturerCSVForm(forms.ModelForm):
 class ManufacturerCSVForm(forms.ModelForm):
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
-        fields = [
-            'name', 'slug'
-        ]
+        fields = Manufacturer.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Manufacturer name',
             'name': 'Manufacturer name',
             'slug': 'URL-friendly slug',
             'slug': 'URL-friendly slug',
@@ -526,8 +514,7 @@ class DeviceTypeCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = DeviceType
         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 = {
         help_texts = {
             'model': 'Model name',
             'model': 'Model name',
             'slug': 'URL-friendly slug',
             'slug': 'URL-friendly slug',
@@ -692,7 +679,7 @@ class DeviceRoleCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ['name', 'slug', 'color', 'vm_role']
+        fields = DeviceRole.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of device role',
             'name': 'Name of device role',
             'color': 'RGB color in hexadecimal (e.g. 00ff00)'
             'color': 'RGB color in hexadecimal (e.g. 00ff00)'
@@ -716,7 +703,7 @@ class PlatformCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['name', 'slug', 'manufacturer', 'napalm_driver']
+        fields = Platform.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Platform name',
             'name': 'Platform name',
             'manufacturer': 'Manufacturer name',
             'manufacturer': 'Manufacturer name',
@@ -970,7 +957,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             '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):
     def clean(self):
@@ -1019,7 +1006,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
             '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):
     def clean(self):
@@ -2096,7 +2083,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = InterfaceConnection
         model = InterfaceConnection
-        fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
+        fields = InterfaceConnection.csv_headers
 
 
     def clean_interface_a(self):
     def clean_interface_a(self):
 
 
@@ -2238,7 +2225,7 @@ class InventoryItemCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']
+        fields = InventoryItem.csv_headers
 
 
 
 
 class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
 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.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import *
 from .constants import *
 from .fields import ASNField, MACAddressField
 from .fields import ASNField, MACAddressField
 from .querysets import InterfaceQuerySet
 from .querysets import InterfaceQuerySet
@@ -44,9 +43,7 @@ class Region(MPTTModel):
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
 
 
-    csv_headers = [
-        'name', 'slug', 'parent',
-    ]
+    csv_headers = ['name', 'slug', 'parent']
 
 
     class MPTTMeta:
     class MPTTMeta:
         order_insertion_by = ['name']
         order_insertion_by = ['name']
@@ -58,11 +55,11 @@ class Region(MPTTModel):
         return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
         return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.slug,
             self.slug,
             self.parent.name if self.parent else None,
             self.parent.name if self.parent else None,
-        ])
+        )
 
 
 
 
 #
 #
@@ -102,8 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
     objects = SiteManager()
 
 
     csv_headers = [
     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:
     class Meta:
@@ -116,7 +113,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
         return reverse('dcim:site', args=[self.slug])
         return reverse('dcim:site', args=[self.slug])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.slug,
             self.slug,
             self.get_status_display(),
             self.get_status_display(),
@@ -126,10 +123,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
             self.asn,
             self.asn,
             self.time_zone,
             self.time_zone,
             self.description,
             self.description,
+            self.physical_address,
+            self.shipping_address,
             self.contact_name,
             self.contact_name,
             self.contact_phone,
             self.contact_phone,
             self.contact_email,
             self.contact_email,
-        ])
+            self.comments,
+        )
 
 
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CLASSES[self.status]
         return STATUS_CLASSES[self.status]
@@ -175,9 +175,7 @@ class RackGroup(models.Model):
     slug = models.SlugField()
     slug = models.SlugField()
     site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
     site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
 
 
-    csv_headers = [
-        'site', 'name', 'slug',
-    ]
+    csv_headers = ['site', 'name', 'slug']
 
 
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         ordering = ['site', 'name']
@@ -193,11 +191,11 @@ class RackGroup(models.Model):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.site,
             self.site,
             self.name,
             self.name,
             self.slug,
             self.slug,
-        ])
+        )
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
@@ -209,6 +207,8 @@ class RackRole(models.Model):
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
     color = ColorField()
     color = ColorField()
 
 
+    csv_headers = ['name', 'slug', 'color']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -218,6 +218,13 @@ class RackRole(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
         return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
 
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+        )
+
 
 
 class RackManager(NaturalOrderByManager):
 class RackManager(NaturalOrderByManager):
 
 
@@ -253,7 +260,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 
 
     csv_headers = [
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
-        'desc_units',
+        'desc_units', 'comments',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -303,7 +310,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             Device.objects.filter(rack=self).update(site_id=self.site.pk)
             Device.objects.filter(rack=self).update(site_id=self.site.pk)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.site.name,
             self.site.name,
             self.group.name if self.group else None,
             self.group.name if self.group else None,
             self.name,
             self.name,
@@ -315,7 +322,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             self.width,
             self.width,
             self.u_height,
             self.u_height,
             self.desc_units,
             self.desc_units,
-        ])
+            self.comments,
+        )
 
 
     @property
     @property
     def units(self):
     def units(self):
@@ -491,9 +499,7 @@ class Manufacturer(models.Model):
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
 
 
-    csv_headers = [
-        'name', 'slug',
-    ]
+    csv_headers = ['name', 'slug']
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -505,10 +511,10 @@ class Manufacturer(models.Model):
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.slug,
             self.slug,
-        ])
+        )
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
@@ -551,7 +557,7 @@ class DeviceType(models.Model, CustomFieldModel):
 
 
     csv_headers = [
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
         '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:
     class Meta:
@@ -574,7 +580,7 @@ class DeviceType(models.Model, CustomFieldModel):
         return reverse('dcim:devicetype', args=[self.pk])
         return reverse('dcim:devicetype', args=[self.pk])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.manufacturer.name,
             self.manufacturer.name,
             self.model,
             self.model,
             self.slug,
             self.slug,
@@ -586,7 +592,8 @@ class DeviceType(models.Model, CustomFieldModel):
             self.is_network_device,
             self.is_network_device,
             self.get_subdevice_role_display() if self.subdevice_role else None,
             self.get_subdevice_role_display() if self.subdevice_role else None,
             self.get_interface_ordering_display(),
             self.get_interface_ordering_display(),
-        ])
+            self.comments,
+        )
 
 
     def clean(self):
     def clean(self):
 
 
@@ -766,6 +773,8 @@ class DeviceRole(models.Model):
         help_text="Virtual machines may be assigned to this role"
         help_text="Virtual machines may be assigned to this role"
     )
     )
 
 
+    csv_headers = ['name', 'slug', 'color', 'vm_role']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -775,6 +784,14 @@ class DeviceRole(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
         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
 @python_2_unicode_compatible
 class Platform(models.Model):
 class Platform(models.Model):
@@ -805,6 +822,8 @@ class Platform(models.Model):
         verbose_name="Legacy RPC client"
         verbose_name="Legacy RPC client"
     )
     )
 
 
+    csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -814,6 +833,14 @@ class Platform(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
         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):
 class DeviceManager(NaturalOrderByManager):
 
 
@@ -892,7 +919,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
 
     csv_headers = [
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
         '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:
     class Meta:
@@ -1049,7 +1076,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
         Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name or '',
             self.name or '',
             self.device_role.name,
             self.device_role.name,
             self.tenant.name if self.tenant else None,
             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.rack.name if self.rack else None,
             self.position,
             self.position,
             self.get_face_display(),
             self.get_face_display(),
-        ])
+            self.comments,
+        )
 
 
     @property
     @property
     def display_name(self):
     def display_name(self):
@@ -1158,15 +1186,14 @@ class ConsolePort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
-    # Used for connections export
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.cs_port.device.identifier if self.cs_port else None,
             self.cs_port.device.identifier if self.cs_port else None,
             self.cs_port.name if self.cs_port else None,
             self.cs_port.name if self.cs_port else None,
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
             self.get_connection_status_display(),
             self.get_connection_status_display(),
-        ])
+        )
 
 
 
 
 #
 #
@@ -1241,15 +1268,14 @@ class PowerPort(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
-    # Used for connections export
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.power_outlet.device.identifier if self.power_outlet else None,
             self.power_outlet.device.identifier if self.power_outlet else None,
             self.power_outlet.name if self.power_outlet else None,
             self.power_outlet.name if self.power_outlet else None,
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
             self.get_connection_status_display(),
             self.get_connection_status_display(),
-        ])
+        )
 
 
 
 
 #
 #
@@ -1501,15 +1527,14 @@ class InterfaceConnection(models.Model):
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             pass
             pass
 
 
-    # Used for connections export
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.interface_a.device.identifier,
             self.interface_a.device.identifier,
             self.interface_a.name,
             self.interface_a.name,
             self.interface_b.device.identifier,
             self.interface_b.device.identifier,
             self.interface_b.name,
             self.interface_b.name,
             self.get_connection_status_display(),
             self.get_connection_status_display(),
-        ])
+        )
 
 
 
 
 #
 #
@@ -1575,7 +1600,7 @@ class InventoryItem(models.Model):
     description = models.CharField(max_length=100, blank=True)
     description = models.CharField(max_length=100, blank=True)
 
 
     csv_headers = [
     csv_headers = [
-        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -1589,15 +1614,16 @@ class InventoryItem(models.Model):
         return self.device.get_absolute_url()
         return self.device.get_absolute_url()
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.device.name or '{' + self.device.pk + '}',
             self.device.name or '{' + self.device.pk + '}',
             self.name,
             self.name,
             self.manufacturer.name if self.manufacturer else None,
             self.manufacturer.name if self.manufacturer else None,
             self.part_id,
             self.part_id,
             self.serial,
             self.serial,
             self.asset_tag,
             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():
         if self.pk and self.master not in self.members.all():
             raise ValidationError({
             raise ValidationError({
                 'master': "The selected master is not assigned to this virtual chassis."
                 'master': "The selected master is not assigned to this virtual chassis."
-            })
+            })

+ 55 - 10
netbox/dcim/tables.py

@@ -66,6 +66,10 @@ RACK_ROLE = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+RACK_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
+"""
+
 RACKRESERVATION_ACTIONS = """
 RACKRESERVATION_ACTIONS = """
 {% if perms.dcim.change_rackreservation %}
 {% 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>
     <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 %}
 {% 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 = """
 PLATFORM_ACTIONS = """
 {% if perms.dcim.change_platform %}
 {% 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>
     <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):
 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')
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
 
     class Meta(RackTable.Meta):
     class Meta(RackTable.Meta):
         fields = (
         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):
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     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')
     color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
     slug = tables.Column(verbose_name='Slug')
     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):
     class Meta(BaseTable.Meta):
         model = DeviceRole
         model = DeviceRole
@@ -375,10 +412,18 @@ class DeviceRoleTable(BaseTable):
 
 
 class PlatformTable(BaseTable):
 class PlatformTable(BaseTable):
     pk = ToggleColumn()
     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(
     actions = tables.TemplateColumn(
         template_code=PLATFORM_ACTIONS,
         template_code=PLATFORM_ACTIONS,
         attrs={'td': {'class': 'text-right'}},
         attrs={'td': {'class': 'text-right'}},

+ 3 - 9
netbox/dcim/views.py

@@ -321,7 +321,7 @@ class RackListView(ObjectListView):
     ).prefetch_related(
     ).prefetch_related(
         'devices__device_type'
         'devices__device_type'
     ).annotate(
     ).annotate(
-        device_count=Count('devices', distinct=True)
+        device_count=Count('devices')
     )
     )
     filter = filters.RackFilter
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
     filter_form = forms.RackFilterForm
@@ -763,10 +763,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class DeviceRoleListView(ObjectListView):
 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
     table = tables.DeviceRoleTable
     template_name = 'dcim/devicerole_list.html'
     template_name = 'dcim/devicerole_list.html'
 
 
@@ -804,10 +801,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class PlatformListView(ObjectListView):
 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
     table = tables.PlatformTable
     template_name = 'dcim/platform_list.html'
     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:
     for cf in custom_fields:
         field_name = 'cf_{}'.format(str(cf.name))
         field_name = 'cf_{}'.format(str(cf.name))
+        initial = cf.default if not bulk_edit else None
 
 
         # Integer
         # Integer
         if cf.type == CF_TYPE_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
         # Boolean
         elif cf.type == CF_TYPE_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'),
                 (1, 'True'),
                 (0, 'False'),
                 (0, 'False'),
             )
             )
-            if cf.default.lower() in ['true', 'yes', '1']:
+            if initial.lower() in ['true', 'yes', '1']:
                 initial = 1
                 initial = 1
-            elif cf.default.lower() in ['false', 'no', '0']:
+            elif initial.lower() in ['false', 'no', '0']:
                 initial = 0
                 initial = 0
             else:
             else:
                 initial = None
                 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
         # Date
         elif cf.type == CF_TYPE_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
         # Select
         elif cf.type == CF_TYPE_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
         # URL
         elif cf.type == CF_TYPE_URL:
         elif cf.type == CF_TYPE_URL:
-            field = LaxURLField(required=cf.required, initial=cf.default)
+            field = LaxURLField(required=cf.required, initial=initial)
 
 
         # Text
         # Text
         else:
         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.model = cf
         field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
         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):
     def __str__(self):
         return '{}: {}'.format(self.content_type, self.name)
         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
         Render the template to an HTTP response, delivered as a named file attachment
         """
         """
         template = Template(self.template_code)
         template = Template(self.template_code)
         mime_type = 'text/plain' if not self.mime_type else self.mime_type
         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
         # Replace CRLF-style line terminators
         output = output.replace('\r\n', '\n')
         output = output.replace('\r\n', '\n')
+
+        # Build the response
         response = HttpResponse(output, content_type=mime_type)
         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)
         response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+
         return response
         return response
 
 
 
 

+ 8 - 13
netbox/ipam/forms.py

@@ -57,7 +57,7 @@ class VRFCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
+        fields = VRF.csv_headers
         help_texts = {
         help_texts = {
             'name': 'VRF name',
             'name': 'VRF name',
         }
         }
@@ -102,7 +102,7 @@ class RIRCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
-        fields = ['name', 'slug', 'is_private']
+        fields = RIR.csv_headers
         help_texts = {
         help_texts = {
             'name': 'RIR name',
             'name': 'RIR name',
         }
         }
@@ -144,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Aggregate
         model = Aggregate
-        fields = ['prefix', 'rir', 'date_added', 'description']
+        fields = Aggregate.csv_headers
 
 
 
 
 class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -185,7 +185,7 @@ class RoleCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
-        fields = ['name', 'slug']
+        fields = Role.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Role name',
             'name': 'Role name',
         }
         }
@@ -299,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = [
-            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
-        ]
+        fields = Prefix.csv_headers
 
 
     def clean(self):
     def clean(self):
 
 
@@ -609,10 +607,7 @@ class IPAddressCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = [
-            'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
-            'description',
-        ]
+        fields = IPAddress.csv_headers
 
 
     def clean(self):
     def clean(self):
 
 
@@ -759,7 +754,7 @@ class VLANGroupCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['site', 'name', 'slug']
+        fields = VLANGroup.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of VLAN group',
             'name': 'Name of VLAN group',
         }
         }
@@ -849,7 +844,7 @@ class VLANCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+        fields = VLAN.csv_headers
         help_texts = {
         help_texts = {
             'vid': 'Numeric VLAN ID (1-4095)',
             'vid': 'Numeric VLAN ID (1-4095)',
             'name': 'VLAN name',
             '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 extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import *
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
 from .querysets import PrefixQuerySet
 from .querysets import PrefixQuerySet
@@ -49,13 +48,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         return reverse('ipam:vrf', args=[self.pk])
         return reverse('ipam:vrf', args=[self.pk])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.rd,
             self.rd,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.enforce_unique,
             self.enforce_unique,
             self.description,
             self.description,
-        ])
+        )
 
 
     @property
     @property
     def display_name(self):
     def display_name(self):
@@ -75,6 +74,8 @@ class RIR(models.Model):
     is_private = models.BooleanField(default=False, verbose_name='Private',
     is_private = models.BooleanField(default=False, verbose_name='Private',
                                      help_text='IP space managed by this RIR is considered private')
                                      help_text='IP space managed by this RIR is considered private')
 
 
+    csv_headers = ['name', 'slug', 'is_private']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
         verbose_name = 'RIR'
         verbose_name = 'RIR'
@@ -86,6 +87,13 @@ class RIR(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
         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
 @python_2_unicode_compatible
 class Aggregate(CreatedUpdatedModel, CustomFieldModel):
 class Aggregate(CreatedUpdatedModel, CustomFieldModel):
@@ -147,12 +155,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
         super(Aggregate, self).save(*args, **kwargs)
         super(Aggregate, self).save(*args, **kwargs)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.prefix,
             self.prefix,
             self.rir.name,
             self.rir.name,
-            self.date_added.isoformat() if self.date_added else None,
+            self.date_added,
             self.description,
             self.description,
-        ])
+        )
 
 
     def get_utilization(self):
     def get_utilization(self):
         """
         """
@@ -173,19 +181,20 @@ class Role(models.Model):
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
     weight = models.PositiveSmallIntegerField(default=1000)
     weight = models.PositiveSmallIntegerField(default=1000)
 
 
+    csv_headers = ['name', 'slug', 'weight']
+
     class Meta:
     class Meta:
         ordering = ['weight', 'name']
         ordering = ['weight', 'name']
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         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
 @python_2_unicode_compatible
@@ -262,7 +271,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
         super(Prefix, self).save(*args, **kwargs)
         super(Prefix, self).save(*args, **kwargs)
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.prefix,
             self.prefix,
             self.vrf.rd if self.vrf else None,
             self.vrf.rd if self.vrf else None,
             self.tenant.name if self.tenant 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.role.name if self.role else None,
             self.is_pool,
             self.is_pool,
             self.description,
             self.description,
-        ])
+        )
 
 
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
         return STATUS_CHOICE_CLASSES[self.status]
@@ -461,7 +470,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
         else:
         else:
             is_primary = False
             is_primary = False
 
 
-        return csv_format([
+        return (
             self.address,
             self.address,
             self.vrf.rd if self.vrf else None,
             self.vrf.rd if self.vrf else None,
             self.tenant.name if self.tenant 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,
             self.interface.name if self.interface else None,
             is_primary,
             is_primary,
             self.description,
             self.description,
-        ])
+        )
 
 
     @property
     @property
     def device(self):
     def device(self):
@@ -502,6 +511,8 @@ class VLANGroup(models.Model):
     slug = models.SlugField()
     slug = models.SlugField()
     site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
     site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
 
 
+    csv_headers = ['name', 'slug', 'site']
+
     class Meta:
     class Meta:
         ordering = ['site', 'name']
         ordering = ['site', 'name']
         unique_together = [
         unique_together = [
@@ -517,6 +528,13 @@ class VLANGroup(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
         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):
     def get_next_available_vid(self):
         """
         """
         Return the first available VLAN ID (1-4094) in the group.
         Return the first available VLAN ID (1-4094) in the group.
@@ -577,7 +595,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
             })
             })
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.site.name if self.site else None,
             self.site.name if self.site else None,
             self.group.name if self.group else None,
             self.group.name if self.group else None,
             self.vid,
             self.vid,
@@ -586,7 +604,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
             self.get_status_display(),
             self.get_status_display(),
             self.role.name if self.role else None,
             self.role.name if self.role else None,
             self.description,
             self.description,
-        ])
+        )
 
 
     @property
     @property
     def display_name(self):
     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 %}
 {% 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 = """
 ROLE_ACTIONS = """
 {% if perms.ipam.change_role %}
 {% 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>
     <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):
 class RoleTable(BaseTable):
     pk = ToggleColumn()
     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='')
     actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):

+ 1 - 1
netbox/netbox/views.py

@@ -119,7 +119,7 @@ SEARCH_TYPES = OrderedDict((
     }),
     }),
     # Virtualization
     # Virtualization
     ('cluster', {
     ('cluster', {
-        'queryset': Cluster.objects.all(),
+        'queryset': Cluster.objects.select_related('type', 'group'),
         'filter': ClusterFilter,
         'filter': ClusterFilter,
         'table': ClusterTable,
         'table': ClusterTable,
         'url': 'virtualization:cluster_list',
         'url': 'virtualization:cluster_list',

+ 2 - 2
netbox/secrets/forms.py

@@ -47,7 +47,7 @@ class SecretRoleCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
-        fields = ['name', 'slug']
+        fields = SecretRole.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of secret role',
             'name': 'Name of secret role',
         }
         }
@@ -98,7 +98,7 @@ class SecretCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Secret
         model = Secret
-        fields = ['device', 'role', 'name', 'plaintext']
+        fields = Secret.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name or username',
             '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)
     users = models.ManyToManyField(User, related_name='secretroles', blank=True)
     groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
     groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
 
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -248,6 +250,12 @@ class SecretRole(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
         return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
 
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
     def has_member(self, user):
     def has_member(self, user):
         """
         """
         Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles.
         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' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.circuits.add_circuit %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='circuits' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Circuits{% endblock %}</h1>
 <h1>{% block title %}Circuits{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.circuits.add_circuittype %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Circuit Types{% endblock %}</h1>
 <h1>{% block title %}Circuit Types{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.circuits.add_provider %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='providers' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Providers{% endblock %}</h1>
 <h1>{% block title %}Providers{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,14 +1,12 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.change_consoleport %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Console Connections{% endblock %}</h1>
 <h1>{% block title %}Console Connections{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_device %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='devices' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Devices{% endblock %}</h1>
 <h1>{% block title %}Devices{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_devicerole %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Device Roles{% endblock %}</h1>
 <h1>{% block title %}Device Roles{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_devicetype %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='device types' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Device Types{% endblock %}</h1>
 <h1>{% block title %}Device Types{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,14 +1,12 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_interfaceconnection %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Interface Connections{% endblock %}</h1>
 <h1>{% block title %}Interface Connections{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,15 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_devicetype %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='inventory items' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Inventory Items{% endblock %}</h1>
 <h1>{% block title %}Inventory Items{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_manufacturer %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='manufacturers' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Manufacturers{% endblock %}</h1>
 <h1>{% block title %}Manufacturers{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_platform %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Platforms{% endblock %}</h1>
 <h1>{% block title %}Platforms{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,14 +1,12 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.change_powerport %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Power Connections{% endblock %}</h1>
 <h1>{% block title %}Power Connections{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_rack %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='racks' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Racks{% endblock %}</h1>
 <h1>{% block title %}Racks{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_rackgroup %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='rack groups' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Rack Groups{% endblock %}</h1>
 <h1>{% block title %}Rack Groups{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_region %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='regions' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Regions{% endblock %}</h1>
 <h1>{% block title %}Regions{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.dcim.add_site %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='sites' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Sites{% endblock %}</h1>
 <h1>{% block title %}Sites{% endblock %}</h1>
 <div class="row">
 <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' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load humanize %}
 {% load humanize %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.ipam.add_aggregate %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='aggregates' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Aggregates{% endblock %}</h1>
 <h1>{% block title %}Aggregates{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.ipam.add_ipaddress %}
     {% 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>
 </div>
 <h1>{% block title %}IP Addresses{% endblock %}</h1>
 <h1>{% block title %}IP Addresses{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 {% load form_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>
         <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>
     </div>
     {% if perms.ipam.add_prefix %}
     {% 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>
 </div>
 <h1>{% block title %}Prefixes{% endblock %}</h1>
 <h1>{% block title %}Prefixes{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load humanize %}
 {% load humanize %}
 {% load helpers %}
 {% load helpers %}
 
 
@@ -16,15 +17,10 @@
         </a>
         </a>
     {% endif %}
     {% endif %}
     {% if perms.ipam.add_rir %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}RIRs{% endblock %}</h1>
 <h1>{% block title %}RIRs{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.ipam.add_role %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1>
 <h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,20 +1,15 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.ipam.add_vlan %}
     {% 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>
 </div>
 <h1>{% block title %}VLANs{% endblock %}</h1>
 <h1>{% block title %}VLANs{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.ipam.add_vlangroup %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}VLAN Groups{% endblock %}</h1>
 <h1>{% block title %}VLAN Groups{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -5,16 +5,10 @@
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.ipam.add_vrf %}
     {% 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>
 </div>
 <h1>{% block title %}VRFs{% endblock %}</h1>
 <h1>{% block title %}VRFs{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,13 +1,11 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.secrets.add_secret %}
     {% 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 %}
     {% endif %}
 </div>
 </div>
 <h1>{% block title %}Secrets{% endblock %}</h1>
 <h1>{% block title %}Secrets{% endblock %}</h1>

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Secret Roles{% endblock %}</h1>
 <h1>{% block title %}Secret Roles{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,19 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.tenancy.add_tenant %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='tenants' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Tenants{% endblock %}</h1>
 <h1>{% block title %}Tenants{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.tenancy.add_tenantgroup %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Tenant Groups{% endblock %}</h1>
 <h1>{% block title %}Tenant Groups{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.virtualization.add_cluster %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='clusters' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Clusters{% endblock %}</h1>
 <h1>{% block title %}Clusters{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.virtualization.add_clustergroup %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Cluster Groups{% endblock %}</h1>
 <h1>{% block title %}Cluster Groups{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.virtualization.add_clustertype %}
     {% 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 %}
     {% endif %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Cluster Types{% endblock %}</h1>
 <h1>{% block title %}Cluster Types{% endblock %}</h1>
 <div class="row">
 <div class="row">

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

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
     {% if perms.virtualization.add_virtualmachine %}
     {% 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 %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='virtual machines' %}
+    {% export_button content_type %}
 </div>
 </div>
 <h1>{% block title %}Virtual Machines{% endblock %}</h1>
 <h1>{% block title %}Virtual Machines{% endblock %}</h1>
 <div class="row">
 <div class="row">

+ 2 - 2
netbox/tenancy/forms.py

@@ -27,7 +27,7 @@ class TenantGroupCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = TenantGroup
         model = TenantGroup
-        fields = ['name', 'slug']
+        fields = TenantGroup.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Group name',
             'name': 'Group name',
         }
         }
@@ -60,7 +60,7 @@ class TenantCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = ['name', 'slug', 'group', 'description', 'comments']
+        fields = Tenant.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Tenant name',
             'name': 'Tenant name',
             'comments': 'Free-form comments'
             '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 extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
@@ -18,6 +17,8 @@ class TenantGroup(models.Model):
     name = models.CharField(max_length=50, unique=True)
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
     slug = models.SlugField(unique=True)
 
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -27,6 +28,12 @@ class TenantGroup(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
         return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
 
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class Tenant(CreatedUpdatedModel, CustomFieldModel):
 class Tenant(CreatedUpdatedModel, CustomFieldModel):
@@ -41,7 +48,7 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     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:
     class Meta:
         ordering = ['group', 'name']
         ordering = ['group', 'name']
@@ -53,9 +60,10 @@ class Tenant(CreatedUpdatedModel, CustomFieldModel):
         return reverse('tenancy:tenant', args=[self.slug])
         return reverse('tenancy:tenant', args=[self.slug])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.slug,
             self.slug,
             self.group.name if self.group else None,
             self.group.name if self.group else None,
             self.description,
             self.description,
-        ])
+            self.comments,
+        )

+ 3 - 7
netbox/utilities/forms.py

@@ -1,7 +1,7 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import csv
 import csv
-import itertools
+from io import StringIO
 import re
 import re
 
 
 from django import forms
 from django import forms
@@ -245,14 +245,10 @@ class CSVDataField(forms.CharField):
 
 
     def to_python(self, value):
     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 = []
         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)
         headers = next(reader)
         for f in self.required_fields:
         for f in self.required_fields:
             if f not in headers:
             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
 from __future__ import unicode_literals
 
 
+import datetime
 import six
 import six
 
 
+from django.http import HttpResponse
+
 
 
 def csv_format(data):
 def csv_format(data):
     """
     """
@@ -15,12 +18,16 @@ def csv_format(data):
             csv.append('')
             csv.append('')
             continue
             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
         # Force conversion to string first so we can check for any commas
         if not isinstance(value, six.string_types):
         if not isinstance(value, six.string_types):
             value = '{}'.format(value)
             value = '{}'.format(value)
 
 
         # Double-quote the value if it contains a comma
         # Double-quote the value if it contains a comma
-        if ',' in value:
+        if ',' in value or '\n' in value:
             csv.append('"{}"'.format(value))
             csv.append('"{}"'.format(value))
         else:
         else:
             csv.append('{}'.format(value))
             csv.append('{}'.format(value))
@@ -28,6 +35,32 @@ def csv_format(data):
     return ','.join(csv)
     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):
 def foreground_color(bg_color):
     """
     """
     Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.
     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 import transaction, IntegrityError
 from django.db.models import ProtectedError
 from django.db.models import ProtectedError
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 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.shortcuts import get_object_or_404, redirect, render
 from django.template.exceptions import TemplateSyntaxError
 from django.template.exceptions import TemplateSyntaxError
 from django.urls import reverse
 from django.urls import reverse
@@ -21,6 +20,7 @@ from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
 from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
 from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
+from utilities.utils import queryset_to_csv
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.forms import BootstrapMixin, CSVDataField
 from .constants import M2M_FIELD_TYPES
 from .constants import M2M_FIELD_TYPES
 from .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
@@ -80,7 +80,7 @@ class ObjectListView(View):
     def get(self, request):
     def get(self, request):
 
 
         model = self.queryset.model
         model = self.queryset.model
-        object_ct = ContentType.objects.get_for_model(model)
+        content_type = ContentType.objects.get_for_model(model)
 
 
         if self.filter:
         if self.filter:
             self.queryset = self.filter(request.GET, self.queryset).qs
             self.queryset = self.filter(request.GET, self.queryset).qs
@@ -93,27 +93,18 @@ class ObjectListView(View):
 
 
         # Check for export template rendering
         # Check for export template rendering
         if request.GET.get('export'):
         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
             queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
             try:
             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:
             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'):
         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
         # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
         self.queryset = self.alter_queryset(request)
         self.queryset = self.alter_queryset(request)
@@ -135,10 +126,10 @@ class ObjectListView(View):
         RequestConfig(request, paginate).configure(table)
         RequestConfig(request, paginate).configure(table)
 
 
         context = {
         context = {
+            'content_type': content_type,
             'table': table,
             'table': table,
             'permissions': permissions,
             'permissions': permissions,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
             '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())
         context.update(self.extra_context())
 
 

+ 4 - 4
netbox/virtualization/forms.py

@@ -41,7 +41,7 @@ class ClusterTypeCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ClusterType
         model = ClusterType
-        fields = ['name', 'slug']
+        fields = ClusterType.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of cluster type',
             'name': 'Name of cluster type',
         }
         }
@@ -64,7 +64,7 @@ class ClusterGroupCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ClusterGroup
         model = ClusterGroup
-        fields = ['name', 'slug']
+        fields = ClusterGroup.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Name of cluster group',
             'name': 'Name of cluster group',
         }
         }
@@ -112,7 +112,7 @@ class ClusterCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ['name', 'type', 'group', 'site', 'comments']
+        fields = Cluster.csv_headers
 
 
 
 
 class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -306,7 +306,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
-        fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
+        fields = VirtualMachine.csv_headers
 
 
 
 
 class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 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 dcim.models import Device
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
 
 
 
@@ -31,6 +30,8 @@ class ClusterType(models.Model):
         unique=True
         unique=True
     )
     )
 
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -40,6 +41,12 @@ class ClusterType(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
         return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
 
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 
 #
 #
 # Cluster groups
 # Cluster groups
@@ -58,6 +65,8 @@ class ClusterGroup(models.Model):
         unique=True
         unique=True
     )
     )
 
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -67,6 +76,12 @@ class ClusterGroup(models.Model):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
         return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
 
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 
 #
 #
 # Clusters
 # Clusters
@@ -109,9 +124,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    csv_headers = [
-        'name', 'type', 'group', 'site', 'comments',
-    ]
+    csv_headers = ['name', 'type', 'group', 'site', 'comments']
 
 
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
@@ -135,13 +148,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
                 })
                 })
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.type.name,
             self.type.name,
             self.group.name if self.group else None,
             self.group.name if self.group else None,
             self.site.name if self.site else None,
             self.site.name if self.site else None,
             self.comments,
             self.comments,
-        ])
+        )
 
 
 
 
 #
 #
@@ -230,7 +243,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
     )
     )
 
 
     csv_headers = [
     csv_headers = [
-        'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+        'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -243,9 +256,10 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
         return reverse('virtualization:virtualmachine', args=[self.pk])
         return reverse('virtualization:virtualmachine', args=[self.pk])
 
 
     def to_csv(self):
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.name,
             self.get_status_display(),
             self.get_status_display(),
+            self.role.name if self.role else None,
             self.cluster.name,
             self.cluster.name,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.platform.name if self.platform else None,
             self.platform.name if self.platform else None,
@@ -253,7 +267,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
             self.memory,
             self.memory,
             self.disk,
             self.disk,
             self.comments,
             self.comments,
-        ])
+        )
 
 
     def get_status_class(self):
     def get_status_class(self):
         return VM_STATUS_CLASSES[self.status]
         return VM_STATUS_CLASSES[self.status]

+ 3 - 2
netbox/virtualization/tables.py

@@ -80,8 +80,9 @@ class ClusterGroupTable(BaseTable):
 class ClusterTable(BaseTable):
 class ClusterTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.LinkColumn()
     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):
     class Meta(BaseTable.Meta):
         model = Cluster
         model = Cluster

+ 2 - 8
netbox/virtualization/views.py

@@ -99,10 +99,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class ClusterListView(ObjectListView):
 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
     table = tables.ClusterTable
     filter = filters.ClusterFilter
     filter = filters.ClusterFilter
     filter_form = forms.ClusterFilterForm
     filter_form = forms.ClusterFilterForm
@@ -162,10 +159,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'virtualization.delete_cluster'
     permission_required = 'virtualization.delete_cluster'
     cls = 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
     table = tables.ClusterTable
     default_return_url = 'virtualization:cluster_list'
     default_return_url = 'virtualization:cluster_list'