diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 000000000..36a41b04e --- /dev/null +++ b/.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 diff --git a/.github/stale.yml b/.github/stale.yml index 7c8d03f12..61201cc4e 100644 --- a/.github/stale.yml +++ b/.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 diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 0070c5545..dd44bb2ab 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -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. diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index ee6ea2e4d..d7832a823 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -6,9 +6,13 @@ * [#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 @@ -21,6 +25,8 @@ * [#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 +* [#3882](https://github.com/netbox-community/netbox/issues/3882) - Fix filtering of devices by rack group --- diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 30c4417b6..15cf901c1 100644 --- a/netbox/circuits/views.py +++ b/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 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__model='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, }) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 7596e393b..93576a390 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -1,5 +1,8 @@ from .choices import InterfaceTypeChoices +# BGP ASN bounds +BGP_ASN_MIN = 1 +BGP_ASN_MAX = 2**32 - 1 # # Interface type groups diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 271655f24..f2bafc7a8 100644 --- a/netbox/dcim/fields.py +++ b/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' diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index bd59a614f..c289f5a25 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -320,8 +320,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' ) @@ -764,7 +764,7 @@ class RackElevationFilterForm(RackFilterForm): # Filter the rack field based on the site and group self.fields['site'].widget.add_filter_for('id', 'site') - self.fields['group_id'].widget.add_filter_for('id', 'group_id') + self.fields['rack_group_id'].widget.add_filter_for('id', 'group_id') # @@ -1936,7 +1936,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( @@ -1962,12 +1962,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' ), diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 3f6e5f53b..265ddcb7b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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', ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 4b4b275a5..ad119a907 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -377,7 +377,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 diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index de0c33aee..1460df66f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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, diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index b4cedd6d3..5bf05b0e2 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -122,58 +122,7 @@
Circuits
- - - - - - - - - - {% for c in circuits %} - - - - - - - - - {% empty %} - - - - {% endfor %} -
Circuit IDTypeTenantA SideZ SideDescription
- {{ c.cid }} - - {{ c.type }} - - {% if c.tenant %} - {{ c.tenant }} - {% else %} - - {% endif %} - - {% if c.termination_a %} - {{ c.termination_a.site }} - {% else %} - - {% endif %} - - {% if c.termination_z %} - {{ c.termination_z.site }} - {% else %} - - {% endif %} - - {% if c.description %} - {{ c.description }} - {% else %} - - {% endif %} -
None
+ {% include 'inc/table.html' with table=circuits_table %} {% if perms.circuits.add_circuit %} {% endif %} + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} {% include 'inc/modal.html' with modal_name='graphs' %} diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html index 579f2d98b..ab163533f 100644 --- a/netbox/templates/ipam/ipaddress_assign.html +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -24,8 +24,8 @@
Select IP Address
- {% render_field form.vrf %} - {% render_field form.address %} + {% render_field form.vrf_id %} + {% render_field form.q %}
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 4d43380c9..007d87668 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -61,8 +61,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 @@ -482,6 +490,7 @@ class ExpandableNameField(forms.CharField): 'Mixed cases and types within a single range are not supported.
' \ 'Examples:' def to_python(self, value): diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py new file mode 100644 index 000000000..2d7235505 --- /dev/null +++ b/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'))