Browse Source

Merge branch 'develop' of github.com:digitalocean/netbox into develop-2.2

Jeremy Stretch 7 years ago
parent
commit
0d9ff907a8

+ 10 - 2
docs/api/examples.md

@@ -4,7 +4,8 @@ Supported HTTP methods:
 
 * `GET`: Retrieve an object or list of objects
 * `POST`: Create a new object
-* `PUT`: Update an existing object
+* `PUT`: Update an existing object, all mandatory fields must be specified
+* `PATCH`: Updates an existing object, only specifiying the field to be changed
 * `DELETE`: Delete an existing object
 
 To authenticate a request, attach your token in an `Authorization` header:
@@ -104,12 +105,19 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
 
 ### Modify an existing site
 
-Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included.
+Make an authenticated `PUT` request to the site detail endpoint. As with a create (`POST`) request, all mandatory fields must be included.
 
 ```
 $ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
 ```
 
+### Modify an object by changing a field
+
+Make an authenticated `PATCH` request to the device endpoint. With `PATCH`, unlike `POST` and `PUT`, we only specify the field that is being changed. In this example, we add a serial number to a device.
+```
+$ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/2549/ --data '{"serial": "FTX1123A090"}'
+```
+
 ### Delete an existing site
 
 Send an authenticated `DELETE` request to the site detail endpoint.

+ 1 - 1
docs/api/overview.md

@@ -6,7 +6,7 @@ REST stands for [representational state transfer](https://en.wikipedia.org/wiki/
 
 * `GET`: Retrieve an object or list of objects
 * `POST`: Create an object
-* `PUT` / `PATCH`: Modify an existing object
+* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
 * `DELETE`: Delete an existing object
 
 The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)

+ 7 - 1
docs/configuration/optional-settings.md

