Parcourir la source

Merge pull request #1903 from digitalocean/develop

Release v2.2.10
Jeremy Stretch il y a 7 ans
Parent
commit
c4f7e8121a
80 fichiers modifiés avec 933 ajouts et 648 suppressions
  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
 installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
 
-### Freenode IRC
+### Slack
 
-For real-time discussion, you can join the #netbox channel on [Freenode](https://freenode.net/).
-You can connect to Freenode at irc.freenode.net using an IRC client, or you can
-use their [webchat client](https://webchat.freenode.net/).
+For real-time discussion, you can join the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/).
 
 ## Reporting Bugs
 
-* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of
-NetBox. If you're running an older version, it's possible that the bug has
+* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases)
+of NetBox. If you're running an older version, it's possible that the bug has
 already been fixed.
 
-* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has already
-been reported. If you think you may be experiencing a reported issue that
-hasn't already been resolved, please click "add a reaction" in the top right
-corner of the issue and add a thumbs up (+1). You mightalso want to add a
-comment describing how it's affecting your installation. This will allow us to
-prioritize bugs based on how many users are affected.
+* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
+to see if the bug you've found has already been reported. If you think you may
+be experiencing a reported issue that hasn't already been resolved, please
+click "add a reaction" in the top right corner of the issue and add a thumbs
+up (+1). You mightalso want to add a comment describing how it's affecting your
+installation. This will allow us to prioritize bugs based on how many users are
+affected.
 
 * If you haven't found an existing issue that describes your suspected bug,
 please inquire about it on the mailing list. **Do not** file an issue until you
@@ -44,7 +43,7 @@ include:
 
 * Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
 The issue will be reviewed by a moderator after submission and the appropriate
-labels will be applied.
+labels will be applied for categorization.
 
 * Keep in mind that we prioritize bugs based on their severity and how much
 work is required to resolve them. It may take some time for someone to address
@@ -52,15 +51,15 @@ your issue.
 
 ## Feature Requests
 
-* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're requesting
-is already listed. (Be sure to search closed issues as well, since some
-feature requests have been rejected.) If the feature you'd like to see has
-already been requested and is open, click "add a reaction" in the top right
-corner of the issue and add a thumbs up (+1). This ensures that the issue has
-a better chance of receiving attention. Also feel free to add a comment with
-any additional justification for the feature. (However, note that comments with
-no substance other than a "+1" will be deleted. Please use GitHub's reactions
-feature to indicate your support.)
+* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
+to see if the feature you're requesting is already listed. (Be sure to search
+closed issues as well, since some feature requests have been rejected.) If the
+feature you'd like to see has already been requested and is open, click "add a
+reaction" in the top right corner of the issue and add a thumbs up (+1). This
+ensures that the issue has a better chance of receiving attention. Also feel
+free to add a comment with any additional justification for the feature.
+(However, note that comments with no substance other than a "+1" will be
+deleted. Please use GitHub's reactions feature to indicate your support.)
 
 * Due to an excessive backlog of feature requests, we are not currently
 accepting any proposals which substantially extend NetBox's functionality
@@ -88,7 +87,7 @@ following:
 
 * Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
 title. The issue will be reviewed by a moderator after submission and the
-appropriate labels will be applied.
+appropriate labels will be applied for categorization.
 
 ## Submitting Pull Requests
 
@@ -109,3 +108,10 @@ these checks):
     * All tests pass when run with `./manage.py test`
     * PEP 8 compliance is enforced, with the exception that lines may be
       greater than 80 characters in length
+
+## Commenting
+
+Only comment on an issue if you are sharing a relevant idea or constructive
+feedback. **Do not** comment on an issue just to show your support (give the
+top post a :+1: instead) or ask for an ETA. These comments will be deleted to
+reduce noise in the discussion.

+ 12 - 4
README.md

@@ -1,12 +1,18 @@
 ![NetBox](docs/netbox_logo.png "NetBox logo")
 
-NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
+NetBox is an IP address management (IPAM) and data center infrastructure
+management (DCIM) tool. Initially conceived by the network engineering team at
+[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
+to address the needs of network and infrastructure engineers.
 
-NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
+NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
+Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
+complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
 
 The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
 
-Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), or join us on IRC in **#netbox** on **irc.freenode.net**!
+Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
+or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)!
 
 ### Build Status
 
@@ -27,7 +33,9 @@ NetBox is built against both Python 2.7 and 3.5.  Python 3.5 is recommended.
 
 # Installation
 
-Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) and run `upgrade.sh`.
+Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
+instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases)
+and run `upgrade.sh`.
 
 ## Alternative Installations
 

+ 2 - 2
docs/api/examples.md

@@ -5,7 +5,7 @@ Supported HTTP methods:
 * `GET`: Retrieve an object or list of objects
 * `POST`: Create a new object
 * `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
 
 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
 ```
 
-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
 
 # 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,
                                     "(objectClass=group)")
 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
@@ -86,7 +86,7 @@ The `count()` method can be appended to the queryset to return a count of object
 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')

+ 2 - 2
netbox/circuits/forms.py

@@ -43,7 +43,7 @@ class ProviderCSVForm(forms.ModelForm):
 
     class Meta:
         model = Provider
-        fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments']
+        fields = Provider.csv_headers
         help_texts = {
             'name': 'Provider name',
             'asn': '32-bit autonomous system number',
@@ -89,7 +89,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
 
     class Meta:
         model = CircuitType
-        fields = ['name', 'slug']
+        fields = CircuitType.csv_headers
         help_texts = {
             'name': 'Name of circuit type',
         }

+ 19 - 8
netbox/circuits/models.py

@@ -9,7 +9,6 @@ from dcim.fields import ASNField
 from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import *
 
 
@@ -29,7 +28,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
-    csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url']
+    csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
 
     class Meta:
         ordering = ['name']
@@ -41,13 +40,16 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
         return reverse('circuits:provider', args=[self.slug])
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.slug,
             self.asn,
             self.account,
             self.portal_url,
-        ])
+            self.noc_contact,
+            self.admin_contact,
+            self.comments,
+        )
 
 
 @python_2_unicode_compatible
@@ -59,6 +61,8 @@ class CircuitType(models.Model):
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
         ordering = ['name']
 
@@ -68,6 +72,12 @@ class CircuitType(models.Model):
     def get_absolute_url(self):
         return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 @python_2_unicode_compatible
 class Circuit(CreatedUpdatedModel, CustomFieldModel):
