Przeglądaj źródła

Merge pull request #1903 from digitalocean/develop

Release v2.2.10
Jeremy Stretch 8 lat temu
rodzic
commit
c4f7e8121a
80 zmienionych plików z 933 dodań i 648 usunięć
  1. 29 23
      CONTRIBUTING.md
  2. 12 4
      README.md
  3. 2 2
      docs/api/examples.md
  4. 1 1
      docs/installation/ldap.md
  5. 2 2
      docs/miscellaneous/shell.md
  6. 2 2
      netbox/circuits/forms.py
  7. 19 8
      netbox/circuits/models.py
  8. 28 28
      netbox/dcim/forms.py
  9. 67 41
      netbox/dcim/models.py
  10. 55 10
      netbox/dcim/tables.py
  11. 3 9
      netbox/dcim/views.py
  12. 1 1
      netbox/extras/admin.py
  13. 20 0
      netbox/extras/constants.py
  14. 12 9
      netbox/extras/filters.py
  15. 13 12
      netbox/extras/forms.py
  16. 0 9
      netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py
  17. 20 0
      netbox/extras/migrations/0009_topologymap_type.py
  18. 51 0
      netbox/extras/migrations/0010_customfield_filter_logic.py
  19. 112 32
      netbox/extras/models.py
  20. 8 13
      netbox/ipam/forms.py
  21. 19 0
      netbox/ipam/migrations/0021_vrf_ordering.py
  22. 38 20
      netbox/ipam/models.py
  23. 20 4
      netbox/ipam/tables.py
  24. 1 1
      netbox/netbox/settings.py
  25. 1 1
      netbox/netbox/views.py
  26. 2 2
      netbox/secrets/forms.py
  27. 8 0
      netbox/secrets/models.py
  28. 4 10
      netbox/templates/circuits/circuit_list.html
  29. 4 9
      netbox/templates/circuits/circuittype_list.html
  30. 4 9
      netbox/templates/circuits/provider_list.html
  31. 3 5
      netbox/templates/dcim/console_connections_list.html
  32. 4 10
      netbox/templates/dcim/device_list.html
  33. 4 9
      netbox/templates/dcim/devicerole_list.html
  34. 4 10
      netbox/templates/dcim/devicetype_list.html
  35. 15 9
      netbox/templates/dcim/inc/device_header.html
  36. 15 0
      netbox/templates/dcim/inc/device_napalm_tabs.html
  37. 29 0
      netbox/templates/dcim/inc/filter_rack_group.html
  38. 3 5
      netbox/templates/dcim/interface_connections_list.html
  39. 3 5
      netbox/templates/dcim/inventoryitem_list.html
  40. 4 10
      netbox/templates/dcim/manufacturer_list.html
  41. 4 9
      netbox/templates/dcim/platform_list.html
  42. 3 5
      netbox/templates/dcim/power_connections_list.html
  43. 6 5
      netbox/templates/dcim/rack_elevation_list.html
  44. 5 39
      netbox/templates/dcim/rack_list.html
  45. 4 10
      netbox/templates/dcim/rackgroup_list.html
  46. 4 10
      netbox/templates/dcim/region_list.html
  47. 4 9
      netbox/templates/dcim/site_list.html
  48. 0 20
      netbox/templates/inc/export_button.html
  49. 4 10
      netbox/templates/ipam/aggregate_list.html
  50. 1 1
      netbox/templates/ipam/ipaddress.html
  51. 5 11
      netbox/templates/ipam/ipaddress_list.html
  52. 1 1
      netbox/templates/ipam/prefix.html
  53. 5 11
      netbox/templates/ipam/prefix_list.html
  54. 4 9
      netbox/templates/ipam/rir_list.html
  55. 4 9
      netbox/templates/ipam/role_list.html
  56. 5 12
      netbox/templates/ipam/vlan_list.html
  57. 4 9
      netbox/templates/ipam/vlangroup_list.html
  58. 5 12
      netbox/templates/ipam/vrf_list.html
  59. 2 5
      netbox/templates/secrets/secret_list.html
  60. 5 10
      netbox/templates/secrets/secretrole_list.html
  61. 4 10
      netbox/templates/tenancy/tenant_list.html
  62. 4 9
      netbox/templates/tenancy/tenantgroup_list.html
  63. 4 9
      netbox/templates/virtualization/cluster_list.html
  64. 4 9
      netbox/templates/virtualization/clustergroup_list.html
  65. 4 9
      netbox/templates/virtualization/clustertype_list.html
  66. 9 2
      netbox/templates/virtualization/virtualmachine_edit.html
  67. 4 9
      netbox/templates/virtualization/virtualmachine_list.html
  68. 2 2
      netbox/tenancy/forms.py
  69. 12 4
      netbox/tenancy/models.py
  70. 3 7
      netbox/utilities/forms.py
  71. 3 0
      netbox/utilities/templates/buttons/add.html
  72. 19 0
      netbox/utilities/templates/buttons/export.html
  73. 3 0
      netbox/utilities/templates/buttons/import.html
  74. 26 0
      netbox/utilities/templatetags/buttons.py
  75. 34 1
      netbox/utilities/utils.py
  76. 11 20
      netbox/utilities/views.py
  77. 42 6
      netbox/virtualization/forms.py
  78. 23 9
      netbox/virtualization/models.py
  79. 3 2
      netbox/virtualization/tables.py
  80. 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