@@ -17,7 +17,7 @@ ADMINS = [
 
 ## BANNER_BOTTOM
 
-Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. To replicate the content of the top banner in the bottom banner, set:
+Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
 
 ```
 BANNER_TOP = 'Your banner text'
@@ -26,6 +26,12 @@ BANNER_BOTTOM = BANNER_TOP
 
 ---
 
+## BANNER_LOGIN
+
+The value of this variable will be displayed on the login page above the login form. HTML is allowed.
+
+---
+
 ## BASE_PATH
 
 Default: None

+ 2 - 0
docs/installation/ldap.md

@@ -78,6 +78,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
 ```
 
 # User Groups for Permissions
+!!! Info
+    When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for AUTH_LDAP_GROUP_TYPE.
 
 ```python
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

+ 2 - 2
docs/installation/netbox.md

@@ -20,7 +20,7 @@ Python 3:
 
 ```no-highlight
 # yum install -y epel-release
-# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
+# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
 # easy_install-3.4 pip
 # ln -s -f python3.4 /usr/bin/python
 ```
@@ -29,7 +29,7 @@ Python 2:
 
 ```no-highlight
 # yum install -y epel-release
-# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
+# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
 ```
 
 You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.

+ 36 - 16
netbox/dcim/models.py

@@ -13,6 +13,7 @@ from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
+from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 
@@ -642,15 +643,16 @@ class InterfaceQuerySet(models.QuerySet):
         To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
         slot, subslot, position, channel, and virtual circuit:
 
-            {type}{slot}/{subslot}/{position}:{channel}.{vc}
+            {type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
 
-        Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
-        be parsed as follows:
+        Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
+        would be parsed as follows:
 
             name = 'GigabitEthernet'
-            slot =  None
-            subslot = 0
-            position = 1
+            slot =  1
+            subslot = 2
+            position = 3
+            subposition = 0
             channel = None
             vc = 0
 
@@ -659,17 +661,35 @@ class InterfaceQuerySet(models.QuerySet):
         """
         sql_col = '{}.name'.format(self.model._meta.db_table)
         ordering = {
-            IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
-            IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
+            IFACE_ORDERING_POSITION: (
+                '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', '_id', 'name',
+            ),
+            IFACE_ORDERING_NAME: (
+                '_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
+            ),
         }[method]
-        return self.extra(select={
-            '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
-            '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
-            '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
-            '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
-            '_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
-            '_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
-        }).order_by(*ordering)
+
+        TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
+        ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
+        SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)"
+        SUBSLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer)"
+        POSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer)"
+        SUBPOSITION_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer)"
+        CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
+        VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
+
+        fields = {
+            '_type': RawSQL(TYPE_RE.format(sql_col), []),
+            '_id': RawSQL(ID_RE.format(sql_col), []),
+            '_slot': RawSQL(SLOT_RE.format(sql_col), []),
+            '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
+            '_position': RawSQL(POSITION_RE.format(sql_col), []),
+            '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
+            '_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
+            '_vc': RawSQL(VC_RE.format(sql_col), []),
+        }
+
+        return self.annotate(**fields).order_by(*ordering)
 
     def connectable(self):
         """

+ 109 - 0
netbox/dcim/tests/test_models.py

@@ -98,3 +98,112 @@ class RackTestCase(TestCase):
             face=None,
         )
         self.assertTrue(pdu)
+
+
+class InterfaceTestCase(TestCase):
+
+    def setUp(self):
+
+        self.site = Site.objects.create(
+            name='TestSite1',
+            slug='my-test-site'
+        )
+        self.rack = Rack.objects.create(
+            name='TestRack1',
+            facility_id='A101',
+            site=self.site,
+            u_height=42
+        )
+        self.manufacturer = Manufacturer.objects.create(
+            name='Acme',
+            slug='acme'
+        )
+
+        self.device_type = DeviceType.objects.create(
+            manufacturer=self.manufacturer,
+            model='FrameForwarder 2048',
+            slug='ff2048'
+        )
+        self.role = DeviceRole.objects.create(
+            name='Switch',
+            slug='switch',
+        )
+
+    def test_interface_order_natural(self):
+        device1 = Device.objects.create(
+            name='TestSwitch1',
+            device_type=self.device_type,
+            device_role=self.role,
+            site=self.site,
+            rack=self.rack,
+            position=10,
+            face=RACK_FACE_REAR,
+        )
+        interface1 = Interface.objects.create(
+            device=device1,
+            name='Ethernet1/3/1'
+        )
+        interface2 = Interface.objects.create(
+            device=device1,
+            name='Ethernet1/5/1'
+        )
+        interface3 = Interface.objects.create(
+            device=device1,
+            name='Ethernet1/4'
+        )
+        interface4 = Interface.objects.create(
+            device=device1,
+            name='Ethernet1/3/2/4'
+        )
+        interface5 = Interface.objects.create(
+            device=device1,
+            name='Ethernet1/3/2/1'
+        )
+        interface6 = Interface.objects.create(
+            device=device1,
+            name='Loopback1'
+        )
+
+        self.assertEqual(
+            list(Interface.objects.all().order_naturally()),
+            [interface1, interface5, interface4, interface3, interface2, interface6]
+        )
+
+    def test_interface_order_natural_subinterfaces(self):
+        device1 = Device.objects.create(
+            name='TestSwitch1',
+            device_type=self.device_type,
+            device_role=self.role,
+            site=self.site,
+            rack=self.rack,
+            position=10,
+            face=RACK_FACE_REAR,
+        )
+        interface1 = Interface.objects.create(
+            device=device1,
+            name='GigabitEthernet0/0/3'
+        )
+        interface2 = Interface.objects.create(
+            device=device1,
+            name='GigabitEthernet0/0/2.2'
+        )
+        interface3 = Interface.objects.create(
+            device=device1,
+            name='GigabitEthernet0/0/0.120'
+        )
+        interface4 = Interface.objects.create(
+            device=device1,
+            name='GigabitEthernet0/0/0'
+        )
+        interface5 = Interface.objects.create(
+            device=device1,
+            name='GigabitEthernet0/0/1.117'
+        )
+        interface6 = Interface.objects.create(
+            device=device1,
+            name='GigabitEthernet0'
+        )
+        self.assertEqual(
+            list(Interface.objects.all().order_naturally()),
+            [interface4, interface3, interface5, interface2, interface1, interface6]
+        )

+ 41 - 28
netbox/extras/api/customfields.py

@@ -29,34 +29,47 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
 
         for field_name, value in data.items():
 
-            cf = custom_fields[field_name]
-
-            # Validate custom field name
-            if field_name not in custom_fields:
-                raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
-
-            # Validate boolean
-            if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
-                raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value))
-
-            # Validate date
-            if cf.type == CF_TYPE_DATE:
-                try:
-                    datetime.strptime(value, '%Y-%m-%d')
-                except ValueError:
-                    raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(
-                        field_name, value
-                    ))
-
-            # Validate selected choice
-            if cf.type == CF_TYPE_SELECT:
-                try:
-                    value = int(value)
-                except ValueError:
-                    raise ValidationError("{}: Choice selections must be passed as integers.".format(field_name))
-                valid_choices = [c.pk for c in cf.choices.all()]
-                if value not in valid_choices:
-                    raise ValidationError("Invalid choice for field {}: {}".format(field_name, value))
+            try:
+                cf = custom_fields[field_name]
+            except KeyError:
+                raise ValidationError(
+                    "Invalid custom field for {} objects: {}".format(content_type, field_name)
+                )
+
+            # Data validation
+            if value not in [None, '']:
+
+                # Validate boolean
+                if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
+                    raise ValidationError(
+                        "Invalid value for boolean field {}: {}".format(field_name, value)
+                    )
+
+                # Validate date
+                if cf.type == CF_TYPE_DATE:
+                    try:
+                        datetime.strptime(value, '%Y-%m-%d')
+                    except ValueError:
+                        raise ValidationError(
+                            "Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
+                        )
+
+                # Validate selected choice
+                if cf.type == CF_TYPE_SELECT:
+                    try:
+                        value = int(value)
+                    except ValueError:
+                        raise ValidationError(
+                            "{}: Choice selections must be passed as integers.".format(field_name)
+                        )
+                    valid_choices = [c.pk for c in cf.choices.all()]
+                    if value not in valid_choices:
+                        raise ValidationError(
+                            "Invalid choice for field {}: {}".format(field_name, value)
+                        )
+
+            elif cf.required:
+                raise ValidationError("Required field {} cannot be empty.".format(field_name))
 
         # Check for missing required fields
         missing_fields = []

+ 1 - 1
netbox/ipam/api/views.py

@@ -98,7 +98,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
             # Create the new IP address
             data = request.data.copy()
             data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
-            data['vrf'] = prefix.vrf
+            data['vrf'] = prefix.vrf.pk if prefix.vrf else None
             serializer = serializers.WritableIPAddressSerializer(data=data)
             if serializer.is_valid():
                 serializer.save()

+ 10 - 0
netbox/ipam/models.py

@@ -516,6 +516,16 @@ class VLANGroup(models.Model):
     def get_absolute_url(self):
         return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
 
+    def get_next_available_vid(self):
+        """
+        Return the first available VLAN ID (1-4094) in the group.
+        """
+        vids = [vlan['vid'] for vlan in self.vlans.order_by('vid').values('vid')]
+        for i in range(1, 4095):
+            if i not in vids:
+                return i
+        return None
+
 
 @python_2_unicode_compatible
 class VLAN(CreatedUpdatedModel, CustomFieldModel):

+ 12 - 5
netbox/ipam/tables.py

@@ -34,7 +34,7 @@ RIR_ACTIONS = """
 
 UTILIZATION_GRAPH = """
 {% load helpers %}
-{% if record.pk %}{% utilization_graph value %}{% else %}—{% endif %}
+{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %}
 """
 
 ROLE_ACTIONS = """
@@ -120,6 +120,13 @@ VLAN_ROLE_LINK = """
 """
 
 VLANGROUP_ACTIONS = """
+{% with next_vid=record.get_next_available_vid %}
+    {% if next_vid and perms.ipam.add_vlan %}
+        <a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
+            <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
+        </a>
+    {% endif %}
+{% endwith %}
 {% if perms.ipam.change_vlangroup %}
     <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
@@ -203,10 +210,10 @@ class AggregateTable(BaseTable):
 
 class AggregateDetailTable(AggregateTable):
     child_count = tables.Column(verbose_name='Prefixes')
-    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
+    utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
     class Meta(AggregateTable.Meta):
-        fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
+        fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
 
 
 #
@@ -249,10 +256,10 @@ class PrefixTable(BaseTable):
 
 
 class PrefixDetailTable(PrefixTable):
-    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
+    utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
 
     class Meta(PrefixTable.Meta):
-        fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
+        fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
 
 
 #

+ 9 - 8
netbox/ipam/views.py

@@ -287,11 +287,12 @@ class AggregateListView(ObjectListView):
         ipv4_total = 0
         ipv6_total = 0
 
-        for a in self.queryset:
-            if a.prefix.version == 4:
-                ipv4_total += a.prefix.size
-            elif a.prefix.version == 6:
-                ipv6_total += a.prefix.size / 2 ** 64
+        for aggregate in self.queryset:
+            if aggregate.prefix.version == 6:
+                # Report equivalent /64s for IPv6 to keep things sane
+                ipv6_total += int(aggregate.prefix.size / 2 ** 64)
+            else:
+                ipv4_total += aggregate.prefix.size
 
         return {
             'ipv4_total': ipv4_total,
@@ -315,7 +316,7 @@ class AggregateView(View):
         )
         child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
 
-        prefix_table = tables.PrefixTable(child_prefixes)
+        prefix_table = tables.PrefixDetailTable(child_prefixes)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             prefix_table.base_columns['pk'].visible = True
 
@@ -474,11 +475,11 @@ class PrefixView(View):
         child_prefixes = Prefix.objects.filter(
             vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
         ).select_related(
-            'site', 'role'
+            'site', 'vlan', 'role',
         ).annotate_depth(limit=0)
         if child_prefixes:
             child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
-        child_prefix_table = tables.PrefixTable(child_prefixes)
+        child_prefix_table = tables.PrefixDetailTable(child_prefixes)
         if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
             child_prefix_table.base_columns['pk'].visible = True
 

+ 5 - 2
netbox/netbox/configuration.example.py

@@ -38,11 +38,14 @@ ADMINS = [
     # ['John Doe', 'jdoe@example.com'],
 ]
 
-# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
-# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
+# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
+# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
 BANNER_TOP = ''
 BANNER_BOTTOM = ''
 
+# Text to include on the login page above the login form. HTML is allowed.
+BANNER_LOGIN = ''
+
 # Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
 # BASE_PATH = 'netbox/'
 BASE_PATH = ''

+ 8 - 3
netbox/netbox/settings.py

@@ -13,7 +13,7 @@ except ImportError:
     )
 
 
-VERSION = '2.1.5-dev'
+VERSION = '2.1.6-dev'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -29,8 +29,9 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 
 # Import optional configuration parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
-BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
-BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
+BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
+BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
+BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
@@ -234,6 +235,10 @@ REST_FRAMEWORK = {
     'DEFAULT_PERMISSION_CLASSES': (
         'utilities.api.TokenPermissions',
     ),
+    'DEFAULT_RENDERER_CLASSES': (
+        'rest_framework.renderers.JSONRenderer',
+        'utilities.api.FormlessBrowsableAPIRenderer',
+    ),
     'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
     'PAGE_SIZE': PAGINATE_COUNT,

+ 7 - 6
netbox/project-static/css/base.css

@@ -329,13 +329,14 @@ li.occupied + li.available {
 }
 
 /* Devices */
-table.component-list tr.ipaddress td {
-    background-color: #eeffff;
-    padding-bottom: 4px;
-    padding-top: 4px;
+table.component-list td.subtable {
+    padding: 0;
+    padding-left: 16px;
 }
-table.component-list tr.ipaddress:hover td {
-    background-color: #e6f7f7;
+table.component-list td.subtable td {
+    border: none;
+    padding-bottom: 6px;
+    padding-top: 6px;
 }
 
 /* AJAX loader */

+ 2 - 2
netbox/templates/dcim/device_lldp_neighbors.html

@@ -53,7 +53,7 @@ $(document).ready(function() {
         success: function(json) {
             $.each(json['get_lldp_neighbors'], function(iface, neighbors) {
                 var neighbor = neighbors[0];
-                var row = $('#' + iface.replace(/(\/)/g, "\\$1"));
+                var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
                 var configured_device = row.children('td.configured_device').attr('data');
                 var configured_interface = row.children('td.configured_interface').attr('data');
                 // Add LLDP neighbors to table
@@ -62,7 +62,7 @@ $(document).ready(function() {
                 // Apply colors to rows
                 if (!configured_device && neighbor['hostname']) {
                     row.addClass('info');
-                } else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port']) {
+                } else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) {
                     row.addClass('success');
                 } else {
                     row.addClass('danger');

+ 51 - 37
netbox/templates/dcim/inc/interface.html

@@ -1,4 +1,4 @@
-<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status %} success{% elif iface.connection and not iface.connection.connection_status %} info{% endif %}">
+<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}">
     {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
         <td class="pk">
             <input name="pk" type="checkbox" value="{{ iface.pk }}" />
@@ -113,41 +113,55 @@
         {% endif %}
     </td>
 </tr>
-{% for ip in iface.ip_addresses.all %}
-    <tr class="ipaddress">
-        {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
-            <td></td>
-        {% endif %}
-        <td colspan="3">
-            <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
-            {% if ip.description %}
-                <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
-            {% endif %}
-            {% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
-                <span class="label label-success">Primary</span>
-            {% endif %}
-        </td>
-        <td class="text-right">
-            {% if ip.vrf %}
-                <a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
+{% with iface.ip_addresses.all as ipaddresses %}
+    {% if ipaddresses %}
+        <tr class="ipaddress">
+            {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
+                <td></td>
+                <td colspan="6" class="subtable">
             {% else %}
-                <span class="text-muted">Global</span>
+                <td colspan="7" class="subtable">
             {% endif %}
-        </td>
-        <td>
-            <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
-        </td>
-        <td class="text-right">
-            {% if perms.ipam.change_ipaddress %}
-                <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
-                    <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
-                </a>
-            {% endif %}
-            {% if perms.ipam.delete_ipaddress %}
-                <a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
-                    <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
-                </a>
-            {% endif %}
-        </td>
-    </tr>
-{% endfor %}
+            <table class="table table-hover">
+                {% for ip in ipaddresses %}
+                    <tr>
+                        <td>
+                            <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
+                            {% if ip.description %}
+                                <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
+                            {% endif %}
+                        </td>
+                        <td>
+                            {% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
+                                <span class="label label-success">Primary</span>
+                            {% endif %}
+                        </td>
+                        <td>
+                            {% if ip.vrf %}
+                                <a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
+                            {% else %}
+                                <span class="text-muted">Global table</span>
+                            {% endif %}
+                        </td>
+                        <td>
+                            <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
+                        </td>
+                        <td class="text-right">
+                            {% if perms.ipam.change_ipaddress %}
+                                <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
+                                    <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
+                                </a>
+                            {% endif %}
+                            {% if perms.ipam.delete_ipaddress %}
+                                <a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                    <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
+                                </a>
+                            {% endif %}
+                        </td>
+                    </tr>
+                {% endfor %}
+            </table>
+            </td>
+        </tr>
+    {% endif %}
+{% endwith %}

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

@@ -20,11 +20,18 @@
 <div class="row">
 	<div class="col-md-9">
         {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %}
-        <p class="text-right">IPv4 total: <strong>{{ ipv4_total|intcomma }} /32s</strong></p>
-        <p class="text-right">IPv6 total: <strong>{{ ipv6_total|intcomma }} /64s</strong></p>
 	</div>
 	<div class="col-md-3">
 		{% include 'inc/search_panel.html' %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong><i class="fa fa-bar-chart"></i> Statistics</strong>
+            </div>
+            <ul class="list-group">
+                <li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
+                <li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
+            </ul>
+        </div>
 	</div>
 </div>
 {% endblock %}

+ 6 - 1
netbox/templates/login.html

@@ -2,8 +2,13 @@
 {% load form_helpers %}
 
 {% block content %}
-<div class="row" style="margin-top: 150px;">
+<div class="row" style="margin-top: {% if settings.BANNER_LOGIN %}100{% else %}150{% endif %}px;">
     <div class="col-sm-4 col-sm-offset-4">
+        {% if settings.BANNER_LOGIN %}
+            <div style="margin-bottom: 25px">
+                {{ settings.BANNER_LOGIN|safe }}
+            </div>
+        {% endif %}
         {% if form.non_field_errors %}
             <div class="panel panel-danger">
                 <div class="panel-heading"><strong>Errors</strong></div>

+ 13 - 0
netbox/utilities/api.py

@@ -8,6 +8,7 @@ from rest_framework.compat import is_authenticated
 from rest_framework.exceptions import APIException
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.permissions import BasePermission, DjangoModelPermissions, SAFE_METHODS
+from rest_framework.renderers import BrowsableAPIRenderer
 from rest_framework.serializers import Field, ModelSerializer, ValidationError
 from rest_framework.views import get_view_name as drf_get_view_name
 
@@ -207,6 +208,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
 
 
 #
+# Renderers
+#
+
+class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
+    """
+    Override the built-in BrowsableAPIRenderer to disable HTML forms.
+    """
+    def show_form_for_method(self, *args, **kwargs):
+        return False
+
+
+#
 # Miscellaneous
 #
 

+ 4 - 1
netbox/utilities/views.py

@@ -479,7 +479,7 @@ class BulkEditView(View):
     template_name = 'utilities/obj_bulk_edit.html'
     default_return_url = 'home'
 
-    def get(self):
+    def get(self, request):
         return redirect(self.default_return_url)
 
     def post(self, request, **kwargs):
@@ -627,6 +627,9 @@ class BulkDeleteView(View):
     template_name = 'utilities/obj_bulk_delete.html'
     default_return_url = 'home'
 
+    def get(self, request):
+        return redirect(self.default_return_url)
+
     def post(self, request, **kwargs):
 
         # Attempt to derive parent object if a parent class has been given