Repository: netbox-community/netbox

Issue:
Improve interfaces templates: allow word expansion
<!--
    NOTE: This form is only for proposing specific new features or enhancements.
    If you have a general idea or question, please post to our mailing list
    instead of opening an issue:

        https://groups.google.com/forum/#!forum/netbox-discuss

    NOTE: Due to an excessive backlog of feature requests, we are not currently
    accepting any proposals which significantly extend NetBox's feature scope.

    Please describe the environment in which you are running NetBox. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report.
-->
### Environment
* Python version:  3.7
* NetBox version:  2.6.6

<!--
    Describe in detail the new functionality you are proposing. Include any
    specific changes to work flows, data models, or the user interface.
-->
### Proposed Functionality
In the template interface, allow user to expand names. For example `Interface/[FRONT|BACK]/Eth[1-10]` would produce:

```
Interface/FRONT/Eth1
Interface/FRONT/Eth2
Interface/FRONT/Eth3
Interface/FRONT/Eth4
Interface/FRONT/Eth5
Interface/FRONT/Eth6
Interface/FRONT/Eth7
Interface/FRONT/Eth8
Interface/FRONT/Eth9
Interface/FRONT/Eth10
Interface/BACK/Eth1
Interface/BACK/Eth2
Interface/BACK/Eth3
Interface/BACK/Eth4
Interface/BACK/Eth5
Interface/BACK/Eth6
Interface/BACK/Eth7
Interface/BACK/Eth8
Interface/BACK/Eth9
Interface/BACK/Eth10
```
<!--
    Convey an example use case for your proposed feature. Write from the
    perspective of a NetBox user who would benefit from the proposed
    functionality and describe how.
--->
### Use Case

We need such expansion pattern for our device templates. Maybe others do as well!

<!--
    Note any changes to the database schema necessary to support the new
    feature. For example, does the proposal require adding a new model or
    field? (Not all new features require database changes.)
--->
### Database Changes
None

<!--
    List any new dependencies on external libraries or services that this new
    feature would introduce. For example, does the proposal require the
    installation of a new Python package? (Not all new features introduce new
    dependencies.)
-->
### External Dependencies
None


Discussion:
Thank you for opening this issue. 
Could you let me know what kind of devices have both front/back interface?
Hello,

from my understanding, mostly patch panels.
Are you using interfaces or front/rear ports?

Two thoughts. First, we should stick to comma-delimited values since that's the current convention. Second, we should be able to tweak `parse_alphanumeric_range()` to simply ignore values which don't specify a range and pass them through.
Sorry for the delay!
@DanSheps : I asked my colleague, and he told me he uses interfaces because the "front/back" thing didn't exist at the time when they created the template. Does that answer your question?

@jeremystretch : I tried to tweak it using commas (by the way, why does the regex allow so many delimiter `[?:,-]` if only the coma is valid?) but I couldn't identify the case where expansion was required instead of enumeration. See my PR if that can be relevant.

Thanks anyway for the interest! 

Resolving patch:
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
--- a/netbox/utilities/forms.py
+++ b/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 @@ def __init__(self, *args, **kwargs):
                              '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):


Test patch:
diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py
new file mode 100644
--- /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'))