Parcourir la source

Merge pull request #587 from digitalocean/develop

Release v1.6.2
Jeremy Stretch il y a 8 ans
Parent
commit
7336fdf162
43 fichiers modifiés avec 463 ajouts et 237 suppressions
  1. 1 1
      README.md
  2. 12 0
      docs/configuration/optional-settings.md
  3. 21 16
      docs/installation/netbox.md
  4. 22 6
      docs/installation/postgresql.md
  5. 12 10
      docs/installation/web-server.md
  6. 10 1
      netbox/dcim/api/serializers.py
  7. 1 1
      netbox/dcim/forms.py
  8. 18 0
      netbox/dcim/models.py
  9. 33 30
      netbox/dcim/tests/test_apis.py
  10. 1 1
      netbox/extras/admin.py
  11. 3 2
      netbox/extras/forms.py
  12. 25 0
      netbox/extras/migrations/0003_exporttemplate_add_description.py
  13. 12 3
      netbox/extras/models.py
  14. 4 0
      netbox/netbox/configuration.docker.py
  15. 4 0
      netbox/netbox/configuration.example.py
  16. 4 1
      netbox/netbox/settings.py
  17. 7 1
      netbox/netbox/urls.py
  18. 10 5
      netbox/project-static/js/forms.js
  19. 1 1
      netbox/secrets/forms.py
  20. 1 1
      netbox/templates/500.html
  21. 3 2
      netbox/templates/_base.html
  22. 10 3
      netbox/templates/circuits/circuit_bulk_edit.html
  23. 6 1
      netbox/templates/circuits/provider_bulk_edit.html
  24. 98 84
      netbox/templates/dcim/device.html
  25. 8 1
      netbox/templates/dcim/device_bulk_edit.html
  26. 7 3
      netbox/templates/dcim/devicetype_bulk_edit.html
  27. 9 30
      netbox/templates/dcim/inc/device_table.html
  28. 14 4
      netbox/templates/dcim/inc/devicetype_component_table.html
  29. 6 1
      netbox/templates/dcim/interface_add_multi.html
  30. 3 1
      netbox/templates/dcim/rack_bulk_edit.html
  31. 5 1
      netbox/templates/dcim/site_bulk_edit.html
  32. 1 1
      netbox/templates/inc/export_button.html
  33. 7 1
      netbox/templates/ipam/aggregate_bulk_edit.html
  34. 9 3
      netbox/templates/ipam/ipaddress_bulk_edit.html
  35. 11 4
      netbox/templates/ipam/prefix_bulk_edit.html
  36. 11 4
      netbox/templates/ipam/vlan_bulk_edit.html
  37. 7 1
      netbox/templates/ipam/vrf_bulk_edit.html
  38. 7 3
      netbox/templates/secrets/secret_bulk_edit.html
  39. 5 1
      netbox/templates/tenancy/tenant_bulk_edit.html
  40. 2 2
      netbox/templates/utilities/bulk_edit_form.html
  41. 13 4
      netbox/templates/utilities/obj_table.html
  42. 18 1
      netbox/utilities/forms.py
  43. 1 1
      netbox/utilities/tables.py

+ 1 - 1
README.md

