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

Merge branch 'develop' into 3668-address-assign-dns-filter

Jeremy Stretch 6 лет назад
Родитель
Сommit
4be7ca0c78

+ 23 - 0
.github/lock.yml

@@ -0,0 +1,23 @@
+# Configuration for Lock (https://github.com/apps/lock)
+
+# Number of days of inactivity before a closed issue or pull request is locked
+daysUntilLock: 90
+
+# Skip issues and pull requests created before a given timestamp. Timestamp must
+# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
+skipCreatedBefore: 2020-01-01
+
+# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
+exemptLabels: []
+
+# Label to add before locking, such as `outdated`. Set to `false` to disable
+lockLabel: false
+
+# Comment to post before locking. Set to `false` to disable
+lockComment: false
+
+# Assign `resolved` as the reason for locking. Set to `false` to disable
+setLockReason: true
+
+# Limit to only `issues` or `pulls`
+# only: issues

+ 7 - 0
.github/stale.yml

@@ -1,20 +1,27 @@
+# Configuration for Stale (https://github.com/apps/stale)
+
 # Number of days of inactivity before an issue becomes stale
 daysUntilStale: 14
+
 # Number of days of inactivity before a stale issue is closed
 daysUntilClose: 7
+
 # Issues with these labels will never be considered stale
 exemptLabels:
   - "status: accepted"
   - "status: gathering feedback"
   - "status: blocked"
+
 # Label to use when marking an issue as stale
 staleLabel: wontfix
+
 # Comment to post when marking an issue as stale. Set to `false` to disable
 markComment: >
   This issue has been automatically marked as stale because it has not had
   recent activity. It will be closed if no further activity occurs. NetBox
   is governed by a small group of core maintainers which means not all opened
   issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
+
 # Comment to post when closing a stale issue. Set to `false` to disable
 closeComment: >
   This issue has been automatically closed due to lack of activity. In an

+ 65 - 0
docs/additional-features/napalm.md