docs/api/examples.md

@@ -5,7 +5,7 @@ Supported HTTP methods:
 * `GET`: Retrieve an object or list of objects
 * `GET`: Retrieve an object or list of objects
 * `POST`: Create a new object
 * `POST`: Create a new object
 * `PUT`: Update an existing object, all mandatory fields must be specified
 * `PUT`: Update an existing object, all mandatory fields must be specified
-* `PATCH`: Updates an existing object, only specifiying the field to be changed
+* `PATCH`: Updates an existing object, only specifying the field to be changed
 * `DELETE`: Delete an existing object
 * `DELETE`: Delete an existing object
 
 
 To authenticate a request, attach your token in an `Authorization` header:
 To authenticate a request, attach your token in an `Authorization` header:
@@ -144,4 +144,4 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
 * Closing connection 0
 * Closing connection 0
 ```
 ```
 
 
-The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.
+The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.

+ 1 - 1
docs/installation/ldap.md

@@ -87,7 +87,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
 
 
 # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
 # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
-# heirarchy.
+# hierarchy.
 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("dc=example,dc=com", ldap.SCOPE_SUBTREE,
                                     "(objectClass=group)")
                                     "(objectClass=group)")
 AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
 AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

+ 2 - 2
docs/miscellaneous/shell.md

@@ -1,4 +1,4 @@
-NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
+NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
 
 
 ```
 ```
 ./manage.py nbshell
 ./manage.py nbshell
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
 982
 982
 ```
 ```
 
 
-Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
+Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
 
 
 ```
 ```
 >>> Device.objects.filter(tenant__name='Pied Piper')
 >>> Device.objects.filter(tenant__name='Pied Piper')

+ 2 - 2
netbox/circuits/forms.py

@@ -43,7 +43,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',
@@ -89,7 +89,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',
         }
         }

+ 19 - 8
netbox/circuits/models.py

@@ -9,7 +9,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 *
 from .constants import *
 
 
 
 
@@ -29,7 +28,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']
@@ -41,13 +40,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
@@ -59,6 +61,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']
 
 
@@ -68,6 +72,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):
@@ -86,7 +96,7 @@ 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', 'tenant', 'install_date', 'commit_rate', 'description']
+    csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
 
 
     class Meta:
     class Meta:
         ordering = ['provider', 'cid']
         ordering = ['provider', 'cid']
@@ -99,15 +109,16 @@ 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.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_termination(self, side):
     def _get_termination(self, side):
         for ct in self.terminations.all():
         for ct in self.terminations.all():

+ 28 - 28
netbox/dcim/forms.py

@@ -72,9 +72,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',
@@ -136,10 +134,7 @@ class SiteCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = [
-            'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-            'contact_name', 'contact_phone', 'contact_email', 'comments',
-        ]
+        fields = Site.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Site name',
             'name': 'Site name',
             'slug': 'URL-friendly slug',
             'slug': 'URL-friendly slug',
@@ -196,9 +191,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',
@@ -226,7 +219,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)'
@@ -313,10 +306,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',
@@ -444,9 +434,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',
@@ -492,8 +480,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',
@@ -658,7 +645,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)'
@@ -682,7 +669,7 @@ class PlatformCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['name', 'slug', 'napalm_driver']
+        fields = Platform.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Platform name',
             'name': 'Platform name',
         }
         }
@@ -932,7 +919,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):
@@ -981,7 +968,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):
@@ -1061,6 +1048,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     )
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     mac_address = forms.CharField(required=False, label='MAC address')
     mac_address = forms.CharField(required=False, label='MAC address')
+    has_primary_ip = forms.NullBooleanField(
+        required=False,
+        label='Has a primary IP',
+        widget=forms.Select(choices=[
+            ('', '---------'),
+            ('True', 'Yes'),
+            ('False', 'No'),
+        ])
+    )
 
 
 
 
 #
 #
@@ -1610,7 +1606,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description']
+        fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description']
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
         }
         }
@@ -1636,7 +1632,11 @@ class InterfaceCreateForm(ComponentForm):
     lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