@@ -6,7 +6,7 @@ NetBox runs as a web application atop the [Django](https://www.djangoproject.com
 
 The complete documentation for Netbox can be found at [Read the Docs](http://netbox.readthedocs.io/en/latest/).
 
-Questions? Comments? Please subscribe to [the netbox-disucss 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 on IRC in **#netbox** on **irc.freenode.net**!
 
 ### Build Status
 

+ 12 - 0
docs/configuration/optional-settings.md

@@ -26,6 +26,18 @@ BANNER_BOTTOM = BANNER_TOP
 
 ---
 
+## BASE_PATH
+
+Default: None
+
+The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
+
+```
+BASE_PATH = 'netbox/'
+```
+
+---
+
 ## DEBUG
 
 Default: False

+ 21 - 16
docs/installation/netbox.md

@@ -1,19 +1,16 @@
 # Installation
 
-NetBox requires following system dependencies:
+**Debian/Ubuntu**
 
-* python2.7
-* python-dev
-* python-pip
-* libxml2-dev
-* libxslt1-dev
-* libffi-dev
-* graphviz
-* libpq-dev
-* libssl-dev
+```
+# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
+```
+
+**CentOS/RHEL**
 
 ```
-# sudo apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
+# yum install -y epel-release
+# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
 ```
 
 You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
@@ -41,8 +38,16 @@ Create the base directory for the NetBox installation. For this guide, we'll use
 
 If `git` is not already installed, install it:
 
+**Debian/Ubuntu**
+
 ```
-# sudo apt-get install -y git
+# apt-get install -y git
+```
+
+**CentOS/RHEL**
+
+```
+# yum install -y git
 ```
 
 Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
@@ -63,7 +68,7 @@ Checking connectivity... done.
 Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
 
 ```
-# sudo pip install -r requirements.txt
+# pip install -r requirements.txt
 ```
 
 # Configuration
@@ -76,7 +81,7 @@ Move into the NetBox configuration directory and make a copy of `configuration.e
 ```
 
 Open `configuration.py` with your preferred editor and set the following variables:
- 
+
 * ALLOWED_HOSTS
 * DATABASE
 * SECRET_KEY
@@ -143,8 +148,8 @@ NetBox does not come with any predefined user accounts. You'll need to create a
 # ./manage.py createsuperuser
 Username: admin
 Email address: admin@example.com
-Password: 
-Password (again): 
+Password:
+Password (again):
 Superuser created successfully.
 ```
 

+ 22 - 6
docs/installation/postgresql.md

@@ -2,17 +2,33 @@ NetBox requires a PostgreSQL database to store data. MySQL is not supported, as
 
 # Installation
 
-The following packages are needed to install PostgreSQL with Python support:
+**Debian/Ubuntu**
 
-* postgresql
-* libpq-dev
-* python-psycopg2
+```
+# apt-get install -y postgresql libpq-dev python-psycopg2
+```
+
+**CentOS/RHEL**
+
+```
+# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
+# postgresql-setup initdb
+```
+
+If using CentOS, modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
+
+```
+host    all             all             127.0.0.1/32            md5
+host    all             all             ::1/128                 md5
+```
+
+Then, start the service:
 
 ```
-# sudo apt-get install -y postgresql libpq-dev python-psycopg2
+# systemctl start postgresql
 ```
 
-# Configuration
+# Database Creation
 
 At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
 

+ 12 - 10
docs/installation/web-server.md

@@ -1,9 +1,12 @@
 # Web Server Installation
 
-We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence. 
+We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence.
+
+!!! info
+    Only Debian/Ubuntu instructions are provided here, but the installation process for CentOS/RHEL does not differ much. Please consult the documentation for those distributions for details.
 
 ```
-# sudo apt-get install -y gunicorn supervisor
+# apt-get install -y gunicorn supervisor
 ```
 
 ## Option A: nginx
@@ -11,10 +14,10 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for
 The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
 
 ```
-# sudo apt-get install -y nginx
+# apt-get install -y nginx
 ```
 
-Once nginx is installed, proceed with the following configuration:
+Once nginx is installed, save the following configuration to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
 
 ```
 server {
@@ -38,19 +41,18 @@ server {
 }
 ```
 
-Save this configuration to `/etc/nginx/sites-available/netbox`. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
+Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
 
 ```
 # cd /etc/nginx/sites-enabled/
 # rm default
-# ln -s /etc/nginx/sites-available/netbox 
+# ln -s /etc/nginx/sites-available/netbox
 ```
 
 Restart the nginx service to use the new configuration.
 
 ```
 # service nginx restart
- * Restarting nginx nginx
 ```
 
 To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04).
@@ -58,7 +60,7 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
 ## Option B: Apache
 
 ```
-# sudo apt-get install -y apache2
+# apt-get install -y apache2
 ```
 
 Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):
@@ -99,7 +101,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https
 
 # gunicorn Installation
 
-Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed.
+Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL change the username from `www-data` to `nginx` or `apache`.
 
 ```
 command = '/usr/bin/gunicorn'
@@ -120,7 +122,7 @@ directory = /opt/netbox/netbox/
 user = www-data
 ```
 
-Finally, restart the supervisor service to detect and run the gunicorn service:
+Then, restart the supervisor service to detect and run the gunicorn service:
 
 ```
 # service supervisor restart

+ 10 - 1
netbox/dcim/api/serializers.py

@@ -5,6 +5,7 @@ from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
     DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
     PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site,
+    SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
 )
 from extras.api.serializers import CustomFieldSerializer
 from tenancy.api.serializers import TenantNestedSerializer
@@ -131,11 +132,19 @@ class ManufacturerNestedSerializer(ManufacturerSerializer):
 
 class DeviceTypeSerializer(serializers.ModelSerializer):
     manufacturer = ManufacturerNestedSerializer()
+    subdevice_role = serializers.SerializerMethodField()
 
     class Meta:
         model = DeviceType
         fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-                  'is_console_server', 'is_pdu', 'is_network_device']
+                  'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role']
+
+    def get_subdevice_role(self, obj):
+        return {
+            SUBDEVICE_ROLE_PARENT: 'parent',
+            SUBDEVICE_ROLE_CHILD: 'child',
+            None: None,
+        }[obj.subdevice_role]
 
 
 class DeviceTypeNestedSerializer(DeviceTypeSerializer):

+ 1 - 1
netbox/dcim/forms.py

@@ -593,7 +593,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug')
-    rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')),
+    rack_group_id = FilterChoiceField(queryset=RackGroup.objects.annotate(filter_count=Count('racks__devices')),
                                       label='Rack Group')
     role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug')
     tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',

+ 18 - 0
netbox/dcim/models.py

@@ -576,11 +576,29 @@ class DeviceType(models.Model):
     def __unicode__(self):
         return u'{} {}'.format(self.manufacturer, self.model)
 
+    def __init__(self, *args, **kwargs):
+        super(DeviceType, self).__init__(*args, **kwargs)
+
+        # Save a copy of u_height for validation in clean()
+        self._original_u_height = self.u_height
+
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
 
     def clean(self):
 
+        # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
+        # room to expand within their racks. This validation will impose a very high performance penalty when there are
+        # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
+        if self.pk is not None and self.u_height > self._original_u_height:
+            for d in Device.objects.filter(device_type=self, position__isnull=False):
+                face_required = None if self.is_full_depth else d.face
+                u_available = d.rack.get_available_units(u_height=self.u_height, rack_face=face_required,
+                                                         exclude=[d.pk])
+                if d.position not in u_available:
+                    raise ValidationError("Device {} in rack {} does not have sufficient space to accommodate a height "
+                                          "of {}U".format(d, d.rack, self.u_height))
+
         if not self.is_console_server and self.cs_port_templates.count():
             raise ValidationError("Must delete all console server port templates associated with this device before "
                                   "declassifying it as a console server.")

+ 33 - 30
netbox/dcim/tests/test_apis.py

@@ -2,6 +2,8 @@ import json
 from rest_framework import status
 from rest_framework.test import APITestCase
 
+from django.conf import settings
+
 
 class SiteTest(APITestCase):
 
@@ -57,7 +59,7 @@ class SiteTest(APITestCase):
         'embed_link',
     ]
 
-    def test_get_list(self, endpoint='/api/dcim/sites/'):
+    def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -67,7 +69,7 @@ class SiteTest(APITestCase):
                 sorted(self.standard_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/sites/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -76,7 +78,7 @@ class SiteTest(APITestCase):
             sorted(self.standard_fields),
         )
 