@@ -0,0 +1,65 @@
+# NAPALM
+
+NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API.
+
+!!! info
+    To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/2-netbox/#napalm-automation-optional) for more information.
+
+```
+GET /api/dcim/devices/1/napalm/?method=get_environment
+
+{
+    "get_environment": {
+        ...
+    }
+}
+```
+
+## Authentication
+
+By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) are used for NAPALM authentication. They can be overridden for an individual API call through the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
+
+```
+$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
+-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+-H "X-NAPALM-Username: foo" \
+-H "X-NAPALM-Password: bar"
+```
+
+## Method Support
+
+The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. NetBox only supports [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
+
+## Multiple Methods
+
+More than one method in an API call can be invoked by adding multiple `method` parameters. For example:
+
+```
+GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
+
+{
+    "get_ntp_servers": {
+        ...
+    },
+    "get_ntp_peers": {
+        ...
+    }
+}
+```
+
+## Optional Arguments
+
+The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`.
+
+
+For instance, the SSH port is changed to 2222 in this API call:
+
+```
+$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
+-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+-H "X-NAPALM-port: 2222"
+```

+ 8 - 0
docs/release-notes/version-2.6.md

@@ -2,22 +2,30 @@
 
 ## Enhancements
 
+* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger
 * [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering the link
+* [#2113](https://github.com/netbox-community/netbox/issues/2113) - Allow NAPALM driver settings to be changed with request headers
 * [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle for showing available prefixes/ip addresses
 * [#3009](https://github.com/netbox-community/netbox/issues/3009) - Search by description when assigning IP address
 * [#3090](https://github.com/netbox-community/netbox/issues/3090) - Add filter field for device interfaces
 * [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations
+* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate the circuits at the provider details view
 * [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total length to cable trace
+* [#3623](https://github.com/netbox-community/netbox/issues/3623) - Add word expansion during interface creation
 * [#3668](https://github.com/netbox-community/netbox/issues/3668) - Search by DNS name when assigning IP address
 * [#3851](https://github.com/netbox-community/netbox/issues/3851) - Allow passing initial data to custom script forms
 
 ## Bug Fixes
 
 * [#3589](https://github.com/netbox-community/netbox/issues/3589) - Fix validation on tagged VLANs of an interface
+* [#3849](https://github.com/netbox-community/netbox/issues/3849) - Fix ordering of models when dumping data to JSON
 * [#3853](https://github.com/netbox-community/netbox/issues/3853) - Fix device role link on config context view
 * [#3856](https://github.com/netbox-community/netbox/issues/3856) - Allow filtering VM interfaces by multiple MAC addresses
 * [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix group custom links rendering
 * [#3862](https://github.com/netbox-community/netbox/issues/3862) - Allow filtering device components by multiple device names
+* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks
+* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs of an address
+* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fixed min/max to ASN input field at the site creation page
 
 ---
 

+ 1 - 0
mkdocs.yml

@@ -35,6 +35,7 @@ pages:
         - Custom Scripts: 'additional-features/custom-scripts.md'
         - Export Templates: 'additional-features/export-templates.md'
         - Graphs: 'additional-features/graphs.md'
+        - NAPALM: 'additional-features/napalm.md'
         - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
         - Reports: 'additional-features/reports.md'
         - Tags: 'additional-features/tags.md'

+ 13 - 1
netbox/circuits/views.py

@@ -1,3 +1,4 @@
+from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -5,9 +6,11 @@ from django.db import transaction
 from django.db.models import Count, OuterRef, Subquery
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views.generic import View
+from django_tables2 import RequestConfig
 
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from utilities.forms import ConfirmationForm
+from utilities.paginator import EnhancedPaginator
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
@@ -38,9 +41,18 @@ class ProviderView(PermissionRequiredMixin, View):
         circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
         show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
 
+        circuits_table = tables.CircuitTable(circuits, orderable=False)
+        circuits_table.columns.hide('provider')
+
+        paginate = {
+            'paginator_class': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(circuits_table)
+
         return render(request, 'circuits/provider.html', {
             'provider': provider,
-            'circuits': circuits,
+            'circuits_table': circuits_table,
             'show_graphs': show_graphs,
         })
 

+ 4 - 0
netbox/dcim/api/serializers.py

@@ -370,6 +370,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
         return obj.get_config_context()
 
 
+class DeviceNAPALMSerializer(serializers.Serializer):
+    method = serializers.DictField()
+
+
 class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     cable = NestedCableSerializer(read_only=True)

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

@@ -358,6 +358,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
 
         return Response(serializer.data)
 
+    @swagger_auto_schema(
+        manual_parameters=[
+            Parameter(
+                name='method',
+                in_='query',
+                required=True,
+                type=openapi.TYPE_STRING
+            )
+        ],
+        responses={'200': serializers.DeviceNAPALMSerializer}
+    )
     @action(detail=True, url_path='napalm')
     def napalm(self, request, pk):
         """
@@ -396,13 +407,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
         napalm_methods = request.GET.getlist('method')
         response = OrderedDict([(m, None) for m in napalm_methods])
         ip_address = str(device.primary_ip.address.ip)
+        username = settings.NAPALM_USERNAME
+        password = settings.NAPALM_PASSWORD
         optional_args = settings.NAPALM_ARGS.copy()
         if device.platform.napalm_args is not None:
             optional_args.update(device.platform.napalm_args)
+
+        # Update NAPALM parameters according to the request headers
+        for header in request.headers:
+            if header[:9].lower() != 'x-napalm-':
+                continue
+
+            key = header[9:]
+            if key.lower() == 'username':
+                username = request.headers[header]
+            elif key.lower() == 'password':
+                password = request.headers[header]
+            elif key:
+                optional_args[key.lower()] = request.headers[header]
+
         d = driver(
             hostname=ip_address,
-            username=settings.NAPALM_USERNAME,
-            password=settings.NAPALM_PASSWORD,
+            username=username,
+            password=password,
             timeout=settings.NAPALM_TIMEOUT,
             optional_args=optional_args
         )

+ 4 - 0
netbox/dcim/constants.py

@@ -1,4 +1,8 @@
 
+# BGP ASN bounds
+BGP_ASN_MIN = 1
+BGP_ASN_MAX = 2**32 - 1
+
 # Rack types
 RACK_TYPE_2POST = 100
 RACK_TYPE_4POST = 200

+ 9 - 2
netbox/dcim/fields.py

@@ -3,14 +3,21 @@ from django.core.validators import MinValueValidator, MaxValueValidator
 from django.db import models
 from netaddr import AddrFormatError, EUI, mac_unix_expanded
 
+from .constants import *
+
 
 class ASNField(models.BigIntegerField):
     description = "32-bit ASN field"
     default_validators = [
-        MinValueValidator(1),
-        MaxValueValidator(4294967295),
+        MinValueValidator(BGP_ASN_MIN),
+        MaxValueValidator(BGP_ASN_MAX),
     ]
 
+    def formfield(self, **kwargs):
+        defaults = {'min_value': BGP_ASN_MIN, 'max_value': BGP_ASN_MAX}
+        defaults.update(**kwargs)
+        return super().formfield(**defaults)
+
 
 class mac_unix_expanded_uppercase(mac_unix_expanded):
     word_fmt = '%.2X'

+ 2 - 2
netbox/dcim/forms.py

@@ -292,8 +292,8 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         )
     )
     asn = forms.IntegerField(
-        min_value=1,
-        max_value=4294967295,
+        min_value=BGP_ASN_MIN,
+        max_value=BGP_ASN_MAX,
         required=False,
         label='ASN'
     )

+ 181 - 181
netbox/dcim/models.py

@@ -2755,6 +2755,187 @@ class VirtualChassis(ChangeLoggedModel):
         )
 
 
+#
+# Power
+#
+
+class PowerPanel(ChangeLoggedModel):
+    """
+    A distribution point for electrical power; e.g. a data center RPP.
+    """
+    site = models.ForeignKey(
+        to='Site',
+        on_delete=models.PROTECT
+    )
+    rack_group = models.ForeignKey(
+        to='RackGroup',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=50
+    )
+
+    csv_headers = ['site', 'rack_group_name', 'name']
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = ['site', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:powerpanel', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.site.name,
+            self.rack_group.name if self.rack_group else None,
+            self.name,
+        )
+
+    def clean(self):
+
+        # RackGroup must belong to assigned Site
+        if self.rack_group and self.rack_group.site != self.site:
+            raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
+                self.rack_group, self.rack_group.site, self.site
+            ))
+
+
+class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
+    """
+    An electrical circuit delivered from a PowerPanel.
+    """
+    power_panel = models.ForeignKey(
+        to='PowerPanel',
+        on_delete=models.PROTECT,
+        related_name='powerfeeds'
+    )
+    rack = models.ForeignKey(
+        to='Rack',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    connected_endpoint = models.OneToOneField(
+        to='dcim.PowerPort',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    status = models.PositiveSmallIntegerField(
+        choices=POWERFEED_STATUS_CHOICES,
+        default=POWERFEED_STATUS_ACTIVE
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=POWERFEED_TYPE_CHOICES,
+        default=POWERFEED_TYPE_PRIMARY
+    )
+    supply = models.PositiveSmallIntegerField(
+        choices=POWERFEED_SUPPLY_CHOICES,
+        default=POWERFEED_SUPPLY_AC
+    )
+    phase = models.PositiveSmallIntegerField(
+        choices=POWERFEED_PHASE_CHOICES,
+        default=POWERFEED_PHASE_SINGLE
+    )
+    voltage = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1)],
+        default=120
+    )
+    amperage = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1)],
+        default=20
+    )
+    max_utilization = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1), MaxValueValidator(100)],
+        default=80,
+        help_text="Maximum permissible draw (percentage)"
+    )
+    available_power = models.PositiveSmallIntegerField(
+        default=0,
+        editable=False
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = [
+        'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+        'amperage', 'max_utilization', 'comments',
+    ]
+
+    class Meta:
+        ordering = ['power_panel', 'name']
+        unique_together = ['power_panel', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:powerfeed', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.power_panel.site.name,
+            self.power_panel.name,
+            self.rack.group.name if self.rack and self.rack.group else None,
+            self.rack.name if self.rack else None,
+            self.name,
+            self.get_status_display(),
+            self.get_type_display(),
+            self.get_supply_display(),
+            self.get_phase_display(),
+            self.voltage,
+            self.amperage,
+            self.max_utilization,
+            self.comments,
+        )
+
+    def clean(self):
+
+        # Rack must belong to same Site as PowerPanel
+        if self.rack and self.rack.site != self.power_panel.site:
+            raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
+                self.rack, self.rack.site, self.power_panel, self.power_panel.site
+            ))
+
+    def save(self, *args, **kwargs):
+
+        # Cache the available_power property on the instance
+        kva = self.voltage * self.amperage * (self.max_utilization / 100)
+        if self.phase == POWERFEED_PHASE_3PHASE:
+            self.available_power = round(kva * 1.732)
+        else:
+            self.available_power = round(kva)
+
+        super().save(*args, **kwargs)
+
+    def get_type_class(self):
+        return STATUS_CLASSES[self.type]
+
+    def get_status_class(self):
+        return STATUS_CLASSES[self.status]
+
+
 #
 # Cables
 #
@@ -3008,184 +3189,3 @@ class Cable(ChangeLoggedModel):
         b_endpoint = b_path[-1][2]
 
         return a_endpoint, b_endpoint, path_status
-
-
-#
-# Power
-#
-
-class PowerPanel(ChangeLoggedModel):
-    """
-    A distribution point for electrical power; e.g. a data center RPP.
-    """
-    site = models.ForeignKey(
-        to='Site',
-        on_delete=models.PROTECT
-    )
-    rack_group = models.ForeignKey(
-        to='RackGroup',
-        on_delete=models.PROTECT,
-        blank=True,
-        null=True
-    )
-    name = models.CharField(
-        max_length=50
-    )
-
-    csv_headers = ['site', 'rack_group_name', 'name']
-
-    class Meta:
-        ordering = ['site', 'name']
-        unique_together = ['site', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:powerpanel', args=[self.pk])
-
-    def to_csv(self):
-        return (
-            self.site.name,
-            self.rack_group.name if self.rack_group else None,
-            self.name,
-        )
-
-    def clean(self):
-
-        # RackGroup must belong to assigned Site
-        if self.rack_group and self.rack_group.site != self.site:
-            raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
-                self.rack_group, self.rack_group.site, self.site
-            ))
-
-
-class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
-    """
-    An electrical circuit delivered from a PowerPanel.
-    """
-    power_panel = models.ForeignKey(
-        to='PowerPanel',
-        on_delete=models.PROTECT,
-        related_name='powerfeeds'
-    )
-    rack = models.ForeignKey(
-        to='Rack',
-        on_delete=models.PROTECT,
-        blank=True,
-        null=True
-    )
-    connected_endpoint = models.OneToOneField(
-        to='dcim.PowerPort',
-        on_delete=models.SET_NULL,
-        related_name='+',
-        blank=True,
-        null=True
-    )
-    connection_status = models.NullBooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        blank=True
-    )
-    name = models.CharField(
-        max_length=50
-    )
-    status = models.PositiveSmallIntegerField(
-        choices=POWERFEED_STATUS_CHOICES,
-        default=POWERFEED_STATUS_ACTIVE
-    )
-    type = models.PositiveSmallIntegerField(
-        choices=POWERFEED_TYPE_CHOICES,
-        default=POWERFEED_TYPE_PRIMARY
-    )
-    supply = models.PositiveSmallIntegerField(
-        choices=POWERFEED_SUPPLY_CHOICES,
-        default=POWERFEED_SUPPLY_AC
-    )
-    phase = models.PositiveSmallIntegerField(
-        choices=POWERFEED_PHASE_CHOICES,
-        default=POWERFEED_PHASE_SINGLE
-    )
-    voltage = models.PositiveSmallIntegerField(
-        validators=[MinValueValidator(1)],
-        default=120
-    )
-    amperage = models.PositiveSmallIntegerField(
-        validators=[MinValueValidator(1)],
-        default=20
-    )
-    max_utilization = models.PositiveSmallIntegerField(
-        validators=[MinValueValidator(1), MaxValueValidator(100)],
-        default=80,
-        help_text="Maximum permissible draw (percentage)"
-    )
-    available_power = models.PositiveSmallIntegerField(
-        default=0,
-        editable=False
-    )
-    comments = models.TextField(
-        blank=True
-    )
-    custom_field_values = GenericRelation(
-        to='extras.CustomFieldValue',
-        content_type_field='obj_type',
-        object_id_field='obj_id'
-    )
-
-    tags = TaggableManager(through=TaggedItem)
-
-    csv_headers = [
-        'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
-        'amperage', 'max_utilization', 'comments',
-    ]
-
-    class Meta:
-        ordering = ['power_panel', 'name']
-        unique_together = ['power_panel', 'name']
-
-    def __str__(self):
-        return self.name
-
-    def get_absolute_url(self):
-        return reverse('dcim:powerfeed', args=[self.pk])
-
-    def to_csv(self):
-        return (
-            self.power_panel.site.name,
-            self.power_panel.name,
-            self.rack.group.name if self.rack and self.rack.group else None,
-            self.rack.name if self.rack else None,
-            self.name,
-            self.get_status_display(),
-            self.get_type_display(),
-            self.get_supply_display(),
-            self.get_phase_display(),
-            self.voltage,
-            self.amperage,
-            self.max_utilization,
-            self.comments,
-        )
-
-    def clean(self):
-
-        # Rack must belong to same Site as PowerPanel
-        if self.rack and self.rack.site != self.power_panel.site:
-            raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
-                self.rack, self.rack.site, self.power_panel, self.power_panel.site
-            ))
-
-    def save(self, *args, **kwargs):
-
-        # Cache the available_power property on the instance
-        kva = self.voltage * self.amperage * (self.max_utilization / 100)
-        if self.phase == POWERFEED_PHASE_3PHASE:
-            self.available_power = round(kva * 1.732)
-        else:
-            self.available_power = round(kva)
-
-        super().save(*args, **kwargs)
-
-    def get_type_class(self):
-        return STATUS_CLASSES[self.type]
-
-    def get_status_class(self):
-        return STATUS_CLASSES[self.status]

+ 18 - 0
netbox/ipam/models.py

@@ -177,6 +177,12 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
             # Clear host bits from prefix
             self.prefix = self.prefix.cidr
 
+            # /0 masks are not acceptable
+            if self.prefix.prefixlen == 0:
+                raise ValidationError({
+                    'prefix': "Cannot create aggregate with /0 mask."
+                })
+
             # Ensure that the aggregate being added is not covered by an existing aggregate
             covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix))
             if self.pk:
@@ -347,6 +353,12 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
 
         if self.prefix:
 
+            # /0 masks are not acceptable
+            if self.prefix.prefixlen == 0:
+                raise ValidationError({
+                    'prefix': "Cannot create prefix with /0 mask."
+                })
+
             # Disallow host masks
             if self.prefix.version == 4 and self.prefix.prefixlen == 32:
                 raise ValidationError({
@@ -622,6 +634,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
 
         if self.address:
 
+            # /0 masks are not acceptable
+            if self.address.prefixlen == 0:
+                raise ValidationError({
+                    'address': "Cannot create IP address with /0 mask."
+                })
+
             # Enforce unique IP space (if applicable)
             if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
                 self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE

+ 8 - 1
netbox/ipam/views.py

@@ -686,7 +686,14 @@ class IPAddressView(PermissionRequiredMixin, View):
         ).filter(
             vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
         )
-        related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False)
+
+        related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
+
+        paginate = {
+            'paginator_class': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(related_ips_table)
 
         return render(request, 'ipam/ipaddress.html', {
             'ipaddress': ipaddress,

+ 2 - 52
netbox/templates/circuits/provider.html

@@ -125,58 +125,7 @@
             <div class="panel-heading">
                 <strong>Circuits</strong>
             </div>
-            <table class="table table-hover panel-body">
-                <tr>
-                    <th>Circuit ID</th>
-                    <th>Type</th>
-                    <th>Tenant</th>
-                    <th>A Side</th>
-                    <th>Z Side</th>
-                    <th>Description</th>
-                </tr>
-                {% for c in circuits %}
-                    <tr>
-                        <td>
-                            <a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
-                        </td>
-                        <td>
-                            <a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
-                        </td>
-                        <td>
-                            {% if c.tenant %}
-                                <a href="{% url 'tenancy:tenant' slug=c.tenant.slug %}">{{ c.tenant }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                        <td>
-                            {% if c.termination_a %}
-                                <a href="{% url 'dcim:site' slug=c.termination_a.site.slug %}">{{ c.termination_a.site }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                        <td>
-                            {% if c.termination_z %}
-                                <a href="{% url 'dcim:site' slug=c.termination_z.site.slug %}">{{ c.termination_z.site }}</a>
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                        <td>
-                            {% if c.description %}
-                                {{ c.description }}
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                {% empty %}
-                    <tr>
-                        <td colspan="6" class="text-muted">None</td>
-                    </tr>
-                {% endfor %}
-            </table>
+            {% include 'inc/table.html' with table=circuits_table %}
             {% if perms.circuits.add_circuit %}
                 <div class="panel-footer text-right noprint">
                     <a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
@@ -185,6 +134,7 @@
                 </div>
             {% endif %}
         </div>
+    {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
     </div>
 </div>
 {% include 'inc/modal.html' with modal_name='graphs' %}

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

@@ -160,7 +160,7 @@
         {% if duplicate_ips_table.rows %}
             {% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
         {% endif %}
-        {% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default noprint' %}
+        {% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
 	</div>
 </div>
 {% endblock %}

+ 11 - 2
netbox/utilities/forms.py

@@ -60,8 +60,16 @@ def parse_alphanumeric_range(string):
             for n in list(range(int(begin), int(end) + 1)):
                 values.append(n)
         else:
-            for n in list(range(ord(begin), ord(end) + 1)):
-                values.append(chr(n))
+            # Value-based
+            if begin == end:
+                values.append(begin)
+            # Range-based
+            else:
+                # Not a valid range (more than a single character)
+                if not len(begin) == len(end) == 1:
+                    raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range))
+                for n in list(range(ord(begin), ord(end) + 1)):
+                    values.append(chr(n))
     return values
 
 
@@ -481,6 +489,7 @@ class ExpandableNameField(forms.CharField):
                              'Mixed cases and types within a single range are not supported.<br />' \
                              'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
                              '<li><code>e[0-3][a-d,f]</code></li>' \
+                             '<li><code>[xe,ge]-0/0/0</code></li>' \
                              '<li><code>e[0-3,a-d,f]</code></li></ul>'
 
     def to_python(self, value):

+ 283 - 0
netbox/utilities/tests/test_forms.py

@@ -0,0 +1,283 @@
+from django import forms
+from django.test import TestCase
+
+from utilities.forms import *
+
+
+class ExpandIPAddress(TestCase):
+    """
+    Validate the operation of expand_ipaddress_pattern().
+    """
+    def test_ipv4_range(self):
+        input = '1.2.3.[9-10]/32'
+        output = sorted([
+            '1.2.3.9/32',
+            '1.2.3.10/32',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
+
+    def test_ipv4_set(self):
+        input = '1.2.3.[4,44]/32'
+        output = sorted([
+            '1.2.3.4/32',
+            '1.2.3.44/32',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
+
+    def test_ipv4_multiple_ranges(self):
+        input = '1.[9-10].3.[9-11]/32'
+        output = sorted([
+            '1.9.3.9/32',
+            '1.9.3.10/32',
+            '1.9.3.11/32',
+            '1.10.3.9/32',
+            '1.10.3.10/32',
+            '1.10.3.11/32',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
+
+    def test_ipv4_multiple_sets(self):
+        input = '1.[2,22].3.[4,44]/32'
+        output = sorted([
+            '1.2.3.4/32',
+            '1.2.3.44/32',
+            '1.22.3.4/32',
+            '1.22.3.44/32',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
+
+    def test_ipv4_set_and_range(self):
+        input = '1.[2,22].3.[9-11]/32'
+        output = sorted([
+            '1.2.3.9/32',
+            '1.2.3.10/32',
+            '1.2.3.11/32',
+            '1.22.3.9/32',
+            '1.22.3.10/32',
+            '1.22.3.11/32',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 4)), output)
+
+    def test_ipv6_range(self):
+        input = 'fec::abcd:[9-b]/64'
+        output = sorted([
+            'fec::abcd:9/64',
+            'fec::abcd:a/64',
+            'fec::abcd:b/64',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
+
+    def test_ipv6_range_multichar_field(self):
+        input = 'fec::abcd:[f-11]/64'
+        output = sorted([
+            'fec::abcd:f/64',
+            'fec::abcd:10/64',
+            'fec::abcd:11/64',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
+
+    def test_ipv6_set(self):
+        input = 'fec::abcd:[9,ab]/64'
+        output = sorted([
+            'fec::abcd:9/64',
+            'fec::abcd:ab/64',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
+
+    def test_ipv6_multiple_ranges(self):
+        input = 'fec::[1-2]bcd:[9-b]/64'
+        output = sorted([
+            'fec::1bcd:9/64',
+            'fec::1bcd:a/64',
+            'fec::1bcd:b/64',
+            'fec::2bcd:9/64',
+            'fec::2bcd:a/64',
+            'fec::2bcd:b/64',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
+
+    def test_ipv6_multiple_sets(self):
+        input = 'fec::[a,f]bcd:[9,ab]/64'
+        output = sorted([
+            'fec::abcd:9/64',
+            'fec::abcd:ab/64',
+            'fec::fbcd:9/64',
+            'fec::fbcd:ab/64',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
+
+    def test_ipv6_set_and_range(self):
+        input = 'fec::[dead,beaf]:[9-b]/64'
+        output = sorted([
+            'fec::dead:9/64',
+            'fec::dead:a/64',
+            'fec::dead:b/64',
+            'fec::beaf:9/64',
+            'fec::beaf:a/64',
+            'fec::beaf:b/64',
+        ])
+
+        self.assertEqual(sorted(expand_ipaddress_pattern(input, 6)), output)
+
+    def test_invalid_address_family(self):
+        with self.assertRaisesRegex(Exception, 'Invalid IP address family: 5'):
+            sorted(expand_ipaddress_pattern(None, 5))
+
+    def test_invalid_non_pattern(self):
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.4/32', 4))
+
+    def test_invalid_range(self):
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[4-]/32', 4))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[-4]/32', 4))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[4--5]/32', 4))
+
+    def test_invalid_range_bounds(self):
+        self.assertEqual(sorted(expand_ipaddress_pattern('1.2.3.[4-3]/32', 6)), [])
+
+    def test_invalid_set(self):
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[4]/32', 4))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[4,]/32', 4))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[,4]/32', 4))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_ipaddress_pattern('1.2.3.[4,,5]/32', 4))
+
+
+class ExpandAlphanumeric(TestCase):
+    """
+    Validate the operation of expand_alphanumeric_pattern().
+    """
+    def test_range_numberic(self):
+        input = 'r[9-11]a'
+        output = sorted([
+            'r9a',
+            'r10a',
+            'r11a',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_range_alpha(self):
+        input = '[r-t]1a'
+        output = sorted([
+            'r1a',
+            's1a',
+            't1a',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_set(self):
+        input = '[r,t]1a'
+        output = sorted([
+            'r1a',
+            't1a',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_set_multichar(self):
+        input = '[ra,tb]1a'
+        output = sorted([
+            'ra1a',
+            'tb1a',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_multiple_ranges(self):
+        input = '[r-t]1[a-b]'
+        output = sorted([
+            'r1a',
+            'r1b',
+            's1a',
+            's1b',
+            't1a',
+            't1b',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_multiple_sets(self):
+        input = '[ra,tb]1[ax,by]'
+        output = sorted([
+            'ra1ax',
+            'ra1by',
+            'tb1ax',
+            'tb1by',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_set_and_range(self):
+        input = '[ra,tb]1[a-c]'
+        output = sorted([
+            'ra1a',
+            'ra1b',
+            'ra1c',
+            'tb1a',
+            'tb1b',
+            'tb1c',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_invalid_non_pattern(self):
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r9a'))
+
+    def test_invalid_range(self):
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[8-]a'))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[-8]a'))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[8--9]a'))
+
+    def test_invalid_range_alphanumeric(self):
+        self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-a]a')), [])
+        self.assertEqual(sorted(expand_alphanumeric_pattern('r[a-9]a')), [])
+
+    def test_invalid_range_bounds(self):
+        self.assertEqual(sorted(expand_alphanumeric_pattern('r[9-8]a')), [])
+        self.assertEqual(sorted(expand_alphanumeric_pattern('r[b-a]a')), [])
+
+    def test_invalid_range_len(self):
+        with self.assertRaises(forms.ValidationError):
+            sorted(expand_alphanumeric_pattern('r[a-bb]a'))
+
+    def test_invalid_set(self):
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[a]a'))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[a,]a'))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[,a]a'))
+
+        with self.assertRaises(ValueError):
+            sorted(expand_alphanumeric_pattern('r[a,,b]a'))