Skip to content

Commit 2445d18

Browse files
Merge pull request #3988 from netbox-community/3509-ipaddress-script-vars
Closes #3509: Add IP address vars for custom scripts
2 parents 5e7fbc4 + 72d1fe0 commit 2445d18

File tree

8 files changed

+162
-43
lines changed

8 files changed

+162
-43
lines changed

docs/additional-features/custom-scripts.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field.
124124

125125
Stored a numeric integer. Options include:
126126

127-
* `min_value:` - Minimum value
127+
* `min_value` - Minimum value
128128
* `max_value` - Maximum value
129129

130130
### BooleanVar
@@ -158,9 +158,20 @@ A NetBox object. The list of available objects is defined by the queryset parame
158158

159159
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
160160

161+
### IPAddressVar
162+
163+
An IPv4 or IPv6 address, without a mask. Returns a `netaddr.IPAddress` object.
164+
165+
### IPAddressWithMaskVar
166+
167+
An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which includes the mask.
168+
161169
### IPNetworkVar
162170

163-
An IPv4 or IPv6 network with a mask.
171+
An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask:
172+
173+
* `min_prefix_length` - Minimum length of the mask (default: none)
174+
* `max_prefix_length` - Maximum length of the mask (default: none)
164175

165176
### Default Options
166177

docs/release-notes/version-2.7.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Enhancements
44