@@ -86,7 +96,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
-    csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description']
+    csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
 
     class Meta:
         ordering = ['provider', 'cid']
@@ -99,15 +109,16 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
         return reverse('circuits:circuit', args=[self.pk])
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.cid,
             self.provider.name,
             self.type.name,
             self.tenant.name if self.tenant else None,
-            self.install_date.isoformat() if self.install_date else None,
+            self.install_date,
             self.commit_rate,
             self.description,
-        ])
+            self.comments,
+        )
 
     def _get_termination(self, side):
         for ct in self.terminations.all():

+ 28 - 28
netbox/dcim/forms.py

@@ -72,9 +72,7 @@ class RegionCSVForm(forms.ModelForm):
 
     class Meta:
         model = Region
-        fields = [
-            'name', 'slug', 'parent',
-        ]
+        fields = Region.csv_headers
         help_texts = {
             'name': 'Region name',
             'slug': 'URL-friendly slug',
@@ -136,10 +134,7 @@ class SiteCSVForm(forms.ModelForm):
 
     class Meta:
         model = Site
-        fields = [
-            'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-            'contact_name', 'contact_phone', 'contact_email', 'comments',
-        ]
+        fields = Site.csv_headers
         help_texts = {
             'name': 'Site name',
             'slug': 'URL-friendly slug',
@@ -196,9 +191,7 @@ class RackGroupCSVForm(forms.ModelForm):
 
     class Meta:
         model = RackGroup
-        fields = [
-            'site', 'name', 'slug',
-        ]
+        fields = RackGroup.csv_headers
         help_texts = {
             'name': 'Name of rack group',
             'slug': 'URL-friendly slug',
@@ -226,7 +219,7 @@ class RackRoleCSVForm(forms.ModelForm):
 
     class Meta:
         model = RackRole
-        fields = ['name', 'slug', 'color']
+        fields = RackRole.csv_headers
         help_texts = {
             'name': 'Name of rack role',
             'color': 'RGB color in hexadecimal (e.g. 00ff00)'
@@ -313,10 +306,7 @@ class RackCSVForm(forms.ModelForm):
 
     class Meta:
         model = Rack
-        fields = [
-            'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
-            'desc_units',
-        ]
+        fields = Rack.csv_headers
         help_texts = {
             'name': 'Rack name',
             'u_height': 'Height in rack units',
@@ -444,9 +434,7 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm):
 class ManufacturerCSVForm(forms.ModelForm):
     class Meta:
         model = Manufacturer
-        fields = [
-            'name', 'slug'
-        ]
+        fields = Manufacturer.csv_headers
         help_texts = {
             'name': 'Manufacturer name',
             'slug': 'URL-friendly slug',
@@ -492,8 +480,7 @@ class DeviceTypeCSVForm(forms.ModelForm):
 
     class Meta:
         model = DeviceType
-        fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
-                  'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
+        fields = DeviceType.csv_headers
         help_texts = {
             'model': 'Model name',
             'slug': 'URL-friendly slug',
@@ -658,7 +645,7 @@ class DeviceRoleCSVForm(forms.ModelForm):
 
     class Meta:
         model = DeviceRole
-        fields = ['name', 'slug', 'color', 'vm_role']
+        fields = DeviceRole.csv_headers
         help_texts = {
             'name': 'Name of device role',
             'color': 'RGB color in hexadecimal (e.g. 00ff00)'
@@ -682,7 +669,7 @@ class PlatformCSVForm(forms.ModelForm):
 
     class Meta:
         model = Platform
-        fields = ['name', 'slug', 'napalm_driver']
+        fields = Platform.csv_headers
         help_texts = {
             'name': 'Platform name',
         }
@@ -932,7 +919,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster',
+            'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster', 'comments',
         ]
 
     def clean(self):
@@ -981,7 +968,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-            'parent', 'device_bay_name', 'cluster',
+            'parent', 'device_bay_name', 'cluster', 'comments',
         ]
 
     def clean(self):
@@ -1061,6 +1048,15 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
     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:
         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 = {
             'device': forms.HiddenInput(),
         }
@@ -1636,7 +1632,11 @@ class InterfaceCreateForm(ComponentForm):
     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')
     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)
 
     def __init__(self, *args, **kwargs):
@@ -1808,7 +1808,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
 
     class Meta:
         model = InterfaceConnection
-        fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
+        fields = InterfaceConnection.csv_headers
 
     def clean_interface_a(self):
 
@@ -1951,7 +1951,7 @@ class InventoryItemCSVForm(forms.ModelForm):
 
     class Meta:
         model = InventoryItem
-        fields = ['device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description']
+        fields = InventoryItem.csv_headers
 
 
 class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):

+ 67 - 41
netbox/dcim/models.py

@@ -22,7 +22,6 @@ from tenancy.models import Tenant
 from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import *
 from .fields import ASNField, MACAddressField
 from .querysets import InterfaceQuerySet
@@ -43,9 +42,7 @@ class Region(MPTTModel):
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
 
-    csv_headers = [
-        'name', 'slug', 'parent',
-    ]
+    csv_headers = ['name', 'slug', 'parent']
 
     class MPTTMeta:
         order_insertion_by = ['name']
@@ -57,11 +54,11 @@ class Region(MPTTModel):
         return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.slug,
             self.parent.name if self.parent else None,
-        ])
+        )
 
 
 #
@@ -98,7 +95,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     objects = SiteManager()
 
     csv_headers = [
-        'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
+        'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
+        'contact_phone', 'contact_email', 'comments',
     ]
 
     class Meta:
@@ -111,17 +109,20 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
         return reverse('dcim:site', args=[self.slug])
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.slug,
             self.region.name if self.region else None,
             self.tenant.name if self.tenant else None,
             self.facility,
             self.asn,
+            self.physical_address,
+            self.shipping_address,
             self.contact_name,
             self.contact_phone,
             self.contact_email,
-        ])
+            self.comments,
+        )
 
     @property
     def count_prefixes(self):
@@ -164,9 +165,7 @@ class RackGroup(models.Model):
     slug = models.SlugField()
     site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
 
-    csv_headers = [
-        'site', 'name', 'slug',
-    ]
+    csv_headers = ['site', 'name', 'slug']
 
     class Meta:
         ordering = ['site', 'name']