-    mgmt_only = forms.BooleanField(required=False, label='OOB Management')
+    mgmt_only = forms.BooleanField(
+        required=False,
+        label='OOB Management',
+        help_text='This interface is used only for out-of-band management'
+    )
     description = forms.CharField(max_length=100, required=False)
     description = forms.CharField(max_length=100, required=False)
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -1808,7 +1808,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):
 
 
@@ -1951,7 +1951,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):

+ 67 - 41
netbox/dcim/models.py

@@ -22,7 +22,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
@@ -43,9 +42,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']
@@ -57,11 +54,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,
-        ])
+        )
 
 
 
 
 #
 #
@@ -98,7 +95,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
     objects = SiteManager()
 
 
     csv_headers = [
     csv_headers = [
-        'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
+        'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
+        'contact_phone', 'contact_email', 'comments',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -111,17 +109,20 @@ 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.region.name if self.region else None,
             self.region.name if self.region else None,
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.facility,
             self.facility,
             self.asn,
             self.asn,
+            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,
+        )
 
 
     @property
     @property
     def count_prefixes(self):
     def count_prefixes(self):
@@ -164,9 +165,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']
@@ -182,11 +181,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
@@ -198,6 +197,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']
 
 
@@ -207,6 +208,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):
 
 
@@ -242,7 +250,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:
@@ -292,7 +300,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,
@@ -304,7 +312,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):
@@ -479,9 +488,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']
@@ -493,10 +500,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
@@ -539,7 +546,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:
@@ -562,7 +569,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,
@@ -574,7 +581,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):
 
 
@@ -754,6 +762,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']
 
 
@@ -763,6 +773,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):
@@ -778,6 +796,8 @@ class Platform(models.Model):
     rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
     rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
                                   verbose_name='Legacy RPC client')
                                   verbose_name='Legacy RPC client')
 
 
+    csv_headers = ['name', 'slug', 'napalm_driver']
+
     class Meta:
     class Meta:
         ordering = ['name']
         ordering = ['name']
 
 
@@ -787,6 +807,13 @@ 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.napalm_driver,
+        )
+
 
 
 class DeviceManager(NaturalOrderByManager):
 class DeviceManager(NaturalOrderByManager):
 
 
@@ -848,7 +875,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:
@@ -989,7 +1016,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,
@@ -1004,7 +1031,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):
@@ -1076,15 +1104,14 @@ class ConsolePort(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
-    # 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(),
-        ])
+        )
 
 
 
 
 #
 #
@@ -1153,15 +1180,14 @@ class PowerPort(models.Model):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
-    # 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(),
-        ])
+        )
 
 
 
 
 #
 #
@@ -1382,15 +1408,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(),
-        ])
+        )
 
 
 
 
 #
 #
@@ -1453,7 +1478,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:
@@ -1464,12 +1489,13 @@ class InventoryItem(models.Model):
         return self.name
         return self.name
 
 
     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,
+        )

+ 55 - 10
netbox/dcim/tables.py

@@ -65,6 +65,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>
@@ -83,6 +87,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>
@@ -218,12 +238,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',
         )
         )
 
 
 
 
@@ -362,12 +386,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
@@ -380,10 +417,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

@@ -276,7 +276,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
@@ -715,10 +715,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'
 
 
@@ -756,10 +753,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'
 
 

+ 1 - 1
netbox/extras/admin.py

@@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
 @admin.register(CustomField)
 @admin.register(CustomField)
 class CustomFieldAdmin(admin.ModelAdmin):
 class CustomFieldAdmin(admin.ModelAdmin):
     inlines = [CustomFieldChoiceAdmin]
     inlines = [CustomFieldChoiceAdmin]
-    list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description']
+    list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
     form = CustomFieldForm
     form = CustomFieldForm
 
 
     def models(self, obj):
     def models(self, obj):

+ 20 - 0
netbox/extras/constants.py

@@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
     (CF_TYPE_SELECT, 'Selection'),
     (CF_TYPE_SELECT, 'Selection'),
 )
 )
 
 
+# Custom field filter logic choices
+CF_FILTER_DISABLED = 0
+CF_FILTER_LOOSE = 1
+CF_FILTER_EXACT = 2
+CF_FILTER_CHOICES = (
+    (CF_FILTER_DISABLED, 'Disabled'),
+    (CF_FILTER_LOOSE, 'Loose'),
+    (CF_FILTER_EXACT, 'Exact'),
+)
+
 # Graph types
 # Graph types
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_PROVIDER = 200
 GRAPH_TYPE_PROVIDER = 200
@@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
     'cluster', 'virtualmachine',                                                    # Virtualization
     'cluster', 'virtualmachine',                                                    # Virtualization
 ]
 ]
 
 