-    def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'):
+    def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -91,7 +93,7 @@ class SiteTest(APITestCase):
                 sorted(self.nested_fields),
             )
 
-    def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'):
+    def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -149,7 +151,7 @@ class RackTest(APITestCase):
         'rear_units'
     ]
 
-    def test_get_list(self, endpoint='/api/dcim/racks/'):
+    def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -163,7 +165,7 @@ class RackTest(APITestCase):
                 sorted(SiteTest.nested_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/racks/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -192,7 +194,7 @@ class ManufacturersTest(APITestCase):
 
     nested_fields = standard_fields
 
-    def test_get_list(self, endpoint='/api/dcim/manufacturers/'):
+    def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -202,7 +204,7 @@ class ManufacturersTest(APITestCase):
                 sorted(self.standard_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -227,6 +229,7 @@ class DeviceTypeTest(APITestCase):
         'is_console_server',
         'is_pdu',
         'is_network_device',
+        'subdevice_role',
     ]
 
     nested_fields = [
@@ -236,7 +239,7 @@ class DeviceTypeTest(APITestCase):
         'slug'
     ]
 
-    def test_get_list(self, endpoint='/api/dcim/device-types/'):
+    def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -246,7 +249,7 @@ class DeviceTypeTest(APITestCase):
                 sorted(self.standard_fields),
             )
 
-    def test_detail_list(self, endpoint='/api/dcim/device-types/1/'):
+    def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
         # TODO: details returns list view.
         # response = self.client.get(endpoint)
         # content = json.loads(response.content)
@@ -270,7 +273,7 @@ class DeviceRolesTest(APITestCase):
 
     nested_fields = ['id', 'name', 'slug']
 
-    def test_get_list(self, endpoint='/api/dcim/device-roles/'):
+    def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -280,7 +283,7 @@ class DeviceRolesTest(APITestCase):
                 sorted(self.standard_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -298,7 +301,7 @@ class PlatformsTest(APITestCase):
 
     nested_fields = ['id', 'name', 'slug']
 
-    def test_get_list(self, endpoint='/api/dcim/platforms/'):
+    def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -308,7 +311,7 @@ class PlatformsTest(APITestCase):
                 sorted(self.standard_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/platforms/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -346,7 +349,7 @@ class DeviceTest(APITestCase):
 
     nested_fields = ['id', 'name', 'display_name']
 
-    def test_get_list(self, endpoint='/api/dcim/devices/'):
+    def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -373,7 +376,7 @@ class DeviceTest(APITestCase):
                 sorted(RackTest.nested_fields),
             )
 
-    def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'):
+    def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)):
 
         flat_fields = [
             'asset_tag',
@@ -421,7 +424,7 @@ class DeviceTest(APITestCase):
             sorted(flat_fields),
         )
 
-    def test_get_detail(self, endpoint='/api/dcim/devices/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -439,7 +442,7 @@ class ConsoleServerPortsTest(APITestCase):
 
     nested_fields = ['id', 'device', 'name']
 
-    def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'):
+    def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -461,7 +464,7 @@ class ConsolePortsTest(APITestCase):
 
     nested_fields = ['id', 'device', 'name']
 
-    def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'):
+    def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -479,7 +482,7 @@ class ConsolePortsTest(APITestCase):
                 sorted(ConsoleServerPortsTest.nested_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -500,7 +503,7 @@ class PowerPortsTest(APITestCase):
 
     nested_fields = ['id', 'device', 'name']
 
-    def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'):
+    def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -514,7 +517,7 @@ class PowerPortsTest(APITestCase):
                 sorted(DeviceTest.nested_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -535,7 +538,7 @@ class PowerOutletsTest(APITestCase):
 
     nested_fields = ['id', 'device', 'name']
 
-    def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'):
+    def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -585,7 +588,7 @@ class InterfaceTest(APITestCase):
         'connection_status',
     ]
 
-    def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'):
+    def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -599,7 +602,7 @@ class InterfaceTest(APITestCase):
                 sorted(DeviceTest.nested_fields),
             )
 
-    def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'):
+    def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -612,7 +615,7 @@ class InterfaceTest(APITestCase):
             sorted(DeviceTest.nested_fields),
         )
 
-    def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'):
+    def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
             response = self.client.get(endpoint)
             content = json.loads(response.content)
             self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -622,7 +625,8 @@ class InterfaceTest(APITestCase):
                     sorted(SiteTest.graph_fields),
                 )
 
-    def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'):
+    def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
+                                       .format(settings.BASE_PATH)):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -643,9 +647,8 @@ class RelatedConnectionsTest(APITestCase):
         'interfaces',
     ]
 
-    def test_get_list(self, endpoint=(
-            '/api/dcim/related-connections/'
-            '?peer-device=test1-edge1&peer-interface=xe-0/0/3')):
+    def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
+                                      .format(settings.BASE_PATH))):
         response = self.client.get(endpoint)
         content = json.loads(response.content)
         self.assertEqual(response.status_code, status.HTTP_200_OK)

+ 1 - 1
netbox/extras/admin.py

@@ -40,7 +40,7 @@ class GraphAdmin(admin.ModelAdmin):
 
 @admin.register(ExportTemplate)
 class ExportTemplateAdmin(admin.ModelAdmin):
-    list_display = ['content_type', 'name', 'mime_type', 'file_extension']
+    list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
 
 
 @admin.register(TopologyMap)

+ 3 - 2
netbox/extras/forms.py

@@ -3,6 +3,7 @@ from collections import OrderedDict
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 
+from utilities.forms import LaxURLField
 from .models import (
     CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
 )
@@ -56,7 +57,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
 
         # URL
         elif cf.type == CF_TYPE_URL:
-            field = forms.URLField(required=cf.required, initial=cf.default)
+            field = LaxURLField(required=cf.required, initial=cf.default)
 
         # Text
         else:
