From d037938fa3ea07bb46e294a8f07752bbed001e1d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Sep 2017 15:14:35 -0400 Subject: [PATCH 01/19] Closes #1484: Added individual "add VLAN" buttons on the VLAN groups list --- netbox/ipam/models.py | 10 ++++++++++ netbox/ipam/tables.py | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bbd5e1827..214293b7d 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -512,6 +512,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): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 96127aec5..512b75649 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -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 %} + + + + {% endif %} +{% endwith %} {% if perms.ipam.change_vlangroup %} {% endif %} From edcb1f120b68e3d774cc36e3f8320226614ebd28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Sep 2017 15:28:09 -0400 Subject: [PATCH 02/19] Closes #1485: Added LOGIN_BANNER configuration setting to display a banner on the login page --- docs/configuration/optional-settings.md | 8 +++++++- netbox/netbox/configuration.example.py | 7 +++++-- netbox/netbox/settings.py | 5 +++-- netbox/templates/login.html | 7 ++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 22916d54c..9cc4e75fd 100644 --- a/docs/configuration/optional-settings.md +++ b/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 diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 192653dc4..ce7a62464 100644 --- a/netbox/netbox/configuration.example.py +++ b/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 = '' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 02cb5953d..e9d8c04f5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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 diff --git a/netbox/templates/login.html b/netbox/templates/login.html index 07b10bfcc..a34b934cc 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -2,8 +2,13 @@ {% load form_helpers %} {% block content %} -
+
+ {% if settings.BANNER_LOGIN %} +
+ {{ settings.BANNER_LOGIN|safe }} +
+ {% endif %} {% if form.non_field_errors %}
Errors
From 15143f6a5689b40374e680084252ad046a5f77cb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Sep 2017 16:34:28 -0400 Subject: [PATCH 03/19] Fixes #1507: Fixed error when creating the next available IP from a prefix within a VRF --- netbox/ipam/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9cf93cb4b..abff09c15 100644 --- a/netbox/ipam/api/views.py +++ b/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() From 85d5e4f0c63ddec12a30e8661783d2be4e4da5f0 Mon Sep 17 00:00:00 2001 From: Chris Howells Date: Mon, 18 Sep 2017 21:37:09 +0100 Subject: [PATCH 04/19] Document the PATCH method. Closes #1481. (#1482) --- docs/api/examples.md | 12 ++++++++++-- docs/api/overview.md | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/api/examples.md b/docs/api/examples.md index 5082534bc..4ec2f0f33 100644 --- a/docs/api/examples.md +++ b/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. diff --git a/docs/api/overview.md b/docs/api/overview.md index bdf0a2f4c..39a4109f9 100644 --- a/docs/api/overview.md +++ b/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.) From ed01b8c67fc26677cdf7fb190df73d53dbfd036a Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Mon, 18 Sep 2017 15:37:51 -0500 Subject: [PATCH 05/19] Added missing package for CentOS and Fedora (#1470) Without the added package, installation fails during `pip install -r requirements.txt` on the pycrypto package for me in CentOS 6 and 7 and Fedora. --- docs/installation/netbox.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index b0928c1b5..d1aa5d001 100644 --- a/docs/installation/netbox.md +++ b/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. From c6469777c8edb5b5da366956aaad010f0f148eed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Sep 2017 09:56:20 -0400 Subject: [PATCH 06/19] Fixes #1520: Redirect on GET request to bulk edit/delete views --- netbox/utilities/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1ec21b628..ec91399df 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -478,7 +478,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): @@ -626,6 +626,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 From f6e6910bc5bd2de5968ed9c175347f2188db8ea7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Sep 2017 14:44:51 -0400 Subject: [PATCH 07/19] Cleaned up device interfaces/IPs table --- netbox/project-static/css/base.css | 13 ++-- netbox/templates/dcim/inc/interface.html | 88 ++++++++++++++---------- 2 files changed, 58 insertions(+), 43 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 0f6b24077..b013aab97 100644 --- a/netbox/project-static/css/base.css +++ b/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 */ diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 75d0f027d..3845c8759 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,4 +1,4 @@ - + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} @@ -113,41 +113,55 @@ {% endif %} -{% for ip in iface.ip_addresses.all %} - - {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - {{ ip }} - {% if ip.description %} - - {% endif %} - {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} - Primary - {% endif %} - - - {% if ip.vrf %} - {{ ip.vrf }} +{% with iface.ip_addresses.all as ipaddresses %} + {% if ipaddresses %} + + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% else %} - Global + {% endif %} - - - {{ ip.get_status_display }} - - - {% if perms.ipam.change_ipaddress %} - - - - {% endif %} - {% if perms.ipam.delete_ipaddress %} - - - - {% endif %} - - -{% endfor %} + + {% for ip in ipaddresses %} + + + + + + + + {% endfor %} +
+ {{ ip }} + {% if ip.description %} + + {% endif %} + + {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} + Primary + {% endif %} + + {% if ip.vrf %} + {{ ip.vrf }} + {% else %} + Global table + {% endif %} + + {{ ip.get_status_display }} + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} +
+ + + {% endif %} +{% endwith %} From 596698d24635321fc55cdf55d36f2d94c7934ca4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 20 Sep 2017 16:54:27 -0400 Subject: [PATCH 08/19] Fixes #1522: Removed object create/edit forms from the browsable API --- netbox/netbox/settings.py | 4 ++++ netbox/utilities/api.py | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e9d8c04f5..13ae4c48b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -234,6 +234,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, diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 36728a660..3698bc47c 100644 --- a/netbox/utilities/api.py +++ b/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 @@ -206,6 +207,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return self.default_limit +# +# Renderers +# + +class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer): + """ + Override the built-in BrowsableAPIRenderer to disable HTML forms. + """ + def show_form_for_method(self, *args, **kwargs): + return False + + # # Miscellaneous # From 54425300bf35aa1595a67210bec7f249f1abeed8 Mon Sep 17 00:00:00 2001 From: Lars Hartmann Date: Thu, 21 Sep 2017 16:36:25 +0200 Subject: [PATCH 09/19] mentioned support of nested groups in LDAP installation documentation. --- docs/installation/ldap.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index b43105b8b..bb1618e32 100644 --- a/docs/installation/ldap.md +++ b/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 From ec7b815b12bc16511d945e1e6b30b33d777c242f Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Thu, 21 Sep 2017 10:33:34 -0700 Subject: [PATCH 10/19] Fix order_naturally with unbalanced names and use RawSQL instead of extra --- netbox/dcim/models.py | 48 +++++++++++++++-------- netbox/dcim/tests/test_models.py | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a189d9f29..52527cc19 100644 --- a/netbox/dcim/models.py +++ b/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,31 @@ 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', 'name'), + IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '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) + + fields = { + '_type': RawSQL(r"SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), []), + '_slot': RawSQL(r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)') AS integer)".format(sql_col), []), + '_subslot': RawSQL( + r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+)\/([0-9]+)') AS integer), 0)".format( + sql_col), [] + ), + '_position': RawSQL( + r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)".format( + sql_col), [] + ), + '_subposition': RawSQL( + r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)".format( + sql_col), [] + ), + '_channel': RawSQL( + r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col), []), + '_vc': RawSQL(r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col), []), + } + + return self.annotate(**fields).order_by(*ordering) def connectable(self): """ diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 340c58092..9acbe3901 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -98,3 +98,69 @@ 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_device_port_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' + ) + + self.assertEqual( + list(Interface.objects.all().order_naturally()), + [interface1, interface5, interface4, interface3, interface2] + ) From 210286f0a3a6a6df10d89221e66b6ab1859f7575 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Thu, 21 Sep 2017 17:20:21 -0700 Subject: [PATCH 11/19] Another test case to ensure subinterface ordering --- netbox/dcim/tests/test_models.py | 41 +++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 9acbe3901..00a4de28c 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -129,7 +129,7 @@ class InterfaceTestCase(TestCase): slug='switch', ) - def test_device_port_order_natural(self): + def test_interface_order_natural(self): device1 = Device.objects.create( name='TestSwitch1', device_type=self.device_type, @@ -164,3 +164,42 @@ class InterfaceTestCase(TestCase): list(Interface.objects.all().order_naturally()), [interface1, interface5, interface4, interface3, interface2] ) + + 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()), + [interface6, interface4, interface3, interface5, interface2, interface1] + ) From a8757266c43ffb2ba22ab1dda17f286334199ea8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Sep 2017 16:18:43 -0400 Subject: [PATCH 12/19] More work on #1523 (natural ordering for interfaces) --- netbox/dcim/models.py | 42 +++++++++++++++++--------------- netbox/dcim/tests/test_models.py | 8 ++++-- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 52527cc19..311f02e99 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -661,28 +661,32 @@ class InterfaceQuerySet(models.QuerySet): """ sql_col = '{}.name'.format(self.model._meta.db_table) ordering = { - IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_type', 'name'), - IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_subposition', '_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] + 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(r"SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), []), - '_slot': RawSQL(r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)') AS integer)".format(sql_col), []), - '_subslot': RawSQL( - r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+)\/([0-9]+)') AS integer), 0)".format( - sql_col), [] - ), - '_position': RawSQL( - r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)".format( - sql_col), [] - ), - '_subposition': RawSQL( - r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)".format( - sql_col), [] - ), - '_channel': RawSQL( - r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col), []), - '_vc': RawSQL(r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col), []), + '_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) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 00a4de28c..b4101cfd5 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -159,10 +159,14 @@ class InterfaceTestCase(TestCase): 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] + [interface1, interface5, interface4, interface3, interface2, interface6] ) def test_interface_order_natural_subinterfaces(self): @@ -201,5 +205,5 @@ class InterfaceTestCase(TestCase): ) self.assertEqual( list(Interface.objects.all().order_naturally()), - [interface6, interface4, interface3, interface5, interface2, interface1] + [interface4, interface3, interface5, interface2, interface1, interface6] ) From 23f8a56e94ba9d8d389dd54dd77c4f20deb31413 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 22 Sep 2017 21:17:46 -0400 Subject: [PATCH 13/19] Closes #1499: Added utilization graph to child prefixes table --- netbox/ipam/tables.py | 10 +++++----- netbox/ipam/views.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 512b75649..24bb9b3c2 100644 --- a/netbox/ipam/tables.py +++ b/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 = """ @@ -210,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') # @@ -256,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') # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d0225e567..89eecd352 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -473,11 +473,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 From d2c6fd7809597316d3377f2b5788ccdf42603d83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 12:14:55 -0400 Subject: [PATCH 14/19] Closes #1536: Improved formatting of aggregate prefix statistics --- netbox/ipam/views.py | 11 ++++++----- netbox/templates/ipam/aggregate_list.html | 11 +++++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 89eecd352..29757a530 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -286,11 +286,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, diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html index fde53a790..df8e4772e 100644 --- a/netbox/templates/ipam/aggregate_list.html +++ b/netbox/templates/ipam/aggregate_list.html @@ -20,11 +20,18 @@
{% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %} -