@@ -182,11 +181,11 @@ class RackGroup(models.Model):
         return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.site,
             self.name,
             self.slug,
-        ])
+        )
 
 
 @python_2_unicode_compatible
@@ -198,6 +197,8 @@ class RackRole(models.Model):
     slug = models.SlugField(unique=True)
     color = ColorField()
 
+    csv_headers = ['name', 'slug', 'color']
+
     class Meta:
         ordering = ['name']
 
@@ -207,6 +208,13 @@ class RackRole(models.Model):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+        )
+
 
 class RackManager(NaturalOrderByManager):
 
@@ -242,7 +250,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
 
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
-        'desc_units',
+        'desc_units', 'comments',
     ]
 
     class Meta:
@@ -292,7 +300,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             Device.objects.filter(rack=self).update(site_id=self.site.pk)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.site.name,
             self.group.name if self.group else None,
             self.name,
@@ -304,7 +312,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
             self.width,
             self.u_height,
             self.desc_units,
-        ])
+            self.comments,
+        )
 
     @property
     def units(self):
@@ -479,9 +488,7 @@ class Manufacturer(models.Model):
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
 
-    csv_headers = [
-        'name', 'slug',
-    ]
+    csv_headers = ['name', 'slug']
 
     class Meta:
         ordering = ['name']
@@ -493,10 +500,10 @@ class Manufacturer(models.Model):
         return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.slug,
-        ])
+        )
 
 
 @python_2_unicode_compatible
@@ -539,7 +546,7 @@ class DeviceType(models.Model, CustomFieldModel):
 
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
-        'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering',
+        'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments',
     ]
 
     class Meta:
@@ -562,7 +569,7 @@ class DeviceType(models.Model, CustomFieldModel):
         return reverse('dcim:devicetype', args=[self.pk])
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.manufacturer.name,
             self.model,
             self.slug,
@@ -574,7 +581,8 @@ class DeviceType(models.Model, CustomFieldModel):
             self.is_network_device,
             self.get_subdevice_role_display() if self.subdevice_role else None,
             self.get_interface_ordering_display(),
-        ])
+            self.comments,
+        )
 
     def clean(self):
 
@@ -754,6 +762,8 @@ class DeviceRole(models.Model):
         help_text="Virtual machines may be assigned to this role"
     )
 
+    csv_headers = ['name', 'slug', 'color', 'vm_role']
+
     class Meta:
         ordering = ['name']
 
@@ -763,6 +773,14 @@ class DeviceRole(models.Model):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.color,
+            self.vm_role,
+        )
+
 
 @python_2_unicode_compatible
 class Platform(models.Model):
@@ -778,6 +796,8 @@ class Platform(models.Model):
     rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
                                   verbose_name='Legacy RPC client')
 
+    csv_headers = ['name', 'slug', 'napalm_driver']
+
     class Meta:
         ordering = ['name']
 
@@ -787,6 +807,13 @@ class Platform(models.Model):
     def get_absolute_url(self):
         return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.napalm_driver,
+        )
+
 
 class DeviceManager(NaturalOrderByManager):
 
@@ -848,7 +875,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
 
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
-        'site', 'rack_group', 'rack_name', 'position', 'face',
+        'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
     ]
 
     class Meta:
@@ -989,7 +1016,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name or '',
             self.device_role.name,
             self.tenant.name if self.tenant else None,
@@ -1004,7 +1031,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             self.rack.name if self.rack else None,
             self.position,
             self.get_face_display(),
-        ])
+            self.comments,
+        )
 
     @property
     def display_name(self):
@@ -1076,15 +1104,14 @@ class ConsolePort(models.Model):
     def __str__(self):
         return self.name
 
-    # Used for connections export
     def to_csv(self):
-        return csv_format([
+        return (
             self.cs_port.device.identifier if self.cs_port else None,
             self.cs_port.name if self.cs_port else None,
             self.device.identifier,
             self.name,
             self.get_connection_status_display(),
-        ])
+        )
 
 
 #
@@ -1153,15 +1180,14 @@ class PowerPort(models.Model):
     def __str__(self):
         return self.name
 
-    # Used for connections export
     def to_csv(self):
-        return csv_format([
+        return (
             self.power_outlet.device.identifier if self.power_outlet else None,
             self.power_outlet.name if self.power_outlet else None,
             self.device.identifier,
             self.name,
             self.get_connection_status_display(),
-        ])
+        )
 
 
 #
@@ -1382,15 +1408,14 @@ class InterfaceConnection(models.Model):
         except ObjectDoesNotExist:
             pass
 
-    # Used for connections export
     def to_csv(self):
-        return csv_format([
+        return (
             self.interface_a.device.identifier,
             self.interface_a.name,
             self.interface_b.device.identifier,
             self.interface_b.name,
             self.get_connection_status_display(),
-        ])
+        )
 
 
 #
@@ -1453,7 +1478,7 @@ class InventoryItem(models.Model):
     description = models.CharField(max_length=100, blank=True)
 
     csv_headers = [
-        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+        'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
     ]
 
     class Meta:
@@ -1464,12 +1489,13 @@ class InventoryItem(models.Model):
         return self.name
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.device.name or '{' + self.device.pk + '}',
             self.name,
             self.manufacturer.name if self.manufacturer else None,
             self.part_id,
             self.serial,
             self.asset_tag,
-            self.description
-        ])
+            self.discovered,
+            self.description,
+        )

+ 55 - 10
netbox/dcim/tables.py

@@ -65,6 +65,10 @@ RACK_ROLE = """
 {% endif %}
 """
 
+RACK_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
+"""
+
 RACKRESERVATION_ACTIONS = """
 {% if perms.dcim.change_rackreservation %}
     <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -83,6 +87,22 @@ MANUFACTURER_ACTIONS = """
 {% endif %}
 """
 
