Просмотр исходного кода

Merge branch 'develop' into develop-2.4

Jeremy Stretch 7 лет назад
Родитель
Сommit
d0308e0f58

+ 2 - 1
.github/ISSUE_TEMPLATE.md

@@ -21,6 +21,7 @@
 [ ] Feature request <!-- An enhancement of existing functionality -->
 [ ] Bug report      <!-- Unexpected or erroneous behavior -->
 [ ] Documentation   <!-- A modification to the documentation -->
+[ ] Housekeeping    <!-- Changes pertaining to the codebase itself -->
 
 <!--
     Please describe the environment in which you are running NetBox. (Be sure
@@ -31,7 +32,7 @@
 -->
 ### Environment
 * Python version:  <!-- Example: 3.5.4 -->
-* NetBox version:  <!-- Example: 2.1.3 -->
+* NetBox version:  <!-- Example: 2.3.5 -->
 
 <!--
     BUG REPORTS must include:

+ 1 - 1
.travis.yml

@@ -9,7 +9,7 @@ python:
   - "3.5"
 install:
   - pip install -r requirements.txt
-  - pip install pep8
+  - pip install pycodestyle
 before_script:
   - psql --version
   - psql -U postgres -c 'SELECT version();'

+ 1 - 0
netbox/circuits/api/views.py

@@ -19,6 +19,7 @@ from . import serializers
 
 class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
     fields = (
+        (Circuit, ['status']),
         (CircuitTermination, ['term_side']),
     )
 

+ 2 - 1
netbox/dcim/api/views.py

@@ -36,11 +36,12 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
     fields = (
         (Device, ['face', 'status']),
         (ConsolePort, ['connection_status']),
-        (Interface, ['form_factor']),
+        (Interface, ['form_factor', 'mode']),
         (InterfaceConnection, ['connection_status']),
         (InterfaceTemplate, ['form_factor']),
         (PowerPort, ['connection_status']),
         (Rack, ['type', 'width']),
+        (Site, ['status']),
     )
 
 

+ 1 - 1
netbox/dcim/forms.py

@@ -35,7 +35,7 @@ from .models import (
     RackRole, Region, Site, VirtualChassis
 )
 
-DEVICE_BY_PK_RE = '{\d+\}'
+DEVICE_BY_PK_RE = r'{\d+\}'
 
 INTERFACE_MODE_HELP_TEXT = """
 Access: One untagged VLAN<br />

+ 10 - 4
netbox/dcim/models.py

@@ -1352,6 +1352,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
                 'face': "Must specify rack face when defining rack position.",
             })
 
+        # Prevent 0U devices from being assigned to a specific position
+        if self.position and self.device_type.u_height == 0:
+            raise ValidationError({
+                'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
+            })
+
         if self.rack:
 
             try:
@@ -1612,8 +1618,8 @@ class ConsoleServerPortManager(models.Manager):
     def get_queryset(self):
         # Pad any trailing digits to effect natural sorting
         return super(ConsoleServerPortManager, self).get_queryset().extra(select={
-            'name_padded': "CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
-                           "LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
+            'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
+                           r"LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
         }).order_by('device', 'name_padded')
 
 
@@ -1720,8 +1726,8 @@ class PowerOutletManager(models.Manager):
     def get_queryset(self):
         # Pad any trailing digits to effect natural sorting
         return super(PowerOutletManager, self).get_queryset().extra(select={
-            'name_padded': "CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
-                           "LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
+            'name_padded': r"CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
+                           r"LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
         }).order_by('device', 'name_padded')
 
 

+ 4 - 4
netbox/dcim/tables.py

@@ -7,9 +7,9 @@ from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
-    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
-    VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
+    Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
+    RackReservation, Region, Site, VirtualChassis,
 )
 
 REGION_LINK = """
@@ -621,7 +621,7 @@ class InterfaceConnectionTable(BaseTable):
     interface_b = tables.Column(verbose_name='Interface B')
 
     class Meta(BaseTable.Meta):
-        model = Interface
+        model = InterfaceConnection
         fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
 
 

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

@@ -102,7 +102,7 @@ class TopologyMapViewSet(ModelViewSet):
 
         try:
             data = tmap.render(img_format=img_format)
-        except:
+        except Exception:
             return HttpResponse(
                 "There was an error generating the requested graph. Ensure that the GraphViz executables have been "
                 "installed correctly."

+ 9 - 1
netbox/extras/forms.py

@@ -5,6 +5,7 @@ from collections import OrderedDict
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
 from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.models import Tag
 
@@ -64,7 +65,14 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
             choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
             if not cf.required or bulk_edit or filterable_only:
                 choices = [(None, '---------')] + choices
-            field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required)
+            # Check for a default choice
+            default_choice = None
+            if initial:
+                try:
+                    default_choice = cf.choices.get(value=initial).pk
+                except ObjectDoesNotExist:
+                    pass
+            field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
 
         # URL
         elif cf.type == CF_TYPE_URL:

+ 1 - 1
netbox/extras/migrations/0007_unicode_literals.py

@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='customfield',
             name='default',
-            field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
+            field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans.', max_length=100),
         ),
         migrations.AlterField(
             model_name='customfield',

+ 1 - 1
netbox/extras/migrations/0008_reports.py

@@ -19,7 +19,7 @@ def verify_postgresql_version(apps, schema_editor):
         with connection.cursor() as cursor:
             cursor.execute("SELECT VERSION()")
             row = cursor.fetchone()
-            pg_version = re.match('^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
+            pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
             if StrictVersion(pg_version) < StrictVersion('9.4.0'):
                 raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
 

+ 1 - 2
netbox/extras/models.py

@@ -172,8 +172,7 @@ class CustomField(models.Model):
     default = models.CharField(
         max_length=100,
         blank=True,
-        help_text='Default value for the field. Use "true" or "false" for '
-                  'booleans. N/A for selection fields.'
+        help_text='Default value for the field. Use "true" or "false" for booleans.'
     )
     weight = models.PositiveSmallIntegerField(
         default=100,

+ 7 - 7
netbox/extras/rpc.py

@@ -163,8 +163,8 @@ class IOSSSH(SSHClient):
 
             sh_ver = self._send('show version').split('\r\n')
             return {
-                'serial': parse(sh_ver, 'Processor board ID ([^\s]+)'),
-                'description': parse(sh_ver, 'cisco ([^\s]+)')
+                'serial': parse(sh_ver, r'Processor board ID ([^\s]+)'),
+                'description': parse(sh_ver, r'cisco ([^\s]+)')
             }
 
         def items(chassis_serial=None):
@@ -172,9 +172,9 @@ class IOSSSH(SSHClient):
             for i in cmd:
                 i_fmt = i.replace('\r\n', ' ')
                 try:
-                    m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1)
-                    m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1)
-                    m_serial = re.search('SN: ([^\s]+)', i_fmt).group(1)
+                    m_name = re.search(r'NAME: "([^"]+)"', i_fmt).group(1)
+                    m_pid = re.search(r'PID: ([^\s]+)', i_fmt).group(1)
+                    m_serial = re.search(r'SN: ([^\s]+)', i_fmt).group(1)
                     # Omit built-in items and those with no PID
                     if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
                         yield {
@@ -208,7 +208,7 @@ class OpengearSSH(SSHClient):
         try:
             stdin, stdout, stderr = self.ssh.exec_command("showserial")
             serial = stdout.readlines()[0].strip()
-        except:
+        except Exception:
             raise RuntimeError("Failed to glean chassis serial from device.")
         # Older models don't provide serial info
         if serial == "No serial number information available":
@@ -217,7 +217,7 @@ class OpengearSSH(SSHClient):
         try:
             stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model")
             description = stdout.readlines()[0].split(' ', 1)[1].strip()
-        except:
+        except Exception:
             raise RuntimeError("Failed to glean chassis description from device.")
 
         return {

+ 29 - 4
netbox/ipam/api/views.py

@@ -95,7 +95,31 @@ class PrefixViewSet(CustomFieldModelViewSet):
             requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
 
             # Allocate prefixes to the requested objects based on availability within the parent
-            for requested_prefix in requested_prefixes:
+            for i, requested_prefix in enumerate(requested_prefixes):
+
+                # Validate requested prefix size
+                error_msg = None
+                if 'prefix_length' not in requested_prefix:
+                    error_msg = "Item {}: prefix_length field missing".format(i)
+                elif not isinstance(requested_prefix['prefix_length'], int):
+                    error_msg = "Item {}: Invalid prefix length ({})".format(
+                        i, requested_prefix['prefix_length']
+                    )
+                elif prefix.family == 4 and requested_prefix['prefix_length'] > 32:
+                    error_msg = "Item {}: Invalid prefix length ({}) for IPv4".format(
+                        i, requested_prefix['prefix_length']
+                    )
+                elif prefix.family == 6 and requested_prefix['prefix_length'] > 128:
+                    error_msg = "Item {}: Invalid prefix length ({}) for IPv6".format(
+                        i, requested_prefix['prefix_length']
+                    )
+                if error_msg:
+                    return Response(
+                        {
+                            "detail": error_msg
+                        },
+                        status=status.HTTP_400_BAD_REQUEST
+                    )
 
                 # Find the first available prefix equal to or larger than the requested size
                 for available_prefix in available_prefixes.iter_cidrs():
@@ -157,8 +181,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
             requested_ips = request.data if isinstance(request.data, list) else [request.data]
 
             # Determine if the requested number of IPs is available
-            available_ips = list(prefix.get_available_ips())
-            if len(available_ips) < len(requested_ips):
+            available_ips = prefix.get_available_ips()
+            if available_ips.size < len(requested_ips):
                 return Response(
                     {
                         "detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
@@ -168,8 +192,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
                 )
 
             # Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
+            available_ips = iter(available_ips)
             for requested_ip in requested_ips:
-                requested_ip['address'] = available_ips.pop(0)
+                requested_ip['address'] = next(available_ips)
                 requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
 
             # Initialize the serializer with a list or a single object depending on what was requested

+ 16 - 0
netbox/ipam/filters.py

@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 
 import django_filters
+from django.core.exceptions import ValidationError
 from django.db.models import Q
 import netaddr
 from netaddr.core import AddrFormatError
@@ -242,6 +243,10 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search_by_parent',
         label='Parent prefix',
     )
+    address = django_filters.CharFilter(
+        method='filter_address',
+        label='Address',
+    )
     mask_length = django_filters.NumberFilter(
         method='filter_mask_length',
         label='Mask length',
@@ -325,6 +330,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         except (AddrFormatError, ValueError):
             return queryset.none()
 
+    def filter_address(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        try:
+            # Match address and subnet mask
+            if '/' in value:
+                return queryset.filter(address=value)
+            return queryset.filter(address__net_host=value)
+        except ValidationError:
+            return queryset.none()
+
     def filter_mask_length(self, queryset, name, value):
         if not value:
             return queryset

+ 10 - 2
netbox/netbox/settings.py

@@ -294,7 +294,15 @@ SWAGGER_SETTINGS = {
         'utilities.custom_inspectors.NullablePaginatorInspector',
         'drf_yasg.inspectors.DjangoRestResponsePagination',
         'drf_yasg.inspectors.CoreAPICompatInspector',
-    ]
+    ],
+    'SECURITY_DEFINITIONS': {
+        'Bearer': {
+            'type': 'apiKey',
+            'name': 'Authorization',
+            'in': 'header',
+        }
+    },
+    'VALIDATOR_URL': None,
 }
 
 
@@ -307,5 +315,5 @@ INTERNAL_IPS = (
 
 try:
     HOSTNAME = socket.gethostname()
-except:
+except Exception:
     HOSTNAME = 'localhost'

+ 3 - 3
netbox/netbox/urls.py

@@ -52,9 +52,9 @@ _patterns = [
     url(r'^api/secrets/', include('secrets.api.urls')),
     url(r'^api/tenancy/', include('tenancy.api.urls')),
     url(r'^api/virtualization/', include('virtualization.api.urls')),
-    url(r'^api/docs/$', schema_view.with_ui('swagger', cache_timeout=None), name='api_docs'),
-    url(r'^api/redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='api_redocs'),
-    url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema_swagger'),
+    url(r'^api/docs/$', schema_view.with_ui('swagger'), name='api_docs'),
+    url(r'^api/redoc/$', schema_view.with_ui('redoc'), name='api_redocs'),
+    url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),

+ 1 - 1
netbox/secrets/forms.py

@@ -27,7 +27,7 @@ def validate_rsa_key(key, is_secret=True):
         raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.")
     try:
         PKCS1_OAEP.new(key)
-    except:
+    except Exception:
         raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.")
 
 

+ 1 - 1
netbox/secrets/models.py

@@ -105,7 +105,7 @@ class UserKey(models.Model):
                 raise ValidationError({
                     'public_key': "Invalid RSA key format."
                 })
-            except:
+            except Exception:
                 raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
                                       "uploading a valid RSA public key in PEM format (no SSH/PGP).")
 

+ 1 - 1
netbox/templates/dcim/inc/rack_elevation.html

@@ -26,7 +26,7 @@
                 <li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
                     {% ifequal u.device.face face_id %}
                         <a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
-                           data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}">
+                           data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
                             {{ u.device.name|default:u.device.device_role }}
                             {% if u.device.devicebay_count %}
                                 ({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})

+ 5 - 5
netbox/utilities/forms.py

@@ -38,10 +38,10 @@ COLOR_CHOICES = (
     ('607d8b', 'Dark grey'),
     ('111111', 'Black'),
 )
-NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
-ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
-IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
-IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
+NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]'
+ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
+IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
+IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
 
 
 def parse_numeric_range(string, base=10):
@@ -407,7 +407,7 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
         try:
             if not self.to_field_name:
                 key = 'pk'
-            elif re.match('^\{\d+\}$', value):
+            elif re.match(r'^\{\d+\}$', value):
                 key = 'pk'
                 value = value.strip('{}')
             else:

+ 1 - 1
netbox/utilities/validators.py

@@ -15,7 +15,7 @@ class EnhancedURLValidator(URLValidator):
         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()):
+            if not item or not re.match(r'^[a-z][0-9a-z+\-.]*$', item.lower()):
                 return False
             return True
 

+ 4 - 1
scripts/cibuild.sh

@@ -23,8 +23,11 @@ fi
 
 # Check all python source files for PEP 8 compliance, but explicitly
 # ignore:
+#  - W504: line break after binary operator
 #  - E501: line greater than 80 characters in length
-pep8 --ignore=E501 netbox/
+pycodestyle \
+    --ignore=W504,E501 \
+    netbox/
 RC=$?
 if [[ $RC != 0 ]]; then
 	echo -e "\n$(info) one or more PEP 8 errors detected, failing build."