+# Topology map types
+TOPOLOGYMAP_TYPE_NETWORK = 1
+TOPOLOGYMAP_TYPE_CONSOLE = 2
+TOPOLOGYMAP_TYPE_POWER = 3
+TOPOLOGYMAP_TYPE_CHOICES = (
+    (TOPOLOGYMAP_TYPE_NETWORK, 'Network'),
+    (TOPOLOGYMAP_TYPE_CONSOLE, 'Console'),
+    (TOPOLOGYMAP_TYPE_POWER, 'Power'),
+)
+
 # User action types
 # User action types
 ACTION_CREATE = 1
 ACTION_CREATE = 1
 ACTION_IMPORT = 2
 ACTION_IMPORT = 2

+ 12 - 9
netbox/extras/filters.py

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from dcim.models import Site
 from dcim.models import Site
-from .constants import CF_TYPE_SELECT
+from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
 from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
 from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction
 
 
 
 
@@ -14,8 +14,9 @@ class CustomFieldFilter(django_filters.Filter):
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
     """
     """
 
 
-    def __init__(self, cf_type, *args, **kwargs):
-        self.cf_type = cf_type
+    def __init__(self, custom_field, *args, **kwargs):
+        self.cf_type = custom_field.type
+        self.filter_logic = custom_field.filter_logic
         super(CustomFieldFilter, self).__init__(*args, **kwargs)
         super(CustomFieldFilter, self).__init__(*args, **kwargs)
 
 
     def filter(self, queryset, value):
     def filter(self, queryset, value):
@@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter):
             except ValueError:
             except ValueError:
                 return queryset.none()
                 return queryset.none()
 
 
-        return queryset.filter(
-            custom_field_values__field__name=self.name,
-            custom_field_values__serialized_value__icontains=value,
-        )
+        # Apply the assigned filter logic (exact or loose)
+        queryset = queryset.filter(custom_field_values__field__name=self.name)
+        if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
+            return queryset.filter(custom_field_values__serialized_value=value)
+        else:
+            return queryset.filter(custom_field_values__serialized_value__icontains=value)
 
 
 
 
 class CustomFieldFilterSet(django_filters.FilterSet):
 class CustomFieldFilterSet(django_filters.FilterSet):
@@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
         super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
 
 
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         obj_type = ContentType.objects.get_for_model(self._meta.model)
-        custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
+        custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED)
         for cf in custom_fields:
         for cf in custom_fields:
-            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
+            self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf)
 
 
 
 
 class GraphFilter(django_filters.FilterSet):
 class GraphFilter(django_filters.FilterSet):

+ 13 - 12
netbox/extras/forms.py

@@ -6,7 +6,7 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
 from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
-from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
+from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL
 from .models import CustomField, CustomFieldValue, ImageAttachment
 from .models import CustomField, CustomFieldValue, ImageAttachment
 
 
 
 
@@ -15,17 +15,17 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
     Retrieve all CustomFields applicable to the given ContentType
     Retrieve all CustomFields applicable to the given ContentType
     """
     """
     field_dict = OrderedDict()
     field_dict = OrderedDict()
-    kwargs = {'obj_type': content_type}
+    custom_fields = CustomField.objects.filter(obj_type=content_type)
     if filterable_only:
     if filterable_only:
-        kwargs['is_filterable'] = True
-    custom_fields = CustomField.objects.filter(**kwargs)
+        custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED)
 
 
     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 +34,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 is not None and initial.lower() in ['true', 'yes', '1']:
                 initial = 1
                 initial = 1
-            elif cf.default.lower() in ['false', 'no', '0']:
+            elif initial is not None and 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 +57,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()

+ 0 - 9
netbox/extras/migrations/0004_topologymap_change_comma_to_semicolon.py

@@ -4,14 +4,6 @@ from __future__ import unicode_literals
 
 
 from django.db import migrations, models
 from django.db import migrations, models
 
 
-from extras.models import TopologyMap
-
-
-def commas_to_semicolons(apps, schema_editor):
-    for tm in TopologyMap.objects.filter(device_patterns__contains=','):
-        tm.device_patterns = tm.device_patterns.replace(',', ';')
-        tm.save()
-
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
             name='device_patterns',
             name='device_patterns',
             field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
             field=models.TextField(help_text=b'Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
         ),
         ),
-        migrations.RunPython(commas_to_semicolons),
     ]
     ]

+ 20 - 0
netbox/extras/migrations/0009_topologymap_type.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-15 16:28
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0008_reports'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='topologymap',
+            name='type',
+            field=models.PositiveSmallIntegerField(choices=[(1, 'Network'), (2, 'Console'), (3, 'Power')], default=1),
+        ),
+    ]