+DEVICEROLE_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
+DEVICEROLE_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
+PLATFORM_DEVICE_COUNT = """
+<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
+"""
+
+PLATFORM_VM_COUNT = """
+<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
+"""
+
 PLATFORM_ACTIONS = """
 {% if perms.dcim.change_platform %}
     <a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -218,12 +238,16 @@ class RackTable(BaseTable):
 
 
 class RackDetailTable(RackTable):
-    devices = tables.Column(accessor=Accessor('device_count'))
+    device_count = tables.TemplateColumn(
+        template_code=RACK_DEVICE_COUNT,
+        verbose_name='Devices'
+    )
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
     class Meta(RackTable.Meta):
         fields = (
-            'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
+            'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
+            'get_utilization',
         )
 
 
@@ -362,12 +386,25 @@ class DeviceBayTemplateTable(BaseTable):
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
-    device_count = tables.Column(verbose_name='Devices')
-    vm_count = tables.Column(verbose_name='VMs')
+    device_count = tables.TemplateColumn(
+        template_code=DEVICEROLE_DEVICE_COUNT,
+        accessor=Accessor('devices.count'),
+        orderable=False,
+        verbose_name='Devices'
+    )
+    vm_count = tables.TemplateColumn(
+        template_code=DEVICEROLE_VM_COUNT,
+        accessor=Accessor('virtual_machines.count'),
+        orderable=False,
+        verbose_name='VMs'
+    )
     color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
     slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
-                                    verbose_name='')
+    actions = tables.TemplateColumn(
+        template_code=DEVICEROLE_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = DeviceRole
@@ -380,10 +417,18 @@ class DeviceRoleTable(BaseTable):
 
 class PlatformTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
-    device_count = tables.Column(verbose_name='Devices')
-    vm_count = tables.Column(verbose_name='VMs')
-    slug = tables.Column(verbose_name='Slug')
+    device_count = tables.TemplateColumn(
+        template_code=PLATFORM_DEVICE_COUNT,
+        accessor=Accessor('devices.count'),
+        orderable=False,
+        verbose_name='Devices'
+    )
+    vm_count = tables.TemplateColumn(
+        template_code=PLATFORM_VM_COUNT,
+        accessor=Accessor('virtual_machines.count'),
+        orderable=False,
+        verbose_name='VMs'
+    )
     actions = tables.TemplateColumn(
         template_code=PLATFORM_ACTIONS,
         attrs={'td': {'class': 'text-right'}},

+ 3 - 9
netbox/dcim/views.py

@@ -276,7 +276,7 @@ class RackListView(ObjectListView):
     ).prefetch_related(
         'devices__device_type'
     ).annotate(
-        device_count=Count('devices', distinct=True)
+        device_count=Count('devices')
     )
     filter = filters.RackFilter
     filter_form = forms.RackFilterForm
@@ -715,10 +715,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class DeviceRoleListView(ObjectListView):
-    queryset = DeviceRole.objects.annotate(
-        device_count=Count('devices', distinct=True),
-        vm_count=Count('virtual_machines', distinct=True)
-    )
+    queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     template_name = 'dcim/devicerole_list.html'
 
@@ -756,10 +753,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class PlatformListView(ObjectListView):
-    queryset = Platform.objects.annotate(
-        device_count=Count('devices', distinct=True),
-        vm_count=Count('virtual_machines', distinct=True)
-    )
+    queryset = Platform.objects.all()
     table = tables.PlatformTable
     template_name = 'dcim/platform_list.html'
 

+ 1 - 1
netbox/extras/admin.py

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

+ 20 - 0
netbox/extras/constants.py

@@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = (
     (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_TYPE_INTERFACE = 100
 GRAPH_TYPE_PROVIDER = 200
@@ -46,6 +56,16 @@ EXPORTTEMPLATE_MODELS = [
     '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
 ACTION_CREATE = 1
 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 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
 
 
@@ -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.
     """
 
-    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)
 
     def filter(self, queryset, value):
@@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter):
             except ValueError:
                 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):
@@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         super(CustomFieldFilterSet, self).__init__(*args, **kwargs)
 
         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:
-            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):

+ 13 - 12
netbox/extras/forms.py

@@ -6,7 +6,7 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 
 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
 
 