@@ -92,7 +93,7 @@ class CustomFieldForm(forms.ModelForm):
             existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
                 .select_related('field')
             for cfv in existing_values:
-                self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.value
+                self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
 
     def _save_custom_fields(self):
 

+ 25 - 0
netbox/extras/migrations/0003_exporttemplate_add_description.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-09-27 20:20
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0002_custom_fields'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='exporttemplate',
+            name='description',
+            field=models.CharField(blank=True, max_length=200),
+        ),
+        migrations.AlterField(
+            model_name='exporttemplate',
+            name='name',
+            field=models.CharField(max_length=100),
+        ),
+    ]

+ 12 - 3
netbox/extras/models.py

@@ -146,8 +146,10 @@ class CustomField(models.Model):
             # Read date as YYYY-MM-DD
             return date(*[int(n) for n in serialized_value.split('-')])
         if self.type == CF_TYPE_SELECT:
-            # return CustomFieldChoice.objects.get(pk=int(serialized_value))
-            return self.choices.get(pk=int(serialized_value))
+            try:
+                return self.choices.get(pk=int(serialized_value))
+            except CustomFieldChoice.DoesNotExist:
+                return None
         return serialized_value
 
 
@@ -198,6 +200,12 @@ class CustomFieldChoice(models.Model):
         if self.field.type != CF_TYPE_SELECT:
             raise ValidationError("Custom field choices can only be assigned to selection fields.")
 
+    def delete(self, using=None, keep_parents=False):
+        # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
+        pk = self.pk
+        super(CustomFieldChoice, self).delete(using, keep_parents)
+        CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
+
 
 class Graph(models.Model):
     type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
@@ -225,7 +233,8 @@ class Graph(models.Model):
 
 class ExportTemplate(models.Model):
     content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
-    name = models.CharField(max_length=200)
+    name = models.CharField(max_length=100)
+    description = models.CharField(max_length=200, blank=True)
     template_code = models.TextField()
     mime_type = models.CharField(max_length=15, blank=True)
     file_extension = models.CharField(max_length=15, blank=True)

+ 4 - 0
netbox/netbox/configuration.docker.py

@@ -52,6 +52,10 @@ EMAIL = {
 # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False)
 
+# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
+# BASE_PATH = 'netbox/'
+BASE_PATH = os.environ.get('BASE_PATH', '')
+
 # Setting this to True will display a "maintenance mode" banner at the top of every page.
 MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False)
 

+ 4 - 0
netbox/netbox/configuration.example.py

@@ -52,6 +52,10 @@ EMAIL = {
 # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 LOGIN_REQUIRED = False
 
+# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
+# BASE_PATH = 'netbox/'
+BASE_PATH = ''
+
 # Setting this to True will display a "maintenance mode" banner at the top of every page.
 MAINTENANCE_MODE = False
 

+ 4 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
 
 
-VERSION = '1.6.1-r1'
+VERSION = '1.6.2'
 
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -27,6 +27,9 @@ ADMINS = getattr(configuration, 'ADMINS', [])
 DEBUG = getattr(configuration, 'DEBUG', False)
 EMAIL = getattr(configuration, 'EMAIL', {})
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
+BASE_PATH = getattr(configuration, 'BASE_PATH', '')
+if BASE_PATH:
+    BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
 MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '')

+ 7 - 1
netbox/netbox/urls.py

@@ -1,3 +1,4 @@
+from django.conf import settings
 from django.conf.urls import include, url
 from django.contrib import admin
 from django.views.defaults import page_not_found
@@ -8,7 +9,7 @@ from users.views import login, logout
 
 handler500 = handle_500
 