+ 51 - 0
netbox/extras/migrations/0010_customfield_filter_logic.py

@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-21 19:48
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT
+
+
+def is_filterable_to_filter_logic(apps, schema_editor):
+    CustomField = apps.get_model('extras', 'CustomField')
+    CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED)
+    CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE)
+    # Select fields match on primary key only
+    CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT)
+
+
+def filter_logic_to_is_filterable(apps, schema_editor):
+    CustomField = apps.get_model('extras', 'CustomField')
+    CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False)
+    CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0009_topologymap_type'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='customfield',
+            name='filter_logic',
+            field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='required',
+            field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'),
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='weight',
+            field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'),
+        ),
+        migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable),
+        migrations.RemoveField(
+            model_name='customfield',
+            name='is_filterable',
+        ),
+    ]

+ 112 - 32
netbox/extras/models.py

@@ -16,6 +16,7 @@ from django.template import Template, Context
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 
 
+from dcim.constants import CONNECTION_STATUS_CONNECTED
 from utilities.utils import foreground_color
 from utilities.utils import foreground_color
 from .constants import *
 from .constants import *
 
 
@@ -54,22 +55,48 @@ class CustomFieldModel(object):
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class CustomField(models.Model):
 class CustomField(models.Model):
-    obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
-                                      limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
-                                      help_text="The object(s) to which this field applies.")
-    type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT)
-    name = models.CharField(max_length=50, unique=True)
-    label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not "
-                                                                  "provided, the field's name will be used)")
-    description = models.CharField(max_length=100, blank=True)
-    required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating "
-                                                            "new objects or editing an existing object.")
-    is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.")
-    default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or "
-                                                                     "\"false\" for booleans. N/A for selection "
-                                                                     "fields.")
-    weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a "
-                                                                     "form")
+    obj_type = models.ManyToManyField(
+        to=ContentType,
+        related_name='custom_fields',
+        verbose_name='Object(s)',
+        limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
+        help_text='The object(s) to which this field applies.'
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=CUSTOMFIELD_TYPE_CHOICES,
+        default=CF_TYPE_TEXT
+    )
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    label = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)'
+    )
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
+    required = models.BooleanField(
+        default=False,
+        help_text='If true, this field is required when creating new objects or editing an existing object.'
+    )
+    filter_logic = models.PositiveSmallIntegerField(
+        choices=CF_FILTER_CHOICES,
+        default=CF_FILTER_LOOSE,
+        help_text="Loose matches any instance of a given string; exact matches the entire field."
+    )
+    default = models.CharField(
+        max_length=100,
+        blank=True,
+        help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.'
+    )
+    weight = models.PositiveSmallIntegerField(
+        default=100,
+        help_text='Fields with higher weights appear lower in a form.'
+    )
 
 
     class Meta:
     class Meta:
         ordering = ['weight', 'name']
         ordering = ['weight', 'name']
@@ -223,19 +250,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
 
 
 
 
@@ -247,7 +280,17 @@ class ExportTemplate(models.Model):
 class TopologyMap(models.Model):
 class TopologyMap(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)
