mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-20 02:06:42 -06:00
commit
e17d79e10f
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -17,15 +17,20 @@ about: Report a reproducible bug in the current release of NetBox
|
|||||||
-->
|
-->
|
||||||
### Environment
|
### Environment
|
||||||
* Python version: <!-- Example: 3.5.4 -->
|
* Python version: <!-- Example: 3.5.4 -->
|
||||||
* NetBox version: <!-- Example: 2.3.6 -->
|
* NetBox version: <!-- Example: 2.5.2 -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Describe in detail the steps that someone else can take to reproduce this
|
Describe in detail the exact steps that someone else can take to reproduce
|
||||||
bug using the current stable release of NetBox (or the current beta release
|
this bug using the current stable release of NetBox (or the current beta
|
||||||
where applicable).
|
release where applicable). Begin with the creation of any necessary
|
||||||
|
database objects and call out every operation being performed explicitly.
|
||||||
|
If reporting a bug in the REST API, be sure to reconstruct the raw HTTP
|
||||||
|
request(s) being made: Don't rely on a wrapper like pynetbox.
|
||||||
-->
|
-->
|
||||||
### Steps to Reproduce
|
### Steps to Reproduce
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
<!-- What did you expect to happen? -->
|
<!-- What did you expect to happen? -->
|
||||||
### Expected Behavior
|
### Expected Behavior
|
||||||
|
22
CHANGELOG.md
22
CHANGELOG.md
@ -1,3 +1,25 @@
|
|||||||
|
v2.5.3 (2019-01-11)
|
||||||
|
|
||||||
|
## Enhancements
|
||||||
|
|
||||||
|
* [#1630](https://github.com/digitalocean/netbox/issues/1630) - Enable bulk editing of prefix/IP mask length
|
||||||
|
* [#1870](https://github.com/digitalocean/netbox/issues/1870) - Add per-page toggle to object lists
|
||||||
|
* [#1871](https://github.com/digitalocean/netbox/issues/1871) - Enable filtering sites by parent region
|
||||||
|
* [#1983](https://github.com/digitalocean/netbox/issues/1983) - Enable regular expressions when bulk renaming device components
|
||||||
|
* [#2682](https://github.com/digitalocean/netbox/issues/2682) - Add DAC and AOC cable types
|
||||||
|
* [#2693](https://github.com/digitalocean/netbox/issues/2693) - Additional cable colors
|
||||||
|
* [#2726](https://github.com/digitalocean/netbox/issues/2726) - Include cables in global search
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
* [#2742](https://github.com/digitalocean/netbox/issues/2742) - Preserve cluster assignment when editing a device
|
||||||
|
* [#2757](https://github.com/digitalocean/netbox/issues/2757) - Always treat first/last IPs within a /31 or /127 as usable
|
||||||
|
* [#2762](https://github.com/digitalocean/netbox/issues/2762) - Add missing DCIM field values to API `_choices` endpoint
|
||||||
|
* [#2777](https://github.com/digitalocean/netbox/issues/2777) - Fix cable validation to handle duplicate connections on import
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
v2.5.2 (2018-12-21)
|
v2.5.2 (2018-12-21)
|
||||||
|
|
||||||
## Enhancements
|
## Enhancements
|
||||||
|
@ -35,13 +35,18 @@ from .exceptions import MissingFilterException
|
|||||||
|
|
||||||
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||||
fields = (
|
fields = (
|
||||||
(Cable, ['length_unit']),
|
(Cable, ['length_unit', 'status', 'type']),
|
||||||
(Device, ['face', 'status']),
|
|
||||||
(ConsolePort, ['connection_status']),
|
(ConsolePort, ['connection_status']),
|
||||||
(Interface, ['connection_status', 'form_factor', 'mode']),
|
(Device, ['face', 'status']),
|
||||||
|
(DeviceType, ['subdevice_role']),
|
||||||
|
(FrontPort, ['type']),
|
||||||
|
(FrontPortTemplate, ['type']),
|
||||||
|
(Interface, ['form_factor', 'mode']),
|
||||||
(InterfaceTemplate, ['form_factor']),
|
(InterfaceTemplate, ['form_factor']),
|
||||||
(PowerPort, ['connection_status']),
|
(PowerPort, ['connection_status']),
|
||||||
(Rack, ['outer_unit', 'status', 'type', 'width']),
|
(Rack, ['outer_unit', 'status', 'type', 'width']),
|
||||||
|
(RearPort, ['type']),
|
||||||
|
(RearPortTemplate, ['type']),
|
||||||
(Site, ['status']),
|
(Site, ['status']),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -339,11 +339,14 @@ CABLE_TYPE_CAT5E = 1510
|
|||||||
CABLE_TYPE_CAT6 = 1600
|
CABLE_TYPE_CAT6 = 1600
|
||||||
CABLE_TYPE_CAT6A = 1610
|
CABLE_TYPE_CAT6A = 1610
|
||||||
CABLE_TYPE_CAT7 = 1700
|
CABLE_TYPE_CAT7 = 1700
|
||||||
|
CABLE_TYPE_DAC_ACTIVE = 1800
|
||||||
|
CABLE_TYPE_DAC_PASSIVE = 1810
|
||||||
CABLE_TYPE_MMF_OM1 = 3010
|
CABLE_TYPE_MMF_OM1 = 3010
|
||||||
CABLE_TYPE_MMF_OM2 = 3020
|
CABLE_TYPE_MMF_OM2 = 3020
|
||||||
CABLE_TYPE_MMF_OM3 = 3030
|
CABLE_TYPE_MMF_OM3 = 3030
|
||||||
CABLE_TYPE_MMF_OM4 = 3040
|
CABLE_TYPE_MMF_OM4 = 3040
|
||||||
CABLE_TYPE_SMF = 3500
|
CABLE_TYPE_SMF = 3500
|
||||||
|
CABLE_TYPE_AOC = 3800
|
||||||
CABLE_TYPE_POWER = 5000
|
CABLE_TYPE_POWER = 5000
|
||||||
CABLE_TYPE_CHOICES = (
|
CABLE_TYPE_CHOICES = (
|
||||||
(
|
(
|
||||||
@ -354,6 +357,8 @@ CABLE_TYPE_CHOICES = (
|
|||||||
(CABLE_TYPE_CAT6, 'CAT6'),
|
(CABLE_TYPE_CAT6, 'CAT6'),
|
||||||
(CABLE_TYPE_CAT6A, 'CAT6a'),
|
(CABLE_TYPE_CAT6A, 'CAT6a'),
|
||||||
(CABLE_TYPE_CAT7, 'CAT7'),
|
(CABLE_TYPE_CAT7, 'CAT7'),
|
||||||
|
(CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
|
||||||
|
(CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -363,6 +368,7 @@ CABLE_TYPE_CHOICES = (
|
|||||||
(CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
|
(CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
|
||||||
(CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
|
(CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
|
||||||
(CABLE_TYPE_SMF, 'Singlemode Fiber'),
|
(CABLE_TYPE_SMF, 'Singlemode Fiber'),
|
||||||
|
(CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(CABLE_TYPE_POWER, 'Power'),
|
(CABLE_TYPE_POWER, 'Power'),
|
||||||
|
@ -62,14 +62,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
choices=SITE_STATUS_CHOICES,
|
choices=SITE_STATUS_CHOICES,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
region_id = django_filters.NumberFilter(
|
||||||
queryset=Region.objects.all(),
|
method='filter_region',
|
||||||
|
field_name='pk',
|
||||||
label='Region (ID)',
|
label='Region (ID)',
|
||||||
)
|
)
|
||||||
region = django_filters.ModelMultipleChoiceFilter(
|
region = django_filters.CharFilter(
|
||||||
field_name='region__slug',
|
method='filter_region',
|
||||||
queryset=Region.objects.all(),
|
field_name='slug',
|
||||||
to_field_name='slug',
|
|
||||||
label='Region (slug)',
|
label='Region (slug)',
|
||||||
)
|
)
|
||||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
@ -108,6 +108,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
|||||||
pass
|
pass
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
|
def filter_region(self, queryset, name, value):
|
||||||
|
try:
|
||||||
|
region = Region.objects.get(**{name: value})
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return queryset.none()
|
||||||
|
return queryset.filter(
|
||||||
|
Q(region=region) |
|
||||||
|
Q(region__in=region.get_descendants())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RackGroupFilter(django_filters.FilterSet):
|
class RackGroupFilter(django_filters.FilterSet):
|
||||||
q = django_filters.CharFilter(
|
q = django_filters.CharFilter(
|
||||||
|
@ -58,6 +58,22 @@ class BulkRenameForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
find = forms.CharField()
|
find = forms.CharField()
|
||||||
replace = forms.CharField()
|
replace = forms.CharField()
|
||||||
|
use_regex = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
label='Use regular expressions'
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
|
||||||
|
# Validate regular expression in "find" field
|
||||||
|
if self.cleaned_data['use_regex']:
|
||||||
|
try:
|
||||||
|
re.compile(self.cleaned_data['find'])
|
||||||
|
except re.error:
|
||||||
|
raise forms.ValidationError({
|
||||||
|
'find': "Invalid regular expression"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -236,9 +252,10 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
region = FilterTreeNodeMultipleChoiceField(
|
region = FilterTreeNodeMultipleChoiceField(
|
||||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
queryset=Region.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
required=False,
|
required=False,
|
||||||
|
count_attr='site_count'
|
||||||
)
|
)
|
||||||
tenant = FilterChoiceField(
|
tenant = FilterChoiceField(
|
||||||
queryset=Tenant.objects.annotate(filter_count=Count('sites')),
|
queryset=Tenant.objects.annotate(filter_count=Count('sites')),
|
||||||
@ -1212,11 +1229,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
|||||||
|
|
||||||
# Initialize helper selectors
|
# Initialize helper selectors
|
||||||
instance = kwargs.get('instance')
|
instance = kwargs.get('instance')
|
||||||
|
if 'initial' not in kwargs:
|
||||||
|
kwargs['initial'] = {}
|
||||||
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
|
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
|
||||||
if instance and hasattr(instance, 'device_type'):
|
if instance and hasattr(instance, 'device_type'):
|
||||||
initial = kwargs.get('initial', {}).copy()
|
kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
|
||||||
initial['manufacturer'] = instance.device_type.manufacturer
|
if instance and instance.cluster is not None:
|
||||||
kwargs['initial'] = initial
|
kwargs['initial']['cluster_group'] = instance.cluster.group
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@ -201,6 +201,13 @@ class Region(MPTTModel, ChangeLoggedModel):
|
|||||||
self.parent.name if self.parent else None,
|
self.parent.name if self.parent else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_count(self):
|
||||||
|
return Site.objects.filter(
|
||||||
|
Q(region=self) |
|
||||||
|
Q(region__in=self.get_descendants())
|
||||||
|
).count()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Sites
|
# Sites
|
||||||
@ -2551,52 +2558,55 @@ class Cable(ChangeLoggedModel):
|
|||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
|
||||||
# Check that termination types are compatible
|
if self.termination_a and self.termination_b:
|
||||||
type_a = self.termination_a_type.model
|
|
||||||
type_b = self.termination_b_type.model
|
|
||||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
|
||||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
|
||||||
self.termination_a_type, self.termination_b_type
|
|
||||||
))
|
|
||||||
|
|
||||||
# A termination point cannot be connected to itself
|
type_a = self.termination_a_type.model
|
||||||
if self.termination_a == self.termination_b:
|
type_b = self.termination_b_type.model
|
||||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
|
||||||
|
|
||||||
# A front port cannot be connected to its corresponding rear port
|
# Check that termination types are compatible
|
||||||
if (
|
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||||
type_a in ['frontport', 'rearport'] and
|
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||||
type_b in ['frontport', 'rearport'] and
|
self.termination_a_type, self.termination_b_type
|
||||||
(
|
))
|
||||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
|
||||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
|
||||||
|
|
||||||
# Check for an existing Cable connected to either termination object
|
# A termination point cannot be connected to itself
|
||||||
if self.termination_a.cable not in (None, self):
|
if self.termination_a == self.termination_b:
|
||||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||||
self.termination_a, self.termination_a.cable_id
|
|
||||||
))
|
|
||||||
if self.termination_b.cable not in (None, self):
|
|
||||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
|
||||||
self.termination_b, self.termination_b.cable_id
|
|
||||||
))
|
|
||||||
|
|
||||||
# Virtual interfaces cannot be connected
|
# A front port cannot be connected to its corresponding rear port
|
||||||
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
|
if (
|
||||||
if (
|
type_a in ['frontport', 'rearport'] and
|
||||||
(
|
type_b in ['frontport', 'rearport'] and
|
||||||
isinstance(endpoint_a, Interface) and
|
(
|
||||||
endpoint_a.form_factor == IFACE_FF_VIRTUAL
|
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||||
) or
|
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||||
(
|
)
|
||||||
isinstance(endpoint_b, Interface) and
|
):
|
||||||
endpoint_b.form_factor == IFACE_FF_VIRTUAL
|
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||||
)
|
|
||||||
):
|
# Check for an existing Cable connected to either termination object
|
||||||
raise ValidationError("Cannot connect to a virtual interface")
|
if self.termination_a.cable not in (None, self):
|
||||||
|
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||||
|
self.termination_a, self.termination_a.cable_id
|
||||||
|
))
|
||||||
|
if self.termination_b.cable not in (None, self):
|
||||||
|
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||||
|
self.termination_b, self.termination_b.cable_id
|
||||||
|
))
|
||||||
|
|
||||||
|
# Virtual interfaces cannot be connected
|
||||||
|
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
isinstance(endpoint_a, Interface) and
|
||||||
|
endpoint_a.form_factor == IFACE_FF_VIRTUAL
|
||||||
|
) or
|
||||||
|
(
|
||||||
|
isinstance(endpoint_b, Interface) and
|
||||||
|
endpoint_b.form_factor == IFACE_FF_VIRTUAL
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValidationError("Cannot connect to a virtual interface")
|
||||||
|
|
||||||
# Validate length and length_unit
|
# Validate length and length_unit
|
||||||
if self.length is not None and self.length_unit is None:
|
if self.length is not None and self.length_unit is None:
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||||
@ -50,7 +52,16 @@ class BulkRenameView(GetReturnURLMixin, View):
|
|||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
for obj in selected_objects:
|
for obj in selected_objects:
|
||||||
obj.new_name = obj.name.replace(form.cleaned_data['find'], form.cleaned_data['replace'])
|
find = form.cleaned_data['find']
|
||||||
|
replace = form.cleaned_data['replace']
|
||||||
|
if form.cleaned_data['use_regex']:
|
||||||
|
try:
|
||||||
|
obj.new_name = re.sub(find, replace, obj.name)
|
||||||
|
# Catch regex group reference errors
|
||||||
|
except re.error:
|
||||||
|
obj.new_name = obj.name
|
||||||
|
else:
|
||||||
|
obj.new_name = obj.name.replace(find, replace)
|
||||||
|
|
||||||
if '_apply' in request.POST:
|
if '_apply' in request.POST:
|
||||||
for obj in selected_objects:
|
for obj in selected_objects:
|
||||||
@ -124,7 +135,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class RegionListView(ObjectListView):
|
class RegionListView(ObjectListView):
|
||||||
queryset = Region.objects.annotate(site_count=Count('sites'))
|
queryset = Region.objects.all()
|
||||||
filter = filters.RegionFilter
|
filter = filters.RegionFilter
|
||||||
filter_form = forms.RegionFilterForm
|
filter_form = forms.RegionFilterForm
|
||||||
table = tables.RegionTable
|
table = tables.RegionTable
|
||||||
|
@ -422,6 +422,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
|||||||
required=False,
|
required=False,
|
||||||
label='VRF'
|
label='VRF'
|
||||||
)
|
)
|
||||||
|
prefix_length = forms.IntegerField(
|
||||||
|
min_value=1,
|
||||||
|
max_value=127,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
tenant = forms.ModelChoiceField(
|
tenant = forms.ModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
@ -819,6 +824,11 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
|
|||||||
required=False,
|
required=False,
|
||||||
label='VRF'
|
label='VRF'
|
||||||
)
|
)
|
||||||
|
mask_length = forms.IntegerField(
|
||||||
|
min_value=1,
|
||||||
|
max_value=128,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
tenant = forms.ModelChoiceField(
|
tenant = forms.ModelChoiceField(
|
||||||
queryset=Tenant.objects.all(),
|
queryset=Tenant.objects.all(),
|
||||||
required=False
|
required=False
|
||||||
|
@ -385,6 +385,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
|||||||
self.description,
|
self.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _set_prefix_length(self, value):
|
||||||
|
"""
|
||||||
|
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
|
||||||
|
e.g. for bulk editing.
|
||||||
|
"""
|
||||||
|
if self.prefix is not None:
|
||||||
|
self.prefix.prefixlen = value
|
||||||
|
prefix_length = property(fset=_set_prefix_length)
|
||||||
|
|
||||||
def get_status_class(self):
|
def get_status_class(self):
|
||||||
return STATUS_CHOICE_CLASSES[self.status]
|
return STATUS_CHOICE_CLASSES[self.status]
|
||||||
|
|
||||||
@ -429,12 +438,23 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
|||||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||||
available_ips = prefix - child_ips
|
available_ips = prefix - child_ips
|
||||||
|
|
||||||
# Remove unusable IPs from non-pool prefixes
|
# All IP addresses within a pool are considered usable
|
||||||
if not self.is_pool:
|
if self.is_pool:
|
||||||
available_ips -= netaddr.IPSet([
|
return available_ips
|
||||||
netaddr.IPAddress(self.prefix.first),
|
|
||||||
netaddr.IPAddress(self.prefix.last),
|
# All IP addresses within a point-to-point prefix (IPv4 /31 or IPv6 /127) are considered usable
|
||||||
])
|
if (
|
||||||
|
self.family == 4 and self.prefix.prefixlen == 31 # RFC 3021
|
||||||
|
) or (
|
||||||
|
self.family == 6 and self.prefix.prefixlen == 127 # RFC 6164
|
||||||
|
):
|
||||||
|
return available_ips
|
||||||
|
|
||||||
|
# Omit first and last IP address from the available set
|
||||||
|
available_ips -= netaddr.IPSet([
|
||||||
|
netaddr.IPAddress(self.prefix.first),
|
||||||
|
netaddr.IPAddress(self.prefix.last),
|
||||||
|
])
|
||||||
|
|
||||||
return available_ips
|
return available_ips
|
||||||
|
|
||||||
@ -630,6 +650,15 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
|||||||
self.description,
|
self.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _set_mask_length(self, value):
|
||||||
|
"""
|
||||||
|
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
|
||||||
|
e.g. for bulk editing.
|
||||||
|
"""
|
||||||
|
if self.address is not None:
|
||||||
|
self.address.prefixlen = value
|
||||||
|
mask_length = property(fset=_set_mask_length)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device(self):
|
def device(self):
|
||||||
if self.interface:
|
if self.interface:
|
||||||
|
@ -15,6 +15,7 @@ OBJ_TYPE_CHOICES = (
|
|||||||
('devicetype', 'Device types'),
|
('devicetype', 'Device types'),
|
||||||
('device', 'Devices'),
|
('device', 'Devices'),
|
||||||
('virtualchassis', 'Virtual Chassis'),
|
('virtualchassis', 'Virtual Chassis'),
|
||||||
|
('cable', 'Cables'),
|
||||||
)),
|
)),
|
||||||
('IPAM', (
|
('IPAM', (
|
||||||
('vrf', 'VRFs'),
|
('vrf', 'VRFs'),
|
||||||
|
@ -22,7 +22,7 @@ except ImportError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
VERSION = '2.5.2'
|
VERSION = '2.5.3'
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
@ -246,6 +246,14 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
|
|||||||
# Secrets
|
# Secrets
|
||||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
PER_PAGE_DEFAULTS = [
|
||||||
|
25, 50, 100, 250, 500, 1000
|
||||||
|
]
|
||||||
|
if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
|
||||||
|
PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
|
||||||
|
PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
|
||||||
|
|
||||||
# Django filters
|
# Django filters
|
||||||
FILTERS_NULL_CHOICE_LABEL = 'None'
|
FILTERS_NULL_CHOICE_LABEL = 'None'
|
||||||
FILTERS_NULL_CHOICE_VALUE = 'null'
|
FILTERS_NULL_CHOICE_VALUE = 'null'
|
||||||
|
@ -11,13 +11,13 @@ from circuits.filters import CircuitFilter, ProviderFilter
|
|||||||
from circuits.models import Circuit, Provider
|
from circuits.models import Circuit, Provider
|
||||||
from circuits.tables import CircuitTable, ProviderTable
|
from circuits.tables import CircuitTable, ProviderTable
|
||||||
from dcim.filters import (
|
from dcim.filters import (
|
||||||
DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
|
CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
|
||||||
)
|
)
|
||||||
from dcim.models import (
|
from dcim.models import (
|
||||||
Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
|
Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
|
||||||
)
|
)
|
||||||
from dcim.tables import (
|
from dcim.tables import (
|
||||||
DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
|
CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
|
||||||
)
|
)
|
||||||
from extras.models import ObjectChange, ReportResult, TopologyMap
|
from extras.models import ObjectChange, ReportResult, TopologyMap
|
||||||
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
|
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
|
||||||
@ -88,6 +88,12 @@ SEARCH_TYPES = OrderedDict((
|
|||||||
'table': VirtualChassisTable,
|
'table': VirtualChassisTable,
|
||||||
'url': 'dcim:virtualchassis_list',
|
'url': 'dcim:virtualchassis_list',
|
||||||
}),
|
}),
|
||||||
|
('cable', {
|
||||||
|
'queryset': Cable.objects.all(),
|
||||||
|
'filter': CableFilter,
|
||||||
|
'table': CableTable,
|
||||||
|
'url': 'dcim:cable_list',
|
||||||
|
}),
|
||||||
# IPAM
|
# IPAM
|
||||||
('vrf', {
|
('vrf', {
|
||||||
'queryset': VRF.objects.select_related('tenant'),
|
'queryset': VRF.objects.select_related('tenant'),
|
||||||
|
@ -140,6 +140,9 @@ table.attr-table td:nth-child(1) {
|
|||||||
div.paginator {
|
div.paginator {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
div.paginator form {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
nav ul.pagination {
|
nav ul.pagination {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 8px !important;
|
margin-bottom: 8px !important;
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
$('select#per_page').change(function() {
|
||||||
|
this.form.submit();
|
||||||
|
});
|
||||||
|
|
||||||
// "Toggle" checkbox for object lists (PK column)
|
// "Toggle" checkbox for object lists (PK column)
|
||||||
$('input:checkbox.toggle').click(function() {
|
$('input:checkbox.toggle').click(function() {
|
||||||
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<td colspan="3" class="method">
|
<td colspan="3" class="method">
|
||||||
{{ method }}
|
{{ method }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right report-stats">
|
<td class="text-right text-nowrap report-stats">
|
||||||
<label class="label label-success">{{ stats.success }}</label>
|
<label class="label label-success">{{ stats.success }}</label>
|
||||||
<label class="label label-info">{{ stats.info }}</label>
|
<label class="label label-info">{{ stats.info }}</label>
|
||||||
<label class="label label-warning">{{ stats.warning }}</label>
|
<label class="label label-warning">{{ stats.warning }}</label>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
<div class="paginator pull-right">
|
<div class="paginator pull-right text-right">
|
||||||
{% if paginator.num_pages > 1 %}
|
{% if paginator.num_pages > 1 %}
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination pull-right">
|
<ul class="pagination pull-right">
|
||||||
@ -19,6 +19,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
<form method="get">
|
||||||
|
{% for k, v in request.GET.items %}
|
||||||
|
{% if k != 'per_page' %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<select name="per_page" id="per_page">
|
||||||
|
{% for n in settings.PER_PAGE_DEFAULTS %}
|
||||||
|
<option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select> per page
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if page %}
|
{% if page %}
|
||||||
<div class="text-right text-muted">
|
<div class="text-right text-muted">
|
||||||
|
@ -3,6 +3,3 @@
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
{% render_table table 'inc/table.html' %}
|
{% render_table table 'inc/table.html' %}
|
||||||
</div>
|
</div>
|
||||||
{% with paginator=table.paginator page=table.page %}
|
|
||||||
{% include 'inc/paginator.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
|
@ -28,19 +28,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include table_template|default:'responsive_table.html' %}
|
{% include table_template|default:'responsive_table.html' %}
|
||||||
{% block extra_actions %}{% endblock %}
|
<div class="pull-left">
|
||||||
{% if bulk_edit_url and permissions.change %}
|
{% block extra_actions %}{% endblock %}
|
||||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
{% if bulk_edit_url and permissions.change %}
|
||||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
|
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
||||||
</button>
|
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
|
||||||
{% endif %}
|
</button>
|
||||||
{% if bulk_delete_url and permissions.delete %}
|
{% endif %}
|
||||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
{% if bulk_delete_url and permissions.delete %}
|
||||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
||||||
</button>
|
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||||
{% endif %}
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include table_template|default:'responsive_table.html' %}
|
{% include table_template|default:'responsive_table.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
|
@ -2,6 +2,7 @@ COLOR_CHOICES = (
|
|||||||
('aa1409', 'Dark red'),
|
('aa1409', 'Dark red'),
|
||||||
('f44336', 'Red'),
|
('f44336', 'Red'),
|
||||||
('e91e63', 'Pink'),
|
('e91e63', 'Pink'),
|
||||||
|
('ffe4e1', 'Rose'),
|
||||||
('ff66ff', 'Fuschia'),
|
('ff66ff', 'Fuschia'),
|
||||||
('9c27b0', 'Purple'),
|
('9c27b0', 'Purple'),
|
||||||
('673ab7', 'Dark purple'),
|
('673ab7', 'Dark purple'),
|
||||||
@ -10,6 +11,7 @@ COLOR_CHOICES = (
|
|||||||
('03a9f4', 'Light blue'),
|
('03a9f4', 'Light blue'),
|
||||||
('00bcd4', 'Cyan'),
|
('00bcd4', 'Cyan'),
|
||||||
('009688', 'Teal'),
|
('009688', 'Teal'),
|
||||||
|
('00ffff', 'Aqua'),
|
||||||
('2f6a31', 'Dark green'),
|
('2f6a31', 'Dark green'),
|
||||||
('4caf50', 'Green'),
|
('4caf50', 'Green'),
|
||||||
('8bc34a', 'Light green'),
|
('8bc34a', 'Light green'),
|
||||||
@ -23,4 +25,5 @@ COLOR_CHOICES = (
|
|||||||
('9e9e9e', 'Grey'),
|
('9e9e9e', 'Grey'),
|
||||||
('607d8b', 'Dark grey'),
|
('607d8b', 'Dark grey'),
|
||||||
('111111', 'Black'),
|
('111111', 'Black'),
|
||||||
|
('ffffff', 'White'),
|
||||||
)
|
)
|
||||||
|
@ -505,8 +505,9 @@ class FilterChoiceIterator(forms.models.ModelChoiceIterator):
|
|||||||
class FilterChoiceFieldMixin(object):
|
class FilterChoiceFieldMixin(object):
|
||||||
iterator = FilterChoiceIterator
|
iterator = FilterChoiceIterator
|
||||||
|
|
||||||
def __init__(self, null_label=None, *args, **kwargs):
|
def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs):
|
||||||
self.null_label = null_label
|
self.null_label = null_label
|
||||||
|
self.count_attr = count_attr
|
||||||
if 'required' not in kwargs:
|
if 'required' not in kwargs:
|
||||||
kwargs['required'] = False
|
kwargs['required'] = False
|
||||||
if 'widget' not in kwargs:
|
if 'widget' not in kwargs:
|
||||||
@ -515,8 +516,9 @@ class FilterChoiceFieldMixin(object):
|
|||||||
|
|
||||||
def label_from_instance(self, obj):
|
def label_from_instance(self, obj):
|
||||||
label = super().label_from_instance(obj)
|
label = super().label_from_instance(obj)
|
||||||
if hasattr(obj, 'filter_count'):
|
obj_count = getattr(obj, self.count_attr, None)
|
||||||
return '{} ({})'.format(label, obj.filter_count)
|
if obj_count is not None:
|
||||||
|
return '{} ({})'.format(label, obj_count)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user