IPv4 total: {{ ipv4_total|intcomma }} /32s

-

IPv6 total: {{ ipv6_total|intcomma }} /64s

{% include 'inc/search_panel.html' %} +
+
+ Statistics +
+
    +
  • Total IPv4 IPs {{ ipv4_total|intcomma }}
  • +
  • Total IPv6 /64s {{ ipv6_total|intcomma }}
  • +
+
{% endblock %} From 548642ded9490e9b3dd5ac6dea1b3225ff718929 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 13:37:11 -0400 Subject: [PATCH 15/19] Fixes #1489: Corrected server error on validation of empty required custom field --- netbox/extras/api/customfields.py | 61 +++++++++++++++++++------------ 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index fc83b33e5..5554df924 100644 --- a/netbox/extras/api/customfields.py +++ b/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] + try: + cf = custom_fields[field_name] + except KeyError: + raise ValidationError( + "Invalid custom field for {} objects: {}".format(content_type, 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)) + # 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 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 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)) + # 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 = [] From f2a180d10c620c874640df51a613ff37d5f38417 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 13:39:03 -0400 Subject: [PATCH 16/19] #1499: Added utilization graph to child prefixes for aggregates --- netbox/ipam/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 29757a530..4152cae91 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -315,7 +315,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 From 644b7e540d1172e1ecc4c4037be08e40f5d988da Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 14:33:32 -0400 Subject: [PATCH 17/19] Fixes #1486: Ignore subinterface IDs when validating LLDP neighbor connections --- netbox/templates/dcim/device_lldp_neighbors.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index a7346c5db..99c1f6465 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/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'); From 05fbf05f1d8cc1db472d34e66215d07549534412 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 14:50:45 -0400 Subject: [PATCH 18/19] Release v2.1.5 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 13ae4c48b..6d944d4db 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.1.5-dev' +VERSION = '2.1.5' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 3cdde72e4208b32b5178354223ab1fd4fb16f0e7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 25 Sep 2017 14:53:10 -0400 Subject: [PATCH 19/19] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6d944d4db..735372b61 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.1.5' +VERSION = '2.1.6-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))