mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 19:52:52 -06:00
Merge branch 'develop' into 568-csv-import-cf
This commit is contained in:
commit
d075bf5882
23
.github/lock.yml
vendored
Normal file
23
.github/lock.yml
vendored
Normal file
@ -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
.github/stale.yml
vendored
7
.github/stale.yml
vendored
@ -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
|
||||
|
@ -69,6 +69,14 @@ If the new field will be included in the object list view, add a column to the m
|
||||
|
||||
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
|
||||
|
||||
### 11. Adjust API and model tests
|
||||
### 11. Create/extend test cases
|
||||
|
||||
Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.
|
||||
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
|
||||
|
||||
* API serializer/view tests
|
||||
* Filter tests
|
||||
* Form tests
|
||||
* Model tests
|
||||
* View tests
|
||||
|
||||
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
|
||||
|
@ -1,17 +1,30 @@
|
||||
# v2.6.12 (FUTURE)
|
||||
# v2.6.13 (FUTURE)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV
|
||||
* [#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
|
||||
|
||||
---
|
||||
|
||||
# v2.6.12 (2020-01-13)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger (OpenAPI)
|
||||
* [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering over 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
|
||||
* [#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
|
||||
* [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle the display of child prefixes/IP addresses
|
||||
* [#3009](https://github.com/netbox-community/netbox/issues/3009) - Search by description when assigning IP address to interfaces
|
||||
* [#3021](https://github.com/netbox-community/netbox/issues/3021) - Add `tenant` filter field for cables
|
||||
* [#3090](https://github.com/netbox-community/netbox/issues/3090) - Enable filtering of interfaces by name on the device view
|
||||
* [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations view
|
||||
* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate assigned circuits at the provider details view
|
||||
* [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total path length to cable trace
|
||||
* [#3491](https://github.com/netbox-community/netbox/issues/3491) - Include content of response on webhook error
|
||||
* [#3623](https://github.com/netbox-community/netbox/issues/3623) - Enable word expansion during interface creation
|
||||
* [#3668](https://github.com/netbox-community/netbox/issues/3668) - Enable searching by DNS name when assigning IP address
|
||||
* [#3851](https://github.com/netbox-community/netbox/issues/3851) - Allow passing initial data to custom script forms
|
||||
* [#3891](https://github.com/netbox-community/netbox/issues/3891) - Add `local_context_data` filter for virtual machines
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
@ -19,11 +32,14 @@
|
||||
* [#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
|
||||
* [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix rendering of grouped custom links
|
||||
* [#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
|
||||
* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks for prefixes and IP addresses
|
||||
* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs on the IP address view
|
||||
* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fix minimum/maximum value rendering for site ASN field
|
||||
* [#3882](https://github.com/netbox-community/netbox/issues/3882) - Fix filtering of devices by rack group
|
||||
* [#3898](https://github.com/netbox-community/netbox/issues/3898) - Fix references to deleted cables without a label
|
||||
* [#3905](https://github.com/netbox-community/netbox/issues/3905) - Fix divide-by-zero on power feeds with low power values
|
||||
|
||||
---
|
||||
|
||||
|
@ -1050,6 +1050,14 @@ class CableFilter(django_filters.FilterSet):
|
||||
method='filter_device',
|
||||
field_name='device__site__slug'
|
||||
)
|
||||
tenant_id = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='device__tenant_id'
|
||||
)
|
||||
tenant = MultiValueNumberFilter(
|
||||
method='filter_device',
|
||||
field_name='device__tenant__slug'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
|
@ -1791,7 +1791,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
||||
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = Device
|
||||
field_order = [
|
||||
'q', 'region', 'site', 'group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
|
||||
'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant',
|
||||
'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip',
|
||||
]
|
||||
q = forms.CharField(
|
||||
@ -1817,12 +1817,12 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
filter_for={
|
||||
'group_id': 'site',
|
||||
'rack_group_id': 'site',
|
||||
'rack_id': 'site',
|
||||
}
|
||||
)
|
||||
)
|
||||
group_id = FilterChoiceField(
|
||||
rack_group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.prefetch_related(
|
||||
'site'
|
||||
),
|
||||
@ -2804,6 +2804,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f
|
||||
termination_b_provider = forms.ModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/circuits/providers/',
|
||||
filter_for={
|
||||
@ -2857,6 +2858,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode
|
||||
termination_b_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/sites/',
|
||||
display_field='cid',
|
||||
@ -2888,6 +2890,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode
|
||||
('rack_group', 'termination_b_rackgroup'),
|
||||
),
|
||||
label='Power Panel',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/power-panels/',
|
||||
filter_for={
|
||||
@ -3119,6 +3122,17 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
}
|
||||
)
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/tenancy/tenants/",
|
||||
value_field='slug',
|
||||
filter_for={
|
||||
'device_id': 'tenant',
|
||||
}
|
||||
)
|
||||
)
|
||||
rack_id = FilterChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack',
|
||||
|
@ -3027,15 +3027,14 @@ class Cable(ChangeLoggedModel):
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
return self.label
|
||||
|
||||
# Save a copy of the PK on the instance since it's nullified if .delete() is called
|
||||
if not hasattr(self, 'id_string'):
|
||||
self.id_string = '#{}'.format(self.pk)
|
||||
|
||||
return self.id_string
|
||||
return self.label or '#{}'.format(self._pk)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
@ -3142,6 +3141,9 @@ class Cable(ChangeLoggedModel):
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
|
||||
self._pk = self.pk
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
|
||||
|
@ -11,6 +11,7 @@ from dcim.models import (
|
||||
VirtualChassis,
|
||||
)
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.models import Tenant
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
|
||||
|
||||
@ -1100,7 +1101,7 @@ class DeviceTestCase(TestCase):
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=RACK_FACE_FRONT, status=DEVICE_STATUS_ACTIVE, cluster=clusters[0]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=RACK_FACE_FRONT, status=DEVICE_STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=RACK_FACE_FRONT, status=DEVICE_STATUS_STAGED, cluster=clusters[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=RACK_FACE_REAR, status=DEVICE_STATUS_FAILED, cluster=clusters[2]),
|
||||
)
|
||||
@ -1328,6 +1329,12 @@ class DeviceTestCase(TestCase):
|
||||
# params = {'device_bays': 'false'}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_local_context_data(self):
|
||||
params = {'local_context_data': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'local_context_data': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConsolePortTestCase(TestCase):
|
||||
queryset = ConsolePort.objects.all()
|
||||
@ -2121,6 +2128,12 @@ class CableTestCase(TestCase):
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
racks = (
|
||||
Rack(name='Rack 1', site=sites[0]),
|
||||
Rack(name='Rack 2', site=sites[1]),
|
||||
@ -2133,9 +2146,9 @@ class CableTestCase(TestCase):
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1),
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1, tenant=tenants[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2, tenant=tenants[0]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1, tenant=tenants[1]),
|
||||
Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2),
|
||||
Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1),
|
||||
Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2),
|
||||
@ -2216,6 +2229,13 @@ class CableTestCase(TestCase):
|
||||
params = {'site': [site[0].slug, site[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_tenant(self):
|
||||
tenant = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenant[0].pk, tenant[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'tenant': [tenant[0].slug, tenant[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
|
||||
class PowerPanelTestCase(TestCase):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
@ -325,9 +325,12 @@ class CableTestCase(TestCase):
|
||||
|
||||
def test_cable_deletion(self):
|
||||
"""
|
||||
When a Cable is deleted, the `cable` field on its termination points must be nullified.
|
||||
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
|
||||
should still return the PK of the string even after being nullified.
|
||||
"""
|
||||
self.cable.delete()
|
||||
self.assertIsNone(self.cable.pk)
|
||||
self.assertNotEqual(str(self.cable), '#None')
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertIsNone(interface1.cable)
|
||||
interface2 = Interface.objects.get(pk=self.interface2.pk)
|
||||
|
@ -60,5 +60,5 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
|
||||
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
|
||||
else:
|
||||
raise requests.exceptions.RequestException(
|
||||
"Status {} returned, webhook FAILED to process.".format(response.status_code)
|
||||
"Status {} returned with content '{}', webhook FAILED to process.".format(response.status_code, response.content)
|
||||
)
|
||||
|
@ -933,7 +933,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
|
||||
|
||||
|
||||
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
vrf = forms.ModelChoiceField(
|
||||
vrf_id = forms.ModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label='VRF',
|
||||
@ -942,8 +942,9 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
api_url="/api/ipam/vrfs/"
|
||||
)
|
||||
)
|
||||
address = forms.CharField(
|
||||
label='IP Address'
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search',
|
||||
)
|
||||
|
||||
|
||||
|
@ -373,7 +373,7 @@ class IPAddressAssignTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
orderable = False
|
||||
|
||||
|
||||
|
@ -756,13 +756,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
addresses = IPAddress.objects.prefetch_related(
|
||||
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
|
||||
).filter(
|
||||
vrf=form.cleaned_data['vrf'],
|
||||
address__istartswith=form.cleaned_data['address'],
|
||||
)[:100] # Limit to 100 results
|
||||
table = tables.IPAddressAssignTable(queryset)
|
||||
)
|
||||
# Limit to 100 results
|
||||
addresses = filters.IPAddressFilter(request.POST, addresses).qs[:100]
|
||||
table = tables.IPAddressAssignTable(addresses)
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'form': form,
|
||||
|
@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.6.12-dev'
|
||||
VERSION = '2.6.13-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -112,7 +112,9 @@
|
||||
{% if utilization %}
|
||||
<td>
|
||||
{{ utilization.allocated }}VA / {{ powerfeed.available_power }}VA
|
||||
{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
|
||||
{% if powerfeed.available_power > 0 %}
|
||||
{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">N/A</td>
|
||||
|
@ -24,8 +24,8 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Select IP Address</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.vrf %}
|
||||
{% render_field form.address %}
|
||||
{% render_field form.vrf_id %}
|
||||
{% render_field form.q %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
|
||||
|
||||
|
||||
@ -498,6 +506,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
netbox/utilities/tests/test_forms.py
Normal file
283
netbox/utilities/tests/test_forms.py
Normal file
@ -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'))
|
@ -2,7 +2,7 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import DeviceRole, Interface, Platform, Region, Site
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
|
||||
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilter
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import (
|
||||
MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
|
||||
@ -99,7 +99,7 @@ class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class VirtualMachineFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
|
@ -203,7 +203,7 @@ class VirtualMachineTestCase(TestCase):
|
||||
DeviceRole.objects.bulk_create(roles)
|
||||
|
||||
vms = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], status=DEVICE_STATUS_ACTIVE, vcpus=1, memory=1, disk=1),
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], status=DEVICE_STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], status=DEVICE_STATUS_STAGED, vcpus=2, memory=2, disk=2),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], status=DEVICE_STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
|
||||
)
|
||||
@ -300,6 +300,12 @@ class VirtualMachineTestCase(TestCase):
|
||||
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_local_context_data(self):
|
||||
params = {'local_context_data': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'local_context_data': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase):
|
||||
queryset = Interface.objects.all()
|
||||
|
Loading…
Reference in New Issue
Block a user