@@ -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
     """
     field_dict = OrderedDict()
-    kwargs = {'obj_type': content_type}
+    custom_fields = CustomField.objects.filter(obj_type=content_type)
     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:
         field_name = 'cf_{}'.format(str(cf.name))
+        initial = cf.default if not bulk_edit else None
 
         # Integer
         if cf.type == CF_TYPE_INTEGER:
-            field = forms.IntegerField(required=cf.required, initial=cf.default)
+            field = forms.IntegerField(required=cf.required, initial=initial)
 
         # Boolean
         elif cf.type == CF_TYPE_BOOLEAN:
@@ -34,18 +34,19 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
                 (1, 'True'),
                 (0, 'False'),
             )
-            if cf.default.lower() in ['true', 'yes', '1']:
+            if initial is not None and initial.lower() in ['true', 'yes', '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
             else:
                 initial = None
-            field = forms.NullBooleanField(required=cf.required, initial=initial,
-                                           widget=forms.Select(choices=choices))
+            field = forms.NullBooleanField(
+                required=cf.required, initial=initial, widget=forms.Select(choices=choices)
+            )
 
         # Date
         elif cf.type == CF_TYPE_DATE:
-            field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD")
+            field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
 
         # Select
         elif cf.type == CF_TYPE_SELECT:
@@ -56,11 +57,11 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
 
         # URL
         elif cf.type == CF_TYPE_URL:
-            field = LaxURLField(required=cf.required, initial=cf.default)
+            field = LaxURLField(required=cf.required, initial=initial)
 
         # Text
         else:
-            field = forms.CharField(max_length=255, required=cf.required, initial=cf.default)
+            field = forms.CharField(max_length=255, required=cf.required, initial=initial)
 
         field.model = cf
         field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()

+ 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 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):
 
@@ -25,5 +17,4 @@ class Migration(migrations.Migration):
             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.'),
         ),
-        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.safestring import mark_safe
 
+from dcim.constants import CONNECTION_STATUS_CONNECTED
 from utilities.utils import foreground_color
 from .constants import *
 
@@ -54,22 +55,48 @@ class CustomFieldModel(object):
 
 @python_2_unicode_compatible
 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:
         ordering = ['weight', 'name']
@@ -223,19 +250,25 @@ class ExportTemplate(models.Model):
     def __str__(self):
         return '{}: {}'.format(self.content_type, self.name)
 
-    def to_response(self, context_dict, filename):
+    def render_to_response(self, queryset):
         """
         Render the template to an HTTP response, delivered as a named file attachment
         """
         template = Template(self.template_code)
         mime_type = 'text/plain' if not self.mime_type else self.mime_type
-        output = template.render(Context(context_dict))
+        output = template.render(Context({'queryset': queryset}))
+
         # Replace CRLF-style line terminators
         output = output.replace('\r\n', '\n')
+
+        # Build the response
         response = HttpResponse(output, content_type=mime_type)
-        if self.file_extension:
-            filename += '.{}'.format(self.file_extension)
+        filename = 'netbox_{}{}'.format(
+            queryset.model._meta.verbose_name_plural,
+            '.{}'.format(self.file_extension) if self.file_extension else ''
+        )
         response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+
         return response
 
 
@@ -247,7 +280,17 @@ class ExportTemplate(models.Model):
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, 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(
         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. "
@@ -269,22 +312,26 @@ class TopologyMap(models.Model):
 
     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
-        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()
         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['directed'] = 'true'
 
             # Add a pseudonode for each device_set to enforce hierarchical layout
             subgraph.node('set{}'.format(i), label='', shape='none', width='0')
             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
             devices = []
@@ -302,31 +349,64 @@ class TopologyMap(models.Model):
             for j in range(0, len(devices) - 1):
                 subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
 
-            graph.subgraph(subgraph)
+            self.graph.subgraph(subgraph)
 
         # Compile list of all devices
         device_superset = Q()
         for device_set in self.device_sets:
             for query in device_set.split(';'):  # Split regexes on semicolons
                 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
-        devices = Device.objects.filter(*(device_superset,))
         connections = InterfaceConnection.objects.filter(
             interface_a__device__in=devices, interface_b__device__in=devices
         )
         for c in connections:
             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
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
             peer_termination = termination.get_peer_termination()
             if (peer_termination is not None and peer_termination.interface is not None and
                     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:
         model = VRF
-        fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
+        fields = VRF.csv_headers
         help_texts = {
             'name': 'VRF name',
         }
@@ -102,7 +102,7 @@ class RIRCSVForm(forms.ModelForm):
 
     class Meta:
         model = RIR
-        fields = ['name', 'slug', 'is_private']
+        fields = RIR.csv_headers
         help_texts = {
             'name': 'RIR name',
         }
@@ -144,7 +144,7 @@ class AggregateCSVForm(forms.ModelForm):
 
     class Meta:
         model = Aggregate
-        fields = ['prefix', 'rir', 'date_added', 'description']
+        fields = Aggregate.csv_headers
 
 
 class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -185,7 +185,7 @@ class RoleCSVForm(forms.ModelForm):
 
     class Meta:
         model = Role
-        fields = ['name', 'slug']
+        fields = Role.csv_headers
         help_texts = {
             'name': 'Role name',
         }
@@ -299,9 +299,7 @@ class PrefixCSVForm(forms.ModelForm):
 
     class Meta:
         model = Prefix
-        fields = [
-            'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
-        ]
+        fields = Prefix.csv_headers
 
     def clean(self):
 
@@ -609,10 +607,7 @@ class IPAddressCSVForm(forms.ModelForm):
 
     class Meta:
         model = IPAddress
-        fields = [
-            'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
-            'description',
-        ]
+        fields = IPAddress.csv_headers
 
     def clean(self):
 
@@ -759,7 +754,7 @@ class VLANGroupCSVForm(forms.ModelForm):
 
     class Meta:
         model = VLANGroup
-        fields = ['site', 'name', 'slug']
+        fields = VLANGroup.csv_headers
         help_texts = {
             'name': 'Name of VLAN group',
         }
@@ -849,7 +844,7 @@ class VLANCSVForm(forms.ModelForm):
 
     class Meta:
         model = VLAN
-        fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
+        fields = VLAN.csv_headers
         help_texts = {
             'vid': 'Numeric VLAN ID (1-4095)',
             'name': 'VLAN name',

+ 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 tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .querysets import PrefixQuerySet
@@ -38,7 +37,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
     class Meta:
-        ordering = ['name']
+        ordering = ['name', 'rd']
         verbose_name = 'VRF'
         verbose_name_plural = 'VRFs'
 
@@ -49,13 +48,13 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
         return reverse('ipam:vrf', args=[self.pk])
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.rd,
             self.tenant.name if self.tenant else None,
             self.enforce_unique,
             self.description,
-        ])
+        )
 
     @property
     def display_name(self):
@@ -75,6 +74,8 @@ class RIR(models.Model):
     is_private = models.BooleanField(default=False, verbose_name='Private',
                                      help_text='IP space managed by this RIR is considered private')
 
+    csv_headers = ['name', 'slug', 'is_private']
+
     class Meta:
         ordering = ['name']
         verbose_name = 'RIR'
@@ -86,6 +87,13 @@ class RIR(models.Model):
     def get_absolute_url(self):
         return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.is_private,
+        )
+
 
 @python_2_unicode_compatible
 class Aggregate(CreatedUpdatedModel, CustomFieldModel):
@@ -147,12 +155,12 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
         super(Aggregate, self).save(*args, **kwargs)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.prefix,
             self.rir.name,
-            self.date_added.isoformat() if self.date_added else None,
+            self.date_added,
             self.description,
-        ])
+        )
 
     def get_utilization(self):
         """
@@ -173,19 +181,20 @@ class Role(models.Model):
     slug = models.SlugField(unique=True)
     weight = models.PositiveSmallIntegerField(default=1000)
 
+    csv_headers = ['name', 'slug', 'weight']
+
     class Meta:
         ordering = ['weight', 'name']
 
     def __str__(self):
         return self.name
 
-    @property
-    def count_prefixes(self):
-        return self.prefixes.count()
-
-    @property
-    def count_vlans(self):
-        return self.vlans.count()
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.weight,
+        )
 
 
 @python_2_unicode_compatible
@@ -262,7 +271,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
         super(Prefix, self).save(*args, **kwargs)
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.prefix,
             self.vrf.rd if self.vrf else None,
             self.tenant.name if self.tenant else None,
@@ -273,7 +282,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
             self.role.name if self.role else None,
             self.is_pool,
             self.description,
-        ])
+        )
 
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
@@ -461,7 +470,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
         else:
             is_primary = False
 
-        return csv_format([
+        return (
             self.address,
             self.vrf.rd if self.vrf else None,
             self.tenant.name if self.tenant else None,
@@ -472,7 +481,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
             self.interface.name if self.interface else None,
             is_primary,
             self.description,
-        ])
+        )
 
     @property
     def device(self):
@@ -502,6 +511,8 @@ class VLANGroup(models.Model):
     slug = models.SlugField()
     site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
 