-    site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
+    type = models.PositiveSmallIntegerField(
+        choices=TOPOLOGYMAP_TYPE_CHOICES,
+        default=TOPOLOGYMAP_TYPE_NETWORK
+    )
+    site = models.ForeignKey(
+        to='dcim.Site',
+        related_name='topology_maps',
+        blank=True,
+        null=True,
+        on_delete=models.CASCADE
+    )
     device_patterns = models.TextField(
     device_patterns = models.TextField(
         help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
         help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
                   "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
                   "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -269,22 +312,26 @@ class TopologyMap(models.Model):
 
 
     def render(self, img_format='png'):
     def render(self, img_format='png'):
 
 
-        from circuits.models import CircuitTermination
-        from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
+        from dcim.models import Device
 
 
         # Construct the graph
         # Construct the graph
-        graph = graphviz.Graph()
-        graph.graph_attr['ranksep'] = '1'
+        if self.type == TOPOLOGYMAP_TYPE_NETWORK:
+            G = graphviz.Graph
+        else:
+            G = graphviz.Digraph
+        self.graph = G()
+        self.graph.graph_attr['ranksep'] = '1'
         seen = set()
         seen = set()
         for i, device_set in enumerate(self.device_sets):
         for i, device_set in enumerate(self.device_sets):
 
 
-            subgraph = graphviz.Graph(name='sg{}'.format(i))
+            subgraph = G(name='sg{}'.format(i))
             subgraph.graph_attr['rank'] = 'same'
             subgraph.graph_attr['rank'] = 'same'
+            subgraph.graph_attr['directed'] = 'true'
 
 
             # Add a pseudonode for each device_set to enforce hierarchical layout
             # Add a pseudonode for each device_set to enforce hierarchical layout
             subgraph.node('set{}'.format(i), label='', shape='none', width='0')
             subgraph.node('set{}'.format(i), label='', shape='none', width='0')
             if i:
             if i:
-                graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
+                self.graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
 
 
             # Add each device to the graph
             # Add each device to the graph
             devices = []
             devices = []
@@ -302,31 +349,64 @@ class TopologyMap(models.Model):
             for j in range(0, len(devices) - 1):
             for j in range(0, len(devices) - 1):
                 subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
                 subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
 
 
-            graph.subgraph(subgraph)
+            self.graph.subgraph(subgraph)
 
 
         # Compile list of all devices
         # Compile list of all devices
         device_superset = Q()
         device_superset = Q()
         for device_set in self.device_sets:
         for device_set in self.device_sets:
             for query in device_set.split(';'):  # Split regexes on semicolons
             for query in device_set.split(';'):  # Split regexes on semicolons
                 device_superset = device_superset | Q(name__regex=query)
                 device_superset = device_superset | Q(name__regex=query)
+        devices = Device.objects.filter(*(device_superset,))
+
+        # Draw edges depending on graph type
+        if self.type == TOPOLOGYMAP_TYPE_NETWORK:
+            self.add_network_connections(devices)
+        elif self.type == TOPOLOGYMAP_TYPE_CONSOLE:
+            self.add_console_connections(devices)
+        elif self.type == TOPOLOGYMAP_TYPE_POWER:
+            self.add_power_connections(devices)
+
+        return self.graph.pipe(format=img_format)
+
+    def add_network_connections(self, devices):
+
+        from circuits.models import CircuitTermination
+        from dcim.models import InterfaceConnection
 
 
         # Add all interface connections to the graph
         # Add all interface connections to the graph
-        devices = Device.objects.filter(*(device_superset,))
         connections = InterfaceConnection.objects.filter(
         connections = InterfaceConnection.objects.filter(
             interface_a__device__in=devices, interface_b__device__in=devices
             interface_a__device__in=devices, interface_b__device__in=devices
         )
         )
         for c in connections:
         for c in connections:
             style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
-            graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
+            self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
 
 
         # Add all circuits to the graph
         # Add all circuits to the graph
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
             peer_termination = termination.get_peer_termination()
             peer_termination = termination.get_peer_termination()
             if (peer_termination is not None and peer_termination.interface is not None and
             if (peer_termination is not None and peer_termination.interface is not None and
                     peer_termination.interface.device in devices):
                     peer_termination.interface.device in devices):
-                graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
+                self.graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
+
+    def add_console_connections(self, devices):
+
+        from dcim.models import ConsolePort
+
+        # Add all console connections to the graph
+        console_ports = ConsolePort.objects.filter(device__in=devices, cs_port__device__in=devices)
+        for cp in console_ports:
+            style = 'solid' if cp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
+            self.graph.edge(cp.cs_port.device.name, cp.device.name, style=style)
+
+    def add_power_connections(self, devices):
+
+        from dcim.models import PowerPort
 
 
-        return graph.pipe(format=img_format)
+        # Add all power connections to the graph
+        power_ports = PowerPort.objects.filter(device__in=devices, power_outlet__device__in=devices)
+        for pp in power_ports:
+            style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
+            self.graph.edge(pp.power_outlet.device.name, pp.device.name, style=style)
 
 
 
 
 #
 #

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

+ 19 - 0
netbox/ipam/migrations/0021_vrf_ordering.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.9 on 2018-02-07 18:37
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0020_ipaddress_add_role_carp'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='vrf',
+            options={'ordering': ['name', 'rd'], 'verbose_name': 'VRF', 'verbose_name_plural': 'VRFs'},
+        ),
+    ]

+ 38 - 20
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
@@ -38,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ['name']
+        ordering = ['name', 'rd']
         verbose_name = 'VRF'
         verbose_name = 'VRF'
         verbose_name_plural = 'VRFs'
         verbose_name_plural = 'VRFs'
 
 
@@ -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/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
     )
 
 
 
 
-VERSION = '2.2.9'
+VERSION = '2.2.10'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 

+ 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 - 10
netbox/templates/circuits/circuit_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/circuits/circuittype_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 10
netbox/templates/dcim/device_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/dcim/devicerole_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 10
netbox/templates/dcim/devicetype_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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">

+ 15 - 9
netbox/templates/dcim/inc/device_header.html