-urlpatterns = [
+_patterns = [
 
     # Default page
     url(r'^$', home, name='home'),
@@ -42,3 +43,8 @@ urlpatterns = [
     url(r'^admin/', include(admin.site.urls)),
 
 ]
+
+# Prepend BASE_PATH
+urlpatterns = [
+    url(r'^{}'.format(settings.BASE_PATH), include(_patterns))
+]

+ 10 - 5
netbox/project-static/js/forms.js

@@ -1,13 +1,18 @@
 $(document).ready(function() {
 
-    // "Select all" checkbox in a table header
-    $('th input:checkbox[name=_all]').click(function (event) {
-        $(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked'));
+    // "Toggle all" checkbox in a table header
+    $('#toggle_all').click(function (event) {
+        $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
+        if ($(this).is(':checked')) {
+            $('#select_all_box').removeClass('hidden');
+        } else {
+            $('#select_all').prop('checked', false);
+        }
     });
-    // Uncheck the "select all" checkbox if an item is unchecked
+    // Uncheck the "toggle all" checkbox if an item is unchecked
     $('input:checkbox[name=pk]').click(function (event) {
         if (!$(this).attr('checked')) {
-            $(this).parents('table').find('input:checkbox[name=_all]').prop('checked', false);
+            $('#select_all, #toggle_all').prop('checked', false);
         }
     });
 

+ 1 - 1
netbox/secrets/forms.py

@@ -91,7 +91,7 @@ class SecretImportForm(BulkImportForm, BootstrapMixin):
 
 class SecretBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
-    role = forms.ModelChoiceField(queryset=SecretRole.objects.all())
+    role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)
     name = forms.CharField(max_length=100, required=False)
 
 

+ 1 - 1
netbox/templates/500.html

@@ -26,7 +26,7 @@
                     <pre><strong>{{ exception }}</strong><br />
 {{ error }}</pre>
                     <div class="text-right">
-                        <a href="/" class="btn btn-primary">Home Page</a>
+                        <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
                     </div>
                 </div>
             </div>

+ 3 - 2
netbox/templates/_base.html

@@ -9,6 +9,7 @@
     <link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
 	<link rel="stylesheet" href="{% static 'css/base.css' %}">
     <link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
+    <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
 </head>
 <body>
     <nav class="navbar navbar-default navbar-fixed-top">
@@ -20,7 +21,7 @@
                     <span class="icon-bar"></span>
                     <span class="icon-bar"></span>
                 </button>
-                <a class="navbar-brand" href="/">
+                <a class="navbar-brand" href="{% url 'home' %}">
                     <img src="{% static 'img/netbox_logo.png' %}" />
                 </a>
             </div>
@@ -288,7 +289,7 @@
                 <div class="col-xs-4 text-right">
                     <p class="text-muted">
                         <i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
-                        <i class="fa fa-fw fa-cloud text-primary"></i> <a href="/api/docs/">API</a> &middot;
+                        <i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
                         <i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a>
                     </p>
                 </div>

+ 10 - 3
netbox/templates/circuits/circuit_bulk_edit.html

@@ -3,14 +3,21 @@
 
 {% block title %}Circuit Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Circuit</th>
+        <th>Type</th>
+        <th>Provider</th>
+        <th>Port speed</th>
+        <th>Commit rate</th>
+    </tr>
     {% for circuit in selected_objects %}
         <tr>
             <td><a href="{% url 'circuits:circuit' pk=circuit.pk %}">{{ circuit }}</a></td>
             <td>{{ circuit.type }}</td>
             <td>{{ circuit.provider }}</td>
-            <td>{{ circuit.port_speed }} Kbps</td>
-            <td>{{ circuit.commit_rate }}</td>
+            <td>{{ circuit.port_speed_human }}</td>
+            <td>{{ circuit.commit_rate_human }}</td>
         </tr>
     {% endfor %}
 {% endblock %}

+ 6 - 1
netbox/templates/circuits/provider_bulk_edit.html

@@ -3,7 +3,12 @@
 
 {% block title %}Provider Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Provider</th>
+        <th>Account</th>
+        <th>ASN</th>
+    </tr>
     {% for provider in selected_objects %}
         <tr>
             <td><a href="{% url 'circuits:provider' slug=provider.slug %}">{{ provider }}</a></td>

+ 98 - 84
netbox/templates/dcim/device.html

@@ -186,18 +186,23 @@
                         {% include 'dcim/inc/_ipaddress.html' %}
                     {% endfor %}
                 </table>
-            {% else %}
+            {% elif interfaces or mgmt_interfaces %}
                 <div class="panel-body text-muted">
-                    None found
+                    None assigned
+                </div>
+            {% else %}
+                <div class="panel-body">
+                    <a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
                 </div>
             {% endif %}
             {% if perms.ipam.add_ipaddress %}
-                <div class="panel-footer text-right">
-                    <a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
-                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                        Assign IP address
-                    </a>
-                </div>
+                {% if interfaces or mgmt_interfaces %}
+                    <div class="panel-footer text-right">
+                        <a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
+                        </a>
+                    </div>
+                {% endif %}
             {% endif %}
         </div>
         <div class="panel panel-default">
@@ -210,7 +215,7 @@
                 {% empty %}
                     <tr>
                         <td colspan="5" class="alert-warning">
-                            <i class="fa fa-fw fa-warning"></i> No management interfaces defined!
+                            <i class="fa fa-fw fa-warning"></i> No management interfaces defined
                             {% if perms.dcim.add_interface %}
                                 <a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
                             {% endif %}
@@ -222,7 +227,7 @@
                 {% empty %}
                     <tr>
                         <td colspan="5" class="alert-warning">
-                            <i class="fa fa-fw fa-warning"></i> No console ports defined!
+                            <i class="fa fa-fw fa-warning"></i> No console ports defined
                             {% if perms.dcim.add_consoleport %}
                                 <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
                             {% endif %}
@@ -235,7 +240,7 @@
                     {% if not device.device_type.is_pdu %}
                         <tr>
                             <td colspan="5" class="alert-warning">
-                                <i class="fa fa-fw fa-warning"></i> No power ports defined!
+                                <i class="fa fa-fw fa-warning"></i> No power ports defined
                                 {% if perms.dcim.add_powerport %}
                                     <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
                                 {% endif %}
@@ -248,20 +253,17 @@
                 <div class="panel-footer text-right">
                     {% if perms.dcim.add_interface %}
                         <a href="{% url 'dcim:interface_add' pk=device.pk %}?mgmt_only=1" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                            Add interface
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interface
                         </a>
                     {% endif %}
                     {% if perms.dcim.add_consoleport %}
                         <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                            Add console
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                         </a>
                     {% endif %}
                     {% if perms.dcim.add_powerport and not device.device_type.is_pdu %}
                         <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
-                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                            Add power
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
                         </a>
                     {% endif %}
                 </div>
@@ -312,6 +314,13 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Device Bays</strong>
+                    {% if perms.dcim.add_devicebay and device_bays|length > 10 %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
+                            </a>
+                        </div>
+                    {% endif %}
                 </div>
                 <table class="table table-hover panel-body">
                     {% for devicebay in device_bays %}
@@ -324,23 +333,19 @@
                 </table>
                 {% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
                     <div class="panel-footer">
-                        <div class="row">
-                            <div class="col-md-6">
-                                {% if device_bays and perms.dcim.delete_devicebay %}
-                                    <button type="submit" class="btn btn-xs btn-danger">
-                                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
-                                    </button>
-                                {% endif %}
-                            </div>
-                            <div class="col-md-6 text-right">
-                                {% if perms.dcim.add_devicebay %}
-                                    <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                                        Add device bay
-                                    </a>
-                                {% endif %}
+                        {% if device_bays and perms.dcim.delete_devicebay %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_devicebay %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
+                                </a>
                             </div>
-                        </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
                      </div>
                 {% endif %}
             </div>
@@ -356,6 +361,13 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Interfaces</strong>
+                    {% if perms.dcim.add_interface and interfaces|length > 10 %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
+                            </a>
+                        </div>
+                    {% endif %}
                 </div>
                 <table class="table table-hover panel-body">
                     {% for iface in interfaces %}
@@ -368,23 +380,19 @@
                 </table>
                 {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
                     <div class="panel-footer">
-                        <div class="row">
-                            <div class="col-md-6">
-                                {% if interfaces and perms.dcim.delete_interface %}
-                                    <button type="submit" class="btn btn-xs btn-danger">
-                                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
-                                    </button>
-                                {% endif %}
-                            </div>
-                            <div class="col-md-6 text-right">
-                                {% if perms.dcim.add_interface %}
-                                    <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                                        Add interface
-                                    </a>
-                                {% endif %}
+                        {% if interfaces and perms.dcim.delete_interface %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_interface %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
+                                </a>
                             </div>
-                        </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
                      </div>
                 {% endif %}
             </div>
@@ -400,6 +408,13 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Console Server Ports</strong>
+                    {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
+                            </a>
+                        </div>
+                    {% endif %}
                 </div>
                 <table class="table table-hover panel-body">
                     {% for csp in cs_ports %}
@@ -412,23 +427,19 @@
                 </table>
                 {% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
                     <div class="panel-footer">
-                        <div class="row">
-                            <div class="col-md-6">
-                                {% if cs_ports and perms.dcim.delete_consoleserverport %}
-                                    <button type="submit" class="btn btn-xs btn-danger">
-                                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
-                                    </button>
-                                {% endif %}
-                            </div>
-                            <div class="col-md-6 text-right">
-                                {% if perms.dcim.add_consoleserverport %}
-                                    <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                                        Add console server ports
-                                    </a>
-                                {% endif %}
+                        {% if cs_ports and perms.dcim.delete_consoleserverport %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_consoleserverport %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
+                                </a>
                             </div>
-                        </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
                     </div>
                 {% endif %}
             </div>
@@ -444,6 +455,13 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Power Outlets</strong>
+                    {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
+                        <div class="pull-right">
+                            <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
+                            </a>
+                        </div>
+                    {% endif %}
                 </div>
                 <table class="table table-hover panel-body">
                     {% for po in power_outlets %}
@@ -456,23 +474,19 @@
                 </table>
                 {% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
                     <div class="panel-footer">
-                        <div class="row">
-                            <div class="col-md-6">
-                                {% if power_outlets and perms.dcim.delete_poweroutlet %}
-                                    <button type="submit" class="btn btn-xs btn-danger">
-                                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
-                                    </button>
-                                {% endif %}
-                            </div>
-                            <div class="col-md-6 text-right">
-                                {% if perms.dcim.add_poweroutlet %}
-                                    <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
-                                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                                        Add power outlets
-                                    </a>
-                                {% endif %}
+                        {% if power_outlets and perms.dcim.delete_poweroutlet %}
+                            <button type="submit" class="btn btn-danger btn-xs">
+                                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
+                            </button>
+                        {% endif %}
+                        {% if perms.dcim.add_poweroutlet %}
+                            <div class="pull-right">
+                                <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
+                                </a>
                             </div>
-                        </div>
+                            <div class="clearfix"></div>
+                        {% endif %}
                     </div>
                 {% endif %}
             </div>
@@ -531,13 +545,13 @@ function toggleConnection(elem, api_url) {
     return false;
 }
 $(".consoleport-toggle").click(function() {
-    return toggleConnection($(this), "/api/dcim/console-ports/");
+    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/console-ports/");
 });
 $(".powerport-toggle").click(function() {
-    return toggleConnection($(this), "/api/dcim/power-ports/");
+    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/power-ports/");
 });
 $(".interface-toggle").click(function() {
-    return toggleConnection($(this), "/api/dcim/interface-connections/");
+    return toggleConnection($(this), "/{{ settings.BASE_PATH }}api/dcim/interface-connections/");
 });
 </script>
 <script src="{% static 'js/graphs.js' %}"></script>

+ 8 - 1
netbox/templates/dcim/device_bulk_edit.html

@@ -3,7 +3,14 @@
 
 {% block title %}Device Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Device</th>
+        <th>Type</th>
+        <th>Role</th>
+        <th>Tenant</th>
+        <th>Serial</th>
+    </tr>
     {% for device in selected_objects %}
         <tr>
             <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>

+ 7 - 3
netbox/templates/dcim/devicetype_bulk_edit.html

@@ -3,11 +3,15 @@
 
 {% block title %}Device Type Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Device type</th>
+        <th>Manufacturer</th>
+        <th>Height</th>
+    </tr>
     {% for devicetype in selected_objects %}
         <tr>
-            <td><a href="{% url 'dcim:devicetype' pk=devicetype.pk %}">{{ devicetype }}</a></td>
-            <td>{{ devicetype.model }}</td>
+            <td><a href="{% url 'dcim:devicetype' pk=devicetype.pk %}">{{ devicetype.model }}</a></td>
             <td>{{ devicetype.manufacturer }}</td>
             <td>{{ devicetype.u_height }}U</td>
         </tr>

+ 9 - 30
netbox/templates/dcim/inc/device_table.html

@@ -1,30 +1,9 @@
-{% load render_table from django_tables2 %}
-{% load helpers %}
-{% if table.model|user_can_change:request.user or table.model|user_can_delete:request.user %}
-    <form method="post" class="form form-horizontal">
-        {% csrf_token %}
-        <input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
-        <input type="hidden" name="pk_all" value="{% for row in table.rows %}{{ row.record.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
-        {% render_table table table_template|default:'table.html' %}
-        {% if perms.dcim.add_interface %}
-            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_add_multi' %}" class="btn btn-primary btn-sm">
-                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                Add Interfaces
-            </button>
-        {% endif %}
-        {% if bulk_edit_url and table.model|user_can_change:request.user %}
-            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}" class="btn btn-warning btn-sm">
-                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
-                Edit Selected
-            </button>
-        {% endif %}
-        {% if bulk_delete_url and table.model|user_can_delete:request.user %}
-            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}" class="btn btn-danger btn-sm">
-                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
-                Delete Selected
-            </button>
-        {% endif %}
-    </form>
-{% else %}
-    {% render_table table table_template|default:'table.html' %}
-{% endif %}
+{% extends 'utilities/obj_table.html' %}
+
+{% block extra_actions %}
+    {% if perms.dcim.add_interface %}
+        <button type="submit" name="_edit" formaction="{% url 'dcim:interface_add_multi' %}" class="btn btn-primary btn-sm">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Interfaces
+        </button>
+    {% endif %}
+{% endblock %}

+ 14 - 4
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -4,11 +4,15 @@
         {% csrf_token %}
         <div class="panel panel-default">
             <div class="panel-heading">
-                <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs pull-right">
-                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
-                    Add {{ title }}
-                </a>
                 <strong>{{ title }}</strong>
+                {% if table.rows|length > 10 %}
+                    <div class="pull-right">
+                        <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                            Add {{ title }}
+                        </a>
+                    </div>
+                {% endif %}
             </div>
             {% render_table table 'table.html' %}
             {% if table.rows %}
@@ -16,6 +20,12 @@
                     <button type="submit" class="btn btn-xs btn-danger">
                         <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
                     </button>
+                    <div class="pull-right">
+                        <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                            Add {{ title }}
+                        </a>
+                    </div>
                 </div>
             {% endif %}
         </div>

+ 6 - 1
netbox/templates/dcim/interface_add_multi.html

@@ -7,7 +7,12 @@
 
 {% block form_title %}Interface(s) to Add{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Device</th>
+        <th>Type</th>
+        <th>Role</th>
+    </tr>
     {% for device in selected_objects %}
         <tr>
             <td><a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a></td>

+ 3 - 1
netbox/templates/dcim/rack_bulk_edit.html

@@ -3,12 +3,13 @@
 
 {% block title %}Rack Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
     <tr>
         <th>Name</th>
         <th>Site</th>
         <th>Group</th>
         <th>Tenant</th>
+        <th>Role</th>
         <th>Type</th>
         <th>Width</th>
         <th>Height</th>
@@ -19,6 +20,7 @@
             <td>{{ rack.site }}</td>
             <td>{{ rack.group }}</td>
             <td>{{ rack.tenant }}</td>
+            <td>{{ rack.role }}</td>
             <td>{{ rack.get_type_display }}</td>
             <td>{{ rack.get_width_display }}</td>
             <td>{{ rack.u_height }}U</td>

+ 5 - 1
netbox/templates/dcim/site_bulk_edit.html

@@ -3,7 +3,11 @@
 
 {% block title %}Site Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Site</th>
+        <th>Tenant</th>
+    </tr>
     {% for site in selected_objects %}
         <tr>
             <td><a href="{% url 'dcim:site' slug=site.slug %}">{{ site }}</a></td>

+ 1 - 1
netbox/templates/inc/export_button.html

@@ -8,7 +8,7 @@
             <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 }}">{{ et.name }}</a></li>
+                <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>

+ 7 - 1
netbox/templates/ipam/aggregate_bulk_edit.html

@@ -3,7 +3,13 @@
 
 {% block title %}Aggregate Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Aggregate</th>
+        <th>RIR</th>
+        <th>Date Added</th>
+        <th>Description</th>
+    </tr>
     {% for aggregate in selected_objects %}
         <tr>
             <td><a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate }}</a></td>

+ 9 - 3
netbox/templates/ipam/ipaddress_bulk_edit.html

@@ -3,14 +3,20 @@
 
 {% block title %}IP Address Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>IP Address</th>
+        <th>VRF</th>
+        <th>Tenant</th>
+        <th>Assigned</th>
+        <th>Description</th>
+    </tr>
     {% for ipaddress in selected_objects %}
         <tr>
             <td><a href="{% url 'ipam:ipaddress' pk=ipaddress.pk %}">{{ ipaddress }}</a></td>
             <td>{{ ipaddress.vrf|default:"Global" }}</td>
             <td>{{ ipaddress.tenant }}</td>
-            <td>{{ ipaddress.interface.device }}</td>
-            <td>{{ ipaddress.interface }}</td>
+            <td>{% if ipaddress.interface %}<i class="glyphicon glyphicon-ok text-success" title="{{ ipaddress.interface.device }} {{ ipaddress.interface }}"></i>{% endif %}</td>
             <td>{{ ipaddress.description }}</td>
         </tr>
     {% endfor %}

+ 11 - 4
netbox/templates/ipam/prefix_bulk_edit.html

@@ -3,16 +3,23 @@
 
 {% block title %}Prefix Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Prefix</th>
+        <th>Site</th>
+        <th>VRF</th>
+        <th>Tenant</th>
+        <th>Status</th>
+        <th>Role</th>
+    </tr>
     {% for prefix in selected_objects %}
         <tr>
             <td><a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a></td>
+            <td>{{ prefix.site }}</td>
             <td>{{ prefix.vrf|default:"Global" }}</td>
             <td>{{ prefix.tenant }}</td>
-            <td>{{ prefix.site }}</td>
-            <td>{{ prefix.status }}</td>
+            <td>{{ prefix.get_status_display }}</td>
             <td>{{ prefix.role }}</td>
-            <td>{{ prefix.description }}</td>
         </tr>
     {% endfor %}
 {% endblock %}

+ 11 - 4
netbox/templates/ipam/vlan_bulk_edit.html

@@ -3,16 +3,23 @@
 
 {% block title %}VLAN Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>VLAN</th>
+        <th>Site</th>
+        <th>Group</th>
+        <th>Tenant</th>
+        <th>Status</th>
+        <th>Role</th>
+    </tr>
     {% for vlan in selected_objects %}
         <tr>
-            <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan.vid }}</a></td>
-            <td>{{ vlan.name }}</td>
+            <td><a href="{% url 'ipam:vlan' pk=vlan.pk %}">{{ vlan }}</a></td>
             <td>{{ vlan.site }}</td>
+            <td>{{ vlan.group }}</td>
             <td>{{ vlan.tenant }}</td>
             <td>{{ vlan.get_status_display }}</td>
             <td>{{ vlan.role }}</td>
-            <td>{{ vlan.description }}</td>
         </tr>
     {% endfor %}
 {% endblock %}

+ 7 - 1
netbox/templates/ipam/vrf_bulk_edit.html

@@ -3,7 +3,13 @@
 
 {% block title %}VRF Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>VRF</th>
+        <th>RD</th>
+        <th>Tenant</th>
+        <th>Description</th>
+    </tr>
     {% for vrf in selected_objects %}
         <tr>
             <td><a href="{% url 'ipam:vrf' pk=vrf.pk %}">{{ vrf.name }}</a></td>

+ 7 - 3
netbox/templates/secrets/secret_bulk_edit.html

@@ -3,11 +3,15 @@
 
 {% block title %}Secret Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Device</th>
+        <th>Role</th>
+        <th>Name</th>
+    </tr>
     {% for secret in selected_objects %}
         <tr>
-            <td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret }}</a></td>
-            <td>{{ secret.device }}</td>
+            <td><a href="{% url 'secrets:secret' pk=secret.pk %}">{{ secret.device }}</a></td>
             <td>{{ secret.role }}</td>
             <td>{{ secret.name }}</td>
         </tr>

+ 5 - 1
netbox/templates/tenancy/tenant_bulk_edit.html

@@ -3,7 +3,11 @@
 
 {% block title %}Tenant Bulk Edit{% endblock %}
 
-{% block select_objects_table %}
+{% block selected_objects_table %}
+    <tr>
+        <th>Tenant</th>
+        <th>Group</th>
+    </tr>
     {% for tenant in selected_objects %}
         <tr>
             <td><a href="{% url 'tenancy:tenant' slug=tenant.slug %}">{{ tenant }}</a></td>

+ 2 - 2
netbox/templates/utilities/bulk_edit_form.html

@@ -11,9 +11,9 @@
     <div class="row">
         <div class="col-md-7">
             <div class="panel panel-default">
-                <div class="panel-heading"><strong>{% block selected_objects_title %}Selected For Editing{% endblock %}</strong></div>
+                <div class="panel-heading"><strong>{% block selected_objects_title %}{{ selected_objects|length }} Selected For Editing{% endblock %}</strong></div>
                 <table class="panel-body table table-hover">
-                    {% block select_objects_table %}{% endblock %}
+                    {% block selected_objects_table %}{% endblock %}
                 </table>
             </div>
         </div>

+ 13 - 4
netbox/templates/utilities/obj_table.html

@@ -5,17 +5,26 @@
         {% csrf_token %}
         <input type="hidden" name="redirect_url" value="{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" />
         <input type="hidden" name="pk_all" value="{% for row in table.rows %}{{ row.record.pk|default:'' }}{% if not forloop.last %},{% endif %}{% endfor %}" />
+        {% if table.paginator.num_pages > 1 %}
+            <div id="select_all_box" class="hidden alert alert-info">
+                <div class="checkbox-inline">
+                    <label for="select_all">
+                        <input type="checkbox" id="select_all" name="_all" />
+                        Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
+                    </label>
+                </div>
+            </div>
+        {% endif %}
         {% render_table table table_template|default:'table.html' %}
+        {% block extra_actions %}{% endblock %}
         {% if bulk_edit_url and table.model|user_can_change:request.user %}
             <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}" class="btn btn-warning btn-sm">
-                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
-                Edit Selected
+                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
             </button>
         {% endif %}
         {% if bulk_delete_url and table.model|user_can_delete:request.user %}
             <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}" class="btn btn-danger btn-sm">
-                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
-                Delete Selected
+                <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
             </button>
         {% endif %}
     </form>

+ 18 - 1
netbox/utilities/forms.py

@@ -3,7 +3,9 @@ import itertools
 import re
 
 from django import forms
+from django.conf import settings
 from django.core.urlresolvers import reverse_lazy
+from django.core.validators import URLValidator
 from django.utils.encoding import force_text
 from django.utils.html import format_html
 from django.utils.safestring import mark_safe
@@ -90,7 +92,7 @@ class APISelect(SelectWithDisabled):
         super(APISelect, self).__init__(*args, **kwargs)
 
         self.attrs['class'] = 'api-select'
-        self.attrs['api-url'] = api_url
+        self.attrs['api-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/'))  # Inject BASE_PATH
         if display_field:
             self.attrs['display-field'] = display_field
         if disabled_indicator:
@@ -253,6 +255,21 @@ class FilterChoiceField(forms.ModelMultipleChoiceField):
     choices = property(_get_choices, forms.ChoiceField._set_choices)
 
 
+class LaxURLField(forms.URLField):
+    """
+    Custom URLField which allows any valid URL scheme
+    """
+
+    class AnyURLScheme(object):
+        # A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
+        def __contains__(self, item):
+            if not item or not re.match('^[a-z][0-9a-z+\-.]*$', item.lower()):
+                return False
+            return True
+
+    default_validators = [URLValidator(schemes=AnyURLScheme())]
+
+
 #
 # Forms
 #

+ 1 - 1
netbox/utilities/tables.py

@@ -27,4 +27,4 @@ class ToggleColumn(tables.CheckBoxColumn):
 
     @property
     def header(self):
-        return mark_safe('<input type="checkbox" name="_all" title="Select all" />')
+        return mark_safe('<input type="checkbox" id="toggle_all" title="Toggle all" />')