+    csv_headers = ['name', 'slug', 'site']
+
     class Meta:
         ordering = ['site', 'name']
         unique_together = [
@@ -517,6 +528,13 @@ class VLANGroup(models.Model):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+            self.site.name if self.site else None,
+        )
+
     def get_next_available_vid(self):
         """
         Return the first available VLAN ID (1-4094) in the group.
@@ -577,7 +595,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
             })
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.site.name if self.site else None,
             self.group.name if self.group else None,
             self.vid,
@@ -586,7 +604,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
             self.get_status_display(),
             self.role.name if self.role else None,
             self.description,
-        ])
+        )
 
     @property
     def display_name(self):

+ 20 - 4
netbox/ipam/tables.py

@@ -37,6 +37,14 @@ UTILIZATION_GRAPH = """
 {% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
 """
 
+ROLE_PREFIX_COUNT = """
+<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
+ROLE_VLAN_COUNT = """
+<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
+"""
+
 ROLE_ACTIONS = """
 {% if perms.ipam.change_role %}
     <a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -220,10 +228,18 @@ class AggregateDetailTable(AggregateTable):
 
 class RoleTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.Column(verbose_name='Name')
-    prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
-    vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
-    slug = tables.Column(verbose_name='Slug')
+    prefix_count = tables.TemplateColumn(
+        accessor=Accessor('prefixes.count'),
+        template_code=ROLE_PREFIX_COUNT,
+        orderable=False,
+        verbose_name='Prefixes'
+    )
+    vlan_count = tables.TemplateColumn(
+        accessor=Accessor('vlans.count'),
+        template_code=ROLE_VLAN_COUNT,
+        orderable=False,
+        verbose_name='VLANs'
+    )
     actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
     class Meta(BaseTable.Meta):

+ 1 - 1
netbox/netbox/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__)))
 

+ 1 - 1
netbox/netbox/views.py

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

+ 2 - 2
netbox/secrets/forms.py

@@ -47,7 +47,7 @@ class SecretRoleCSVForm(forms.ModelForm):
 
     class Meta:
         model = SecretRole
-        fields = ['name', 'slug']
+        fields = SecretRole.csv_headers
         help_texts = {
             'name': 'Name of secret role',
         }
@@ -98,7 +98,7 @@ class SecretCSVForm(forms.ModelForm):
 
     class Meta:
         model = Secret
-        fields = ['device', 'role', 'name', 'plaintext']
+        fields = Secret.csv_headers
         help_texts = {
             'name': 'Name or username',
         }

+ 8 - 0
netbox/secrets/models.py

@@ -239,6 +239,8 @@ class SecretRole(models.Model):
     users = models.ManyToManyField(User, related_name='secretroles', blank=True)
     groups = models.ManyToManyField(Group, related_name='secretroles', blank=True)
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
         ordering = ['name']
 
@@ -248,6 +250,12 @@ class SecretRole(models.Model):
     def get_absolute_url(self):
         return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
     def has_member(self, user):
         """
         Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -43,17 +43,23 @@
 <h1>{{ device }}</h1>
 {% include 'inc/created_updated.html' with obj=device %}
 <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 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 %}
-            <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 %}
 </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' %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.dcim.add_interfaceconnection %}
-        <a href="{% url 'dcim:interface_connections_import' %}" class="btn btn-info">
-            <span class="fa fa-download" aria-hidden="true"></span>
-            Import connections
-        </a>
+        {% import_button 'dcim:interface_connections_import' %}
     {% endif %}