@@ -43,17 +43,23 @@
 <h1>{{ device }}</h1>
 <h1>{{ device }}</h1>
 {% include 'inc/created_updated.html' with obj=device %}
 {% include 'inc/created_updated.html' with obj=device %}
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
-    <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}><a href="{% url 'dcim:device' pk=device.pk %}">Info</a></li>
-    <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}><a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a></li>
+    <li role="presentation"{% if active_tab == 'info' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device' pk=device.pk %}">Info</a>
+    </li>
+    <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
+    </li>
     {% if perms.dcim.napalm_read %}
     {% if perms.dcim.napalm_read %}
-        {% if device.status == 1 and device.platform.napalm_driver and device.primary_ip %}
-            <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}><a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a></li>
-            <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}><a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a></li>
-            <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}><a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a></li>
+        {% if device.status != 1 %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
+        {% elif not device.platform %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
+        {% elif not device.platform.napalm_driver %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
+        {% elif not device.primary_ip %}
+            {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
         {% else %}
         {% else %}
-            <li role="presentation" class="disabled"><a href="#">Status</a></li>
-            <li role="presentation" class="disabled"><a href="#">LLDP Neighbors</a></li>
-            <li role="presentation" class="disabled"><a href="#">Configuration</a></li>
+            {% include 'dcim/inc/device_napalm_tabs.html' %}
         {% endif %}
         {% endif %}
     {% endif %}
     {% endif %}
 </ul>
 </ul>

+ 15 - 0
netbox/templates/dcim/inc/device_napalm_tabs.html

@@ -0,0 +1,15 @@
+{% if not disabled_message %}
+    <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_status' pk=device.pk %}">Status</a>
+    </li>
+    <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_lldp_neighbors' pk=device.pk %}">LLDP Neighbors</a>
+    </li>
+    <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
+        <a href="{% url 'dcim:device_config' pk=device.pk %}">Configuration</a>
+    </li>
+{% else %}
+    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
+    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
+    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
+{% endif %}

+ 29 - 0
netbox/templates/dcim/inc/filter_rack_group.html

@@ -0,0 +1,29 @@
+<script type="text/javascript">
+$(document).ready(function() {
+
+    var site_list = $('#id_site');
+    var rack_group_list = $('#id_group_id');
+
+    // Update rack group and rack options based on selected site
+    site_list.change(function() {
+        var selected_sites = $(this).val();
+        if (selected_sites) {
+
+            // Update rack group options
+            rack_group_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, group) {
+                        var option = $("<option></option>").attr("value", group.id).text(group.name);
+                        rack_group_list.append(option);
+                    });
+                }
+            });
+
+        }
+    });
+
+});
+</script>

+ 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 - 10
netbox/templates/dcim/manufacturer_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/dcim/platform_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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">

+ 6 - 5
netbox/templates/dcim/rack_elevation_list.html

@@ -45,9 +45,10 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-<script type="text/javascript">
-$(function() {
-  $('[data-toggle="popover"]').popover()
-})
-</script>
+    {% include 'dcim/inc/filter_rack_group.html' %}
+    <script type="text/javascript">
+    $(function() {
+        $('[data-toggle="popover"]').popover()
+    })
+    </script>
 {% endblock %}
 {% endblock %}

+ 5 - 39
netbox/templates/dcim/rack_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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">
@@ -27,34 +21,6 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-<script type="text/javascript">
-$(document).ready(function() {
-
-    var site_list = $('#id_site');
-    var rack_group_list = $('#id_group_id');
-
-    // Update rack group and rack options based on selected site
-    site_list.change(function() {
-        var selected_sites = $(this).val();
-        if (selected_sites) {
-
-            // Update rack group options
-            rack_group_list.empty();
-            $.ajax({
-                url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
-                dataType: 'json',
-                success: function (response, status) {
-                    $.each(response["results"], function (index, group) {
-                        var option = $("<option></option>").attr("value", group.id).text(group.name);
-                        rack_group_list.append(option);
-                    });
-                }
-            });
-
-        }
-    });
-
-});
-</script>
+    {% include 'dcim/inc/filter_rack_group.html' %}
 {% endblock %}
 {% endblock %}
 
 

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

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 10
netbox/templates/dcim/region_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 10
netbox/templates/ipam/aggregate_list.html

@@ -1,20 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load humanize %}
 {% load humanize %}