55
* [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable
6+
* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts
67

78
## Bug Fixes
89

netbox/extras/scripts.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
1515
from mptt.models import MPTTModel
1616

17-
from ipam.formfields import IPFormField
18-
from utilities.exceptions import AbortTransaction
19-
from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
17+
from ipam.formfields import IPAddressFormField, IPNetworkFormField
18+
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
2019
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
20+
from utilities.exceptions import AbortTransaction
2121
from .forms import ScriptForm
2222
from .signals import purge_changelog
2323

@@ -27,6 +27,8 @@
2727
'ChoiceVar',
2828
'FileVar',
2929
'IntegerVar',
30+
'IPAddressVar',
31+
'IPAddressWithMaskVar',
3032
'IPNetworkVar',
3133
'MultiObjectVar',
3234
'ObjectVar',
@@ -48,15 +50,19 @@ class ScriptVariable:
4850

4951
def __init__(self, label='', description='', default=None, required=True):
5052

51-
# Default field attributes
52-
self.field_attrs = {
53-
'help_text': description,
54-
'required': required
55-
}
53+
# Initialize field attributes
54+
if not hasattr(self, 'field_attrs'):
55+
self.field_attrs = {}
56+
if description:
57+
self.field_attrs['help_text'] = description
5658
if label:
5759
self.field_attrs['label'] = label
5860
if default:
5961
self.field_attrs['initial'] = default
62+
if required:
63+
self.field_attrs['required'] = True
64+
if 'validators' not in self.field_attrs:
65+
self.field_attrs['validators'] = []
6066

6167
def as_field(self):
6268
"""
@@ -196,17 +202,32 @@ class FileVar(ScriptVariable):
196202
form_field = forms.FileField
197203

198204

205+
class IPAddressVar(ScriptVariable):
206+
"""
207+
An IPv4 or IPv6 address without a mask.
208+
"""
209+
form_field = IPAddressFormField
210+
211+
212+
class IPAddressWithMaskVar(ScriptVariable):
213+
"""
214+
An IPv4 or IPv6 address with a mask.
215+
"""
216+
form_field = IPNetworkFormField
217+
218+
199219
class IPNetworkVar(ScriptVariable):
200220
"""
201221
An IPv4 or IPv6 prefix.
202222
"""
203-
form_field = IPFormField
223+
form_field = IPNetworkFormField
224+
field_attrs = {
225+
'validators': [prefix_validator]
226+
}
204227

205228
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
206229
super().__init__(*args, **kwargs)
207230

208-
self.field_attrs['validators'] = list()
209-
210231
# Optional minimum/maximum prefix lengths
211232
if min_prefix_length is not None:
212233
self.field_attrs['validators'].append(

netbox/extras/tests/test_scripts.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.core.files.uploadedfile import SimpleUploadedFile
22
from django.test import TestCase
3-
from netaddr import IPNetwork
3+
from netaddr import IPAddress, IPNetwork
44

55
from dcim.models import DeviceRole
66
from extras.scripts import *
@@ -186,6 +186,54 @@ class TestScript(Script):
186186
self.assertTrue(form.is_valid())
187187
self.assertEqual(form.cleaned_data['var1'], testfile)
188188

189+
def test_ipaddressvar(self):
190+
191+
class TestScript(Script):
192+
193+
var1 = IPAddressVar()
194+
195+
# Validate IP network enforcement
196+
data = {'var1': '1.2.3'}
197+
form = TestScript().as_form(data, None)
198+
self.assertFalse(form.is_valid())
199+
self.assertIn('var1', form.errors)
200+
201+
# Validate IP mask exclusion
202+
data = {'var1': '192.0.2.0/24'}
203+
form = TestScript().as_form(data, None)
204+
self.assertFalse(form.is_valid())
205+
self.assertIn('var1', form.errors)
206+
207+
# Validate valid data
208+
data = {'var1': '192.0.2.1'}
209+
form = TestScript().as_form(data, None)
210+
self.assertTrue(form.is_valid())
211+
self.assertEqual(form.cleaned_data['var1'], IPAddress(data['var1']))
212+
213+
def test_ipaddresswithmaskvar(self):
214+
215+
class TestScript(Script):
216+
217+
var1 = IPAddressWithMaskVar()
218+
219+
# Validate IP network enforcement
220+
data = {'var1': '1.2.3'}
221+
form = TestScript().as_form(data, None)
222+
self.assertFalse(form.is_valid())
223+
self.assertIn('var1', form.errors)
224+
225+
# Validate IP mask requirement
226+
data = {'var1': '192.0.2.0'}
227+
form = TestScript().as_form(data, None)
228+
self.assertFalse(form.is_valid())
229+
self.assertIn('var1', form.errors)
230+
231+
# Validate valid data
232+
data = {'var1': '192.0.2.0/24'}
233+
form = TestScript().as_form(data, None)
234+
self.assertTrue(form.is_valid())
235+
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
236+
189237
def test_ipnetworkvar(self):
190238

191239
class TestScript(Script):
@@ -198,6 +246,12 @@ class TestScript(Script):
198246
self.assertFalse(form.is_valid())
199247
self.assertIn('var1', form.errors)
200248

249+
# Validate host IP check
250+
data = {'var1': '192.0.2.1/24'}
251+
form = TestScript().as_form(data, None)
252+
self.assertFalse(form.is_valid())
253+
self.assertIn('var1', form.errors)
254+
201255
# Validate valid data
202256
data = {'var1': '192.0.2.0/24'}
203257
form = TestScript().as_form(data, None)

netbox/ipam/fields.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,8 @@
22
from django.db import models
33
from netaddr import AddrFormatError, IPNetwork
44

5-
from . import lookups
6-
from .formfields import IPFormField
7-
8-
9-
def prefix_validator(prefix):
10-
if prefix.ip != prefix.cidr.ip:
11-
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
5+
from . import lookups, validators
6+
from .formfields import IPNetworkFormField
127

138

149
class BaseIPField(models.Field):
@@ -38,7 +33,7 @@ def get_prep_value(self, value):
3833
return str(self.to_python(value))
3934

4035
def form_class(self):
41-
return IPFormField
36+
return IPNetworkFormField
4237

4338
def formfield(self, **kwargs):
4439
defaults = {'form_class': self.form_class()}
@@ -51,7 +46,7 @@ class IPNetworkField(BaseIPField):
5146
IP prefix (network and mask)
5247
"""
5348
description = "PostgreSQL CIDR field"
54-
default_validators = [prefix_validator]
49+
default_validators = [validators.prefix_validator]
5550

5651
def db_type(self, connection):
5752
return 'cidr'

netbox/ipam/formfields.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
11
from django import forms
22
from django.core.exceptions import ValidationError
3-
from netaddr import IPNetwork, AddrFormatError
3+
from django.core.validators import validate_ipv4_address, validate_ipv6_address
4+
from netaddr import IPAddress, IPNetwork, AddrFormatError
45

56

67
#
78
# Form fields
89
#
910

10-
class IPFormField(forms.Field):
11+
class IPAddressFormField(forms.Field):
12+
default_error_messages = {
13+
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
14+
}
15+
16+
def to_python(self, value):
17+
if not value:
18+
return None
19+
20+
if isinstance(value, IPAddress):
21+
return value
22+
23+
# netaddr is a bit too liberal with what it accepts as a valid IP address. For example, '1.2.3' will become
24+
# IPAddress('1.2.0.3'). Here, we employ Django's built-in IPv4 and IPv6 address validators as a sanity check.
25+
try:
26+
validate_ipv4_address(value)
27+
except ValidationError:
28+
try:
29+
validate_ipv6_address(value)
30+
except ValidationError:
31+
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
32+
33+
try:
34+
return IPAddress(value)
35+
except ValueError:
36+
raise ValidationError('This field requires an IP address without a mask.')
37+
except AddrFormatError:
38+
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
39+
40+
41+
class IPNetworkFormField(forms.Field):
1142
default_error_messages = {
1243
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
1344
}

netbox/ipam/validators.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
1-
from django.core.validators import RegexValidator
1+
from django.core.exceptions import ValidationError
2+
from django.core.validators import BaseValidator, RegexValidator
3+
4+
5+
def prefix_validator(prefix):
6+
if prefix.ip != prefix.cidr.ip:
7+
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
8+
9+
10+
class MaxPrefixLengthValidator(BaseValidator):
11+
message = 'The prefix length must be less than or equal to %(limit_value)s.'
12+
code = 'max_prefix_length'
13+
14+
def compare(self, a, b):
15+
return a.prefixlen > b
16+
17+
18+
class MinPrefixLengthValidator(BaseValidator):
19+
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
20+
code = 'min_prefix_length'
21+
22+
def compare(self, a, b):
23+
return a.prefixlen < b
224

325

426
DNSValidator = RegexValidator(

netbox/utilities/validators.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22

3-
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
3+
from django.core.validators import _lazy_re_compile, URLValidator
44

55

66
class EnhancedURLValidator(URLValidator):
@@ -26,19 +26,3 @@ def __contains__(self, item):
2626
r'(?:[/?#][^\s]*)?' # Path
2727
r'\Z', re.IGNORECASE)
2828
schemes = AnyURLScheme()
29-
30-
31-
class MaxPrefixLengthValidator(BaseValidator):
32-
message = 'The prefix length must be less than or equal to %(limit_value)s.'
33-
code = 'max_prefix_length'
34-
35-
def compare(self, a, b):
36-
return a.prefixlen > b
37-
38-
39-
class MinPrefixLengthValidator(BaseValidator):
40-
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
41-
code = 'min_prefix_length'
42-
43-
def compare(self, a, b):
44-
return a.prefixlen < b

0 commit comments

Comments
 (0)