-    {% include 'inc/export_button.html' with obj_type='connections' %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Interface Connections{% endblock %}</h1>
 <div class="row">

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

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

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

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

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

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

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

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

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

@@ -45,9 +45,10 @@
 {% endblock %}
 
 {% 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 %}

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -144,7 +144,7 @@
         {% if duplicate_ips_table.rows %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
         {% 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>
 {% endblock %}

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

@@ -1,19 +1,13 @@
 {% extends '_base.html' %}
-{% load helpers %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_ipaddress %}
-		<a href="{% url 'ipam:ipaddress_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add an IP
-		</a>
-		<a href="{% url 'ipam:ipaddress_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import IPs
-		</a>
-	{% endif %}
-    {% include 'inc/export_button.html' with obj_type='IPs' %}
+        {% add_button 'ipam:ipaddress_add' %}
+        {% import_button 'ipam:ipaddress_import' %}
+    {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}IP Addresses{% endblock %}</h1>
 <div class="row">

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

@@ -136,7 +136,7 @@
         {% if duplicate_prefix_table.rows %}
             {% include 'panel_table.html' with table=duplicate_prefix_table heading='Duplicate Prefixes' panel_class='danger' %}
         {% 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>
 {% endblock %}

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

@@ -1,6 +1,6 @@
 {% extends '_base.html' %}
+{% load buttons %}
 {% load helpers %}
-{% load form_helpers %}
 
 {% block content %}
 <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>
     </div>
     {% if perms.ipam.add_prefix %}
-		<a href="{% url 'ipam:prefix_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a prefix
-		</a>
-		<a href="{% url 'ipam:prefix_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import prefixes
-		</a>
-	{% endif %}
-    {% include 'inc/export_button.html' with obj_type='prefixes' %}
+        {% add_button 'ipam:prefix_add' %}
+        {% import_button 'ipam:prefix_import' %}
+    {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}Prefixes{% endblock %}</h1>
 <div class="row">

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

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

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

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

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

@@ -1,20 +1,13 @@
 {% extends '_base.html' %}
-{% load helpers %}
-{% load form_helpers %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_vlan %}
-		<a href="{% url 'ipam:vlan_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a VLAN
-		</a>
-		<a href="{% url 'ipam:vlan_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import VLANs
-		</a>
-	{% endif %}
-    {% include 'inc/export_button.html' with obj_type='VLANs' %}
+        {% add_button 'ipam:vlan_add' %}
+        {% import_button 'ipam:vlan_import' %}
+    {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}VLANs{% endblock %}</h1>
 <div class="row">

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

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

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

@@ -1,20 +1,13 @@
 {% extends '_base.html' %}
-{% load helpers %}
-{% load form_helpers %}
+{% load buttons %}
 
 {% block content %}
 <div class="pull-right">
     {% if perms.ipam.add_vrf %}
-		<a href="{% url 'ipam:vrf_add' %}" class="btn btn-primary">
-			<span class="fa fa-plus" aria-hidden="true"></span>
-			Add a VRF
-		</a>
-		<a href="{% url 'ipam:vrf_import' %}" class="btn btn-info">
-			<span class="fa fa-download" aria-hidden="true"></span>
-			Import VRFs
-		</a>
-	{% endif %}
-    {% include 'inc/export_button.html' with obj_type='VRFs' %}
+        {% add_button 'ipam:vrf_add' %}
+        {% import_button 'ipam:vrf_import' %}
+    {% endif %}
+    {% export_button content_type %}
 </div>
 <h1>{% block title %}VRFs{% endblock %}</h1>
 <div class="row">

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -6,9 +6,7 @@
         <div class="panel-heading"><strong>Virtual Machine</strong></div>
         <div class="panel-body">
             {% render_field form.name %}
-            {% render_field form.status %}
             {% render_field form.role %}
-            {% render_field form.platform %}
         </div>
     </div>
     <div class="panel panel-default">
@@ -19,6 +17,15 @@
         </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-heading"><strong>Resources</strong></div>
         <div class="panel-body">
             {% render_field form.vcpus %}

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

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

+ 2 - 2
netbox/tenancy/forms.py

@@ -27,7 +27,7 @@ class TenantGroupCSVForm(forms.ModelForm):
 
     class Meta:
         model = TenantGroup
-        fields = ['name', 'slug']
+        fields = TenantGroup.csv_headers
         help_texts = {
             'name': 'Group name',
         }
@@ -60,7 +60,7 @@ class TenantCSVForm(forms.ModelForm):
 
     class Meta:
         model = Tenant
-        fields = ['name', 'slug', 'group', 'description', 'comments']
+        fields = Tenant.csv_headers
         help_texts = {
             'name': 'Tenant name',
             'comments': 'Free-form comments'

+ 12 - 4
netbox/tenancy/models.py

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

+ 3 - 7
netbox/utilities/forms.py

@@ -1,7 +1,7 @@
 from __future__ import unicode_literals
 
 import csv
-import itertools
+from io import StringIO
 import re
 
 from django import forms
@@ -245,14 +245,10 @@ class CSVDataField(forms.CharField):
 
     def to_python(self, value):
 
-        # Python 2's csv module has problems with Unicode
-        if not isinstance(value, str):
-            value = value.encode('utf-8')
-
         records = []
-        reader = csv.reader(value.splitlines())
+        reader = csv.reader(StringIO(value))
 
-        # Consume and valdiate the first line of CSV data as column headers
+        # Consume and validate the first line of CSV data as column headers
         headers = next(reader)
         for f in self.required_fields:
             if f not in headers:

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

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

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

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

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

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

+ 26 - 0
netbox/utilities/templatetags/buttons.py

@@ -0,0 +1,26 @@
+from __future__ import unicode_literals
+
+from django import template
+
+from extras.models import ExportTemplate
+
+register = template.Library()
+
+
+@register.inclusion_tag('buttons/add.html')
+def add_button(url):
+    return {'add_url': url}
+
+
+@register.inclusion_tag('buttons/import.html')
+def import_button(url):
+    return {'import_url': url}
+
+
+@register.inclusion_tag('buttons/export.html', takes_context=True)
+def export_button(context, content_type=None):
+    export_templates = ExportTemplate.objects.filter(content_type=content_type)
+    return {
+        'url_params': context['request'].GET,
+        'export_templates': export_templates,
+    }

+ 34 - 1
netbox/utilities/utils.py

@@ -1,7 +1,10 @@
 from __future__ import unicode_literals
 
+import datetime
 import six
 
+from django.http import HttpResponse
+
 
 def csv_format(data):
     """
@@ -15,12 +18,16 @@ def csv_format(data):
             csv.append('')
             continue
 
+        # Convert dates to ISO format
+        if isinstance(value, (datetime.date, datetime.datetime)):
+            value = value.isoformat()
+
         # Force conversion to string first so we can check for any commas
         if not isinstance(value, six.string_types):
             value = '{}'.format(value)
 
         # Double-quote the value if it contains a comma
-        if ',' in value:
+        if ',' in value or '\n' in value:
             csv.append('"{}"'.format(value))
         else:
             csv.append('{}'.format(value))
@@ -28,6 +35,32 @@ def csv_format(data):
     return ','.join(csv)
 
 
+def queryset_to_csv(queryset):
+    """
+    Export a queryset of objects as CSV, using the model's to_csv() method.
+    """
+    output = []
+
+    # Start with the column headers
+    headers = ','.join(queryset.model.csv_headers)
+    output.append(headers)
+
+    # Iterate through the queryset
+    for obj in queryset:
+        data = csv_format(obj.to_csv())
+        output.append(data)
+
+    # Build the HTTP response
+    response = HttpResponse(
+        '\n'.join(output),
+        content_type='text/csv'
+    )
+    filename = 'netbox_{}.csv'.format(queryset.model._meta.verbose_name_plural)
+    response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
+
+    return response
+
+
 def foreground_color(bg_color):
     """
     Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.

+ 11 - 20
netbox/utilities/views.py

@@ -10,7 +10,6 @@ from django.core.exceptions import ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import ProtectedError
 from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
-from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import TemplateSyntaxError
 from django.urls import reverse
@@ -21,6 +20,7 @@ from django.views.generic import View
 from django_tables2 import RequestConfig
 
 from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
+from utilities.utils import queryset_to_csv
 from utilities.forms import BootstrapMixin, CSVDataField
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm
@@ -79,7 +79,7 @@ class ObjectListView(View):
     def get(self, request):
 
         model = self.queryset.model
-        object_ct = ContentType.objects.get_for_model(model)
+        content_type = ContentType.objects.get_for_model(model)
 
         if self.filter:
             self.queryset = self.filter(request.GET, self.queryset).qs
@@ -92,27 +92,18 @@ class ObjectListView(View):
 
         # Check for export template rendering
         if request.GET.get('export'):
-            et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export'))
+            et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export'))
             queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset
             try:
-                response = et.to_response(context_dict={'queryset': queryset},
-                                          filename='netbox_{}'.format(model._meta.verbose_name_plural))
-                return response
+                return et.render_to_response(queryset)
             except TemplateSyntaxError:
-                messages.error(request, "There was an error rendering the selected export template ({})."
-                               .format(et.name))
-        # Fall back to built-in CSV export
+                messages.error(
+                    request,
+                    "There was an error rendering the selected export template ({}).".format(et.name)
+                )
+        # Fall back to built-in CSV export if no template was specified
         elif 'export' in request.GET and hasattr(model, 'to_csv'):
-            headers = getattr(model, 'csv_headers', None)
-            output = ','.join(headers) + '\n' if headers else ''
-            output += '\n'.join([obj.to_csv() for obj in self.queryset])
-            response = HttpResponse(
-                output,
-                content_type='text/csv'
-            )
-            response['Content-Disposition'] = 'attachment; filename="netbox_{}.csv"'\
-                .format(self.queryset.model._meta.verbose_name_plural)
-            return response
+            return queryset_to_csv(self.queryset)
 
         # Provide a hook to tweak the queryset based on the request immediately prior to rendering the object list
         self.queryset = self.alter_queryset(request)
@@ -134,10 +125,10 @@ class ObjectListView(View):
         RequestConfig(request, paginate).configure(table)
 
         context = {
+            'content_type': content_type,
             'table': table,
             'permissions': permissions,
             'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None,
-            'export_templates': ExportTemplate.objects.filter(content_type=object_ct),
         }
         context.update(self.extra_context())
 

+ 42 - 6
netbox/virtualization/forms.py

@@ -9,6 +9,7 @@ from dcim.constants import IFACE_FF_VIRTUAL
 from dcim.formfields import MACAddressFormField
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from ipam.models import IPAddress
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -41,7 +42,7 @@ class ClusterTypeCSVForm(forms.ModelForm):
 
     class Meta:
         model = ClusterType
-        fields = ['name', 'slug']
+        fields = ClusterType.csv_headers
         help_texts = {
             'name': 'Name of cluster type',
         }
@@ -64,7 +65,7 @@ class ClusterGroupCSVForm(forms.ModelForm):
 
     class Meta:
         model = ClusterGroup
-        fields = ['name', 'slug']
+        fields = ClusterGroup.csv_headers
         help_texts = {
             'name': 'Name of cluster group',
         }
@@ -112,7 +113,7 @@ class ClusterCSVForm(forms.ModelForm):
 
     class Meta:
         model = Cluster
-        fields = ['name', 'type', 'group', 'site', 'comments']
+        fields = Cluster.csv_headers
 
 
 class ClusterBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -246,8 +247,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
         model = VirtualMachine
         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):
@@ -261,6 +262,41 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
 
         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):
     status = CSVChoiceField(
@@ -306,7 +342,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
 
     class Meta:
         model = VirtualMachine
-        fields = ['name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
+        fields = VirtualMachine.csv_headers
 
 
 class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):

+ 23 - 9
netbox/virtualization/models.py

@@ -10,7 +10,6 @@ from django.utils.encoding import python_2_unicode_compatible
 from dcim.models import Device
 from extras.models import CustomFieldModel, CustomFieldValue
 from utilities.models import CreatedUpdatedModel
-from utilities.utils import csv_format
 from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
 
 
@@ -31,6 +30,8 @@ class ClusterType(models.Model):
         unique=True
     )
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
         ordering = ['name']
 
@@ -40,6 +41,12 @@ class ClusterType(models.Model):
     def get_absolute_url(self):
         return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 #
 # Cluster groups
@@ -58,6 +65,8 @@ class ClusterGroup(models.Model):
         unique=True
     )
 
+    csv_headers = ['name', 'slug']
+
     class Meta:
         ordering = ['name']
 
@@ -67,6 +76,12 @@ class ClusterGroup(models.Model):
     def get_absolute_url(self):
         return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
 
+    def to_csv(self):
+        return (
+            self.name,
+            self.slug,
+        )
+
 
 #
 # Clusters
@@ -109,9 +124,7 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
         object_id_field='obj_id'
     )
 
-    csv_headers = [
-        'name', 'type', 'group', 'site', 'comments',
-    ]
+    csv_headers = ['name', 'type', 'group', 'site', 'comments']
 
     class Meta:
         ordering = ['name']
@@ -135,13 +148,13 @@ class Cluster(CreatedUpdatedModel, CustomFieldModel):
                 })
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.type.name,
             self.group.name if self.group else None,
             self.site.name if self.site else None,
             self.comments,