-{% 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">

+ 1 - 1
netbox/templates/ipam/ipaddress.html

@@ -144,7 +144,7 @@
         {% if duplicate_ips_table.rows %}
         {% if duplicate_ips_table.rows %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
         {% endif %}
         {% endif %}
-        {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
+        {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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">

+ 1 - 1
netbox/templates/ipam/prefix.html

@@ -136,7 +136,7 @@
         {% if duplicate_prefix_table.rows %}
         {% if duplicate_prefix_table.rows %}
             {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
             {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
         {% endif %}
         {% endif %}
-        {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' %}
+        {% include 'panel_table.html' with table=parent_prefix_table heading='Parent Prefixes' panel_class='default' %}
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

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

@@ -1,6 +1,6 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
 {% load helpers %}
-{% load form_helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
@@ -9,16 +9,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 - 9
netbox/templates/ipam/rir_list.html

@@ -1,6 +1,6 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
+{% load buttons %}
 {% load humanize %}
 {% load humanize %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right">
 <div class="pull-right">
@@ -16,15 +16,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 - 9
netbox/templates/ipam/role_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 12
netbox/templates/ipam/vlan_list.html

@@ -1,20 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
-{% load form_helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/ipam/vlangroup_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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">

+ 5 - 12
netbox/templates/ipam/vrf_list.html

@@ -1,20 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
-{% load form_helpers %}
+{% load buttons %}
 
 
 {% 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 - 5
netbox/templates/secrets/secret_list.html

@@ -1,13 +1,10 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 10
netbox/templates/secrets/secretrole_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 10
netbox/templates/tenancy/tenant_list.html

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/tenancy/tenantgroup_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/virtualization/clustergroup_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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 - 9
netbox/templates/virtualization/clustertype_list.html

@@ -1,18 +1,13 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 
 {% 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">

+ 9 - 2
netbox/templates/virtualization/virtualmachine_edit.html

@@ -6,9 +6,7 @@
         <div class="panel-heading"><strong>Virtual Machine</strong></div>
         <div class="panel-heading"><strong>Virtual Machine</strong></div>
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.name %}
             {% render_field form.name %}
-            {% render_field form.status %}
             {% render_field form.role %}
             {% render_field form.role %}
-            {% render_field form.platform %}
         </div>
         </div>
     </div>
     </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
@@ -18,6 +16,15 @@
             {% render_field form.cluster %}
             {% render_field form.cluster %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Management</strong></div>
+        <div class="panel-body">
+            {% render_field form.status %}
+            {% render_field form.platform %}
+            {% render_field form.primary_ip4 %}
+            {% render_field form.primary_ip6 %}
+        </div>
+    </div>
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Resources</strong></div>
         <div class="panel-heading"><strong>Resources</strong></div>
         <div class="panel-body">
         <div class="panel-body">

+ 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, TypedChoiceField
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
-from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import TemplateSyntaxError
 from django.template 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 .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm
 from .forms import ConfirmationForm
@@ -79,7 +79,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
@@ -92,27 +92,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)
@@ -134,10 +125,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())
 
 

+ 42 - 6
netbox/virtualization/forms.py

@@ -9,6 +9,7 @@ from dcim.constants import IFACE_FF_VIRTUAL
 from dcim.formfields import MACAddressFormField
 from dcim.formfields import MACAddressFormField
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from ipam.models import IPAddress
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -41,7 +42,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 +65,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 +113,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):
@@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         fields = [
-            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
-            'comments',
+            'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
+            'vcpus', 'memory', 'disk', 'comments',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -261,6 +262,41 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
 
         super(VirtualMachineForm, self).__init__(*args, **kwargs)
         super(VirtualMachineForm, self).__init__(*args, **kwargs)
 
 
+        if self.instance.pk:
+
+            # Compile list of choices for primary IPv4 and IPv6 addresses
+            for family in [4, 6]:
+                ip_choices = [(None, '---------')]
+                # Collect interface IPs
+                interface_ips = IPAddress.objects.select_related('interface').filter(
+                    family=family, interface__virtual_machine=self.instance
+                )
+                if interface_ips:
+                    ip_choices.append(
+                        ('Interface IPs', [
+                            (ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
+                        ])
+                    )
+                # Collect NAT IPs
+                nat_ips = IPAddress.objects.select_related('nat_inside').filter(
+                    family=family, nat_inside__interface__virtual_machine=self.instance
+                )
+                if nat_ips:
+                    ip_choices.append(
+                        ('NAT IPs', [
+                            (ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
+                        ])
+                    )
+                self.fields['primary_ip{}'.format(family)].choices = ip_choices
+
+        else:
+
+            # An object that doesn't exist yet can't have any IPs assigned to it
+            self.fields['primary_ip4'].choices = []
+            self.fields['primary_ip4'].widget.attrs['readonly'] = True
+            self.fields['primary_ip6'].choices = []
+            self.fields['primary_ip6'].widget.attrs['readonly'] = True
+
 
 
 class VirtualMachineCSVForm(forms.ModelForm):
 class VirtualMachineCSVForm(forms.ModelForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
@@ -306,7 +342,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 STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
 from .constants import STATUS_ACTIVE, 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'