-        ])
+        )
 
 
 #
@@ -230,7 +243,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
     )
 
     csv_headers = [
-        'name', 'status', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+        'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
     ]
 
     class Meta:
@@ -243,9 +256,10 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
         return reverse('virtualization:virtualmachine', args=[self.pk])
 
     def to_csv(self):
-        return csv_format([
+        return (
             self.name,
             self.get_status_display(),
+            self.role.name if self.role else None,
             self.cluster.name,
             self.tenant.name if self.tenant else None,
             self.platform.name if self.platform else None,
@@ -253,7 +267,7 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
             self.memory,
             self.disk,
             self.comments,
-        ])
+        )
 
     def get_status_class(self):
         return VM_STATUS_CLASSES[self.status]

+ 3 - 2
netbox/virtualization/tables.py

@@ -80,8 +80,9 @@ class ClusterGroupTable(BaseTable):
 class ClusterTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn()
-    device_count = tables.Column(verbose_name='Devices')
-    vm_count = tables.Column(verbose_name='VMs')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    device_count = tables.Column(accessor=Accessor('devices.count'), orderable=False, verbose_name='Devices')
+    vm_count = tables.Column(accessor=Accessor('virtual_machines.count'), orderable=False, verbose_name='VMs')
 
     class Meta(BaseTable.Meta):
         model = Cluster

+ 2 - 8
netbox/virtualization/views.py

@@ -99,10 +99,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 class ClusterListView(ObjectListView):
-    queryset = Cluster.objects.annotate(
-        device_count=Count('devices', distinct=True),
-        vm_count=Count('virtual_machines', distinct=True)
-    )
+    queryset = Cluster.objects.select_related('type', 'group')
     table = tables.ClusterTable
     filter = filters.ClusterFilter
     filter_form = forms.ClusterFilterForm
@@ -162,10 +159,7 @@ class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'virtualization.delete_cluster'
     cls = Cluster
-    queryset = Cluster.objects.annotate(
-        device_count=Count('devices', distinct=True),
-        vm_count=Count('virtual_machines', distinct=True)
-    )
+    queryset = Cluster.objects.all()
     table = tables.ClusterTable
     default_return_url = 'virtualization:cluster_list'