From 3d911532756a37a9b3f501a207d8afe0c56184cd Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 00:09:38 -0500 Subject: [PATCH 01/36] Add alphabetic variants to interface expansions --- netbox/utilities/forms.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index f63c0c11d..e888cb6ae 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -39,6 +39,7 @@ COLOR_CHOICES = ( ('111111', 'Black'), ) NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]' +ALPHABETIC_EXPANSION_PATTERN = '\[((?:[a-z]+[?:,-])+[a-z]+)\]' IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' @@ -77,6 +78,37 @@ def expand_numeric_pattern(string): yield "{}{}{}".format(lead, i, remnant) +def parse_alphabetic_range(string): + """ + Expand an alphabetic range (continuous or not) into a list. + 'a-d,f' => ['a', 'b', 'c', 'd', 'f'] + """ + values = [] + for dash_range in string.split(','): + try: + begin, end = dash_range.split('-') + except ValueError: + begin, end = dash_range, dash_range + nums = list(range(ord(begin), ord(end)+1)) + for n in nums: + values.append(chr(n)) + return values + + +def expand_alphabetic_pattern(string): + """ + Expand an alphabetic pattern into a list of strings. + """ + lead, pattern, remnant = re.split(ALPHABETIC_EXPANSION_PATTERN, string, maxsplit=1) + parsed_range = parse_alphabetic_range(pattern) + for i in parsed_range: + if re.search(ALPHABETIC_EXPANSION_PATTERN, remnant): + for string in expand_alphabetic_pattern(remnant): + yield "{}{}{}".format(lead, i, string) + else: + yield "{}{}{}".format(lead, i, remnant) + + def expand_ipaddress_pattern(string, family): """ Expand an IP address pattern into a list of strings. Examples: @@ -307,9 +339,12 @@ class ExpandableNameField(forms.CharField): 'Example: ge-0/0/[0-23,25,30]' def to_python(self, value): + values = [] if re.search(NUMERIC_EXPANSION_PATTERN, value): - return list(expand_numeric_pattern(value)) - return [value] + values += expand_numeric_pattern(value) + if re.search(ALPHABETIC_EXPANSION_PATTERN, value): + values += expand_alphabetic_pattern(value) + return values class ExpandableIPAddressField(forms.CharField): From c107f35118778aadd1cfdaf514425343d66611ef Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 17:55:00 -0500 Subject: [PATCH 02/36] Merge letters and numbers into one function --- netbox/utilities/forms.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index e888cb6ae..d1f19c23b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -39,7 +39,7 @@ COLOR_CHOICES = ( ('111111', 'Black'), ) NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]' -ALPHABETIC_EXPANSION_PATTERN = '\[((?:[a-z]+[?:,-])+[a-z]+)\]' +ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:\w+[?:,-])+\w+)\]' IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' @@ -78,9 +78,9 @@ def expand_numeric_pattern(string): yield "{}{}{}".format(lead, i, remnant) -def parse_alphabetic_range(string): +def parse_alphanumeric_range(string): """ - Expand an alphabetic range (continuous or not) into a list. + Expand an alphanumeric range (continuous or not) into a list. 'a-d,f' => ['a', 'b', 'c', 'd', 'f'] """ values = [] @@ -95,15 +95,15 @@ def parse_alphabetic_range(string): return values -def expand_alphabetic_pattern(string): +def expand_alphanumeric_pattern(string): """ Expand an alphabetic pattern into a list of strings. """ - lead, pattern, remnant = re.split(ALPHABETIC_EXPANSION_PATTERN, string, maxsplit=1) - parsed_range = parse_alphabetic_range(pattern) + lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1) + parsed_range = parse_alphanumeric_range(pattern) for i in parsed_range: - if re.search(ALPHABETIC_EXPANSION_PATTERN, remnant): - for string in expand_alphabetic_pattern(remnant): + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant): + for string in expand_alphanumeric_pattern(remnant): yield "{}{}{}".format(lead, i, string) else: yield "{}{}{}".format(lead, i, remnant) @@ -339,12 +339,9 @@ class ExpandableNameField(forms.CharField): 'Example: ge-0/0/[0-23,25,30]' def to_python(self, value): - values = [] - if re.search(NUMERIC_EXPANSION_PATTERN, value): - values += expand_numeric_pattern(value) - if re.search(ALPHABETIC_EXPANSION_PATTERN, value): - values += expand_alphabetic_pattern(value) - return values + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): + return list(expand_alphanumeric_pattern(value)) + return [value] class ExpandableIPAddressField(forms.CharField): From b295849f536e06eba6a40d6288f373d3839ce51e Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 19:30:43 -0500 Subject: [PATCH 03/36] Prevent mismatch of types in ranges --- netbox/utilities/forms.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index d1f19c23b..964524f09 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -39,7 +39,7 @@ COLOR_CHOICES = ( ('111111', 'Black'), ) NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]' -ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:\w+[?:,-])+\w+)\]' +ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' @@ -87,6 +87,8 @@ def parse_alphanumeric_range(string): for dash_range in string.split(','): try: begin, end = dash_range.split('-') + if (str.isalpha(begin) and str.isdigit(end)) or (str.isdigit(begin) and str.isalpha(end)): + continue # Skip if it's invalid, just like any other bad pattern except ValueError: begin, end = dash_range, dash_range nums = list(range(ord(begin), ord(end)+1)) From 3df7e283e38d9fee7ca5a450599e5b6fca9d284e Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 19:46:12 -0500 Subject: [PATCH 04/36] Prevent mismatch of cases in ranges --- netbox/utilities/forms.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 964524f09..b0e741080 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -87,8 +87,11 @@ def parse_alphanumeric_range(string): for dash_range in string.split(','): try: begin, end = dash_range.split('-') + # Skip if incompatible types or mixed case, just like any other bad pattern if (str.isalpha(begin) and str.isdigit(end)) or (str.isdigit(begin) and str.isalpha(end)): - continue # Skip if it's invalid, just like any other bad pattern + continue + if not (str.isupper(begin + end) or str.islower(begin + end)): + continue except ValueError: begin, end = dash_range, dash_range nums = list(range(ord(begin), ord(end)+1)) From 33a99441a48dad31e3115798e03e49b503cdb279 Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 19:55:50 -0500 Subject: [PATCH 05/36] Update help text for ExpandableNameField --- netbox/utilities/forms.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index b0e741080..ce9ecd324 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -340,8 +340,11 @@ class ExpandableNameField(forms.CharField): def __init__(self, *args, **kwargs): super(ExpandableNameField, self).__init__(*args, **kwargs) if not self.help_text: - self.help_text = 'Numeric ranges are supported for bulk creation.
'\ - 'Example: ge-0/0/[0-23,25,30]' + self.help_text = 'Alphanumeric ranges are supported for bulk creation.
' \ + 'Mixed cases and types in ranges are not supported.
' \ + 'Examples:
  • ge-0/0/[0-23,25,30]
  • ' \ + '
  • e[0-3][a-d,f]
  • ' \ + '
  • e[0-3,a-d,f]
' def to_python(self, value): if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): From 1a6ee237f6ce71eb52c8804d31b3f15c63f153b3 Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 19:59:37 -0500 Subject: [PATCH 06/36] Update help text for ExpandableNameField (again) --- netbox/utilities/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index ce9ecd324..9edc37a51 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -341,7 +341,7 @@ class ExpandableNameField(forms.CharField): super(ExpandableNameField, self).__init__(*args, **kwargs) if not self.help_text: self.help_text = 'Alphanumeric ranges are supported for bulk creation.
' \ - 'Mixed cases and types in ranges are not supported.
' \ + 'Mixed cases and types within a single range are not supported.
' \ 'Examples:
  • ge-0/0/[0-23,25,30]
  • ' \ '
  • e[0-3][a-d,f]
  • ' \ '
  • e[0-3,a-d,f]
' From 53f58d4496668a54484346e5efb2e0ae6fa894ac Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 20:03:10 -0500 Subject: [PATCH 07/36] Update comment --- netbox/utilities/forms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 9edc37a51..09585a8ee 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -81,7 +81,8 @@ def expand_numeric_pattern(string): def parse_alphanumeric_range(string): """ Expand an alphanumeric range (continuous or not) into a list. - 'a-d,f' => ['a', 'b', 'c', 'd', 'f'] + 'a-d,f' => [a, b, c, d, f] + '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] """ values = [] for dash_range in string.split(','): From 3d023126bad51a0d058fb82c22d6f4fb73b65d6a Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 20:22:15 -0500 Subject: [PATCH 08/36] Refactor pattern check --- netbox/utilities/forms.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 09585a8ee..c65d56980 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -89,9 +89,10 @@ def parse_alphanumeric_range(string): try: begin, end = dash_range.split('-') # Skip if incompatible types or mixed case, just like any other bad pattern - if (str.isalpha(begin) and str.isdigit(end)) or (str.isdigit(begin) and str.isalpha(end)): + vals = begin + end + if not (vals.isdigit() or vals.isalpha()): continue - if not (str.isupper(begin + end) or str.islower(begin + end)): + if vals.isalpha() and not (vals.isupper() or vals.islower()): continue except ValueError: begin, end = dash_range, dash_range From e57b8aa26f8389f6107d017d4c8e5aea9e2663fa Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 24 Oct 2017 20:43:02 -0500 Subject: [PATCH 09/36] E226 fix --- netbox/utilities/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index c65d56980..1a285707e 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -96,7 +96,7 @@ def parse_alphanumeric_range(string): continue except ValueError: begin, end = dash_range, dash_range - nums = list(range(ord(begin), ord(end)+1)) + nums = list(range(ord(begin), ord(end) + 1)) for n in nums: values.append(chr(n)) return values From 57973f62c5b31ac235141de0f9c18a5aeaf8ed1a Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Tue, 31 Oct 2017 22:03:57 -0500 Subject: [PATCH 10/36] Fix bug with numbers >10 --- netbox/utilities/forms.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1a285707e..417e164fb 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -88,17 +88,18 @@ def parse_alphanumeric_range(string): for dash_range in string.split(','): try: begin, end = dash_range.split('-') - # Skip if incompatible types or mixed case, just like any other bad pattern vals = begin + end - if not (vals.isdigit() or vals.isalpha()): - continue - if vals.isalpha() and not (vals.isupper() or vals.islower()): - continue + # Break out of loop if there's an invalid pattern to return an error + if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): + return [] except ValueError: begin, end = dash_range, dash_range - nums = list(range(ord(begin), ord(end) + 1)) - for n in nums: - values.append(chr(n)) + if begin.isdigit() and end.isdigit(): + 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)) return values From e18b5f5fd41077686b3dc9281cad3078065017db Mon Sep 17 00:00:00 2001 From: Musee Ullah Date: Thu, 22 Feb 2018 05:56:33 +0900 Subject: [PATCH 11/36] Add Ansible alternative installation to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9d3bbe0e8..af5674717 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,4 @@ and run `upgrade.sh`. * [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine)) * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) +* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae)) From c823660a8f89589cbae998f73ca430bbccfcd554 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 1 Mar 2018 15:36:32 -0500 Subject: [PATCH 12/36] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d79dc6ca5..c531231b1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.3.1' +VERSION = '2.3.2-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 603b80db1be167cdfd6da1de5923434a48487f0d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Mar 2018 11:48:26 -0500 Subject: [PATCH 13/36] Fixes #1948: Fix TypeError when attempting to add a member to an existing virtual chassis --- netbox/dcim/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8a8fb8d4c..2842cd549 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2226,7 +2226,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi device = member_select_form.cleaned_data['device'] device.virtual_chassis = virtual_chassis data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']} - membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device) + membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device) if membership_form.is_valid(): @@ -2242,7 +2242,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi else: - membership_form = forms.DeviceVCMembershipForm(request.POST) + membership_form = forms.DeviceVCMembershipForm(data=request.POST) return render(request, 'dcim/virtualchassis_add_member.html', { 'virtual_chassis': virtual_chassis, From 37eef0ba6debe9f2a7836cb5bcb21877bae7a7c7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Mar 2018 12:10:02 -0500 Subject: [PATCH 14/36] Fixes #1951: Fix TypeError exception when importing platforms --- netbox/dcim/forms.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e71f44389..7a45b8dd8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -700,13 +700,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class PlatformCSVForm(forms.ModelForm): slug = SlugField() + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + required=True, + to_field_name='name', + help_text='Manufacturer name', + error_messages={ + 'invalid_choice': 'Manufacturer not found.', + } + ) class Meta: model = Platform fields = Platform.csv_headers help_texts = { 'name': 'Platform name', - 'manufacturer': 'Manufacturer name', } From f5bb072f28cc6225310d7d295a3f35b42587336e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Mar 2018 11:08:28 -0500 Subject: [PATCH 15/36] Fixes #1953: Ignore duplicate IPs when calculating prefix utilization --- netbox/ipam/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index d8e2aae97..714d5a5b0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -365,7 +365,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) return int(float(child_prefixes.size) / self.prefix.size * 100) else: - child_count = self.get_child_ips().count() + # Compile an IPSet to avoid counting duplicate IPs + child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size prefix_size = self.prefix.size if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool: prefix_size -= 2 From 8ae13e29f56191579d01ad8a274d1031404d4d7c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Mar 2018 11:20:10 -0500 Subject: [PATCH 16/36] Fixes #1955: Require a plaintext value when creating a new secret --- netbox/secrets/forms.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index bcc79e2a5..8f8107805 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm): # class SecretForm(BootstrapMixin, forms.ModelForm): - plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext', - widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})) - plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)', - widget=forms.PasswordInput()) + plaintext = forms.CharField( + max_length=65535, + required=False, + label='Plaintext', + widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}) + ) + plaintext2 = forms.CharField( + max_length=65535, + required=False, + label='Plaintext (verify)', + widget=forms.PasswordInput() + ) class Meta: model = Secret fields = ['role', 'name', 'plaintext', 'plaintext2'] + def __init__(self, *args, **kwargs): + + super(SecretForm, self).__init__(*args, **kwargs) + + # A plaintext value is required when creating a new Secret + if not self.instance.pk: + self.fields['plaintext'].required = True + def clean(self): + # Verify that the provided plaintext values match if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']: raise forms.ValidationError({ 'plaintext2': "The two given plaintext values do not match. Please check your input." From 1c9986efc4c71826b6ed0fc4cdd8312fa15752e1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Mar 2018 11:37:05 -0500 Subject: [PATCH 17/36] Closes #1949: Added a button to view elevations on rack groups list --- netbox/dcim/tables.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index aef6b3308..d6e9cfdfe 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -47,8 +47,13 @@ REGION_ACTIONS = """ """ RACKGROUP_ACTIONS = """ + + + {% if perms.dcim.change_rackgroup %} - + + + {% endif %} """ @@ -182,12 +187,21 @@ class SiteTable(BaseTable): class RackGroupTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - rack_count = tables.Column(verbose_name='Racks') - slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}}, - verbose_name='') + name = tables.LinkColumn() + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site.slug')], + verbose_name='Site' + ) + rack_count = tables.Column( + verbose_name='Racks' + ) + slug = tables.Column() + actions = tables.TemplateColumn( + template_code=RACKGROUP_ACTIONS, + attrs={'td': {'class': 'text-right'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = RackGroup From 546f17ab50a6b0ca2021e613334ba0be59cb7b21 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Mar 2018 14:16:38 -0500 Subject: [PATCH 18/36] Closes #1866: Introduced AnnotatedMultipleChoiceField for filter forms --- netbox/circuits/forms.py | 18 +++++----- netbox/dcim/forms.py | 38 ++++++++++----------- netbox/ipam/forms.py | 62 +++++++++++++++------------------- netbox/utilities/forms.py | 33 ++++++++++++++++++ netbox/virtualization/forms.py | 18 +++++----- 5 files changed, 93 insertions(+), 76 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 29203fc8a..bfcfa7187 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, - CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, + AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, + ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField, ) from .constants import CIRCUIT_STATUS_CHOICES from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'commit_rate', 'description', 'comments'] -def circuit_status_choices(): - status_counts = {} - for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES] - - class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit q = forms.CharField(required=False, label='Search') @@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Provider.objects.annotate(filter_count=Count('circuits')), to_field_name='slug' ) - status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=CIRCUIT_STATUS_CHOICES, + annotate=Circuit.objects.all(), + annotate_field='status', + required=False + ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7a45b8dd8..d5089cc53 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -14,11 +14,11 @@ from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, - CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, - FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, - SmallTextarea, SlugField, + AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, + BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, + ChainedModelMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, + FilterChoiceField, FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, + SelectWithPK, SmallTextarea, SlugField, ) from virtualization.models import Cluster from .constants import ( @@ -172,17 +172,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone'] -def site_status_choices(): - status_counts = {} - for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES] - - class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site q = forms.CharField(required=False, label='Search') - status = forms.MultipleChoiceField(choices=site_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=SITE_STATUS_CHOICES, + annotate=Site.objects.all(), + annotate_field='status', + required=False + ) region = FilterTreeNodeMultipleChoiceField( queryset=Region.objects.annotate(filter_count=Count('sites')), to_field_name='slug', @@ -1048,13 +1046,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'platform', 'serial'] -def device_status_choices(): - status_counts = {} - for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES] - - class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device q = forms.CharField(required=False, label='Search') @@ -1092,7 +1083,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --', ) - status = forms.MultipleChoiceField(choices=device_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=DEVICE_STATUS_CHOICES, + annotate=Device.objects.all(), + annotate_field='status', + required=False + ) mac_address = forms.CharField(required=False, label='MAC address') has_primary_ip = forms.NullBooleanField( required=False, diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index c6d73e6f4..5b2c6e672 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField, - ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField, - add_blank_choice, + AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, + CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, + SlugField, add_blank_choice, ) from virtualization.models import VirtualMachine from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES @@ -350,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description'] -def prefix_status_choices(): - status_counts = {} - for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES] - - class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix q = forms.CharField(required=False, label='Search') @@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=PREFIX_STATUS_CHOICES, + annotate=Prefix.objects.all(), + annotate_field='status', + required=False + ) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', @@ -688,20 +686,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): address = forms.CharField(label='IP Address') -def ipaddress_status_choices(): - status_counts = {} - for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES] - - -def ipaddress_role_choices(): - role_counts = {} - for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'): - role_counts[role['role']] = role['count'] - return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES] - - class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress q = forms.CharField(required=False, label='Search') @@ -721,8 +705,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) - role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=IPADDRESS_STATUS_CHOICES, + annotate=IPAddress.objects.all(), + annotate_field='status', + required=False + ) + role = AnnotatedMultipleChoiceField( + choices=IPADDRESS_ROLE_CHOICES, + annotate=IPAddress.objects.all(), + annotate_field='role', + required=False + ) # @@ -878,13 +872,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] -def vlan_status_choices(): - status_counts = {} - for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES] - - class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN q = forms.CharField(required=False, label='Search') @@ -903,7 +890,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=VLAN_STATUS_CHOICES, + annotate=VLAN.objects.all(), + annotate_field='status', + required=False + ) role = FilterChoiceField( queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index a2bfef001..6bf84cbe0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -6,6 +6,7 @@ import re from django import forms from django.conf import settings +from django.db.models import Count from django.urls import reverse_lazy from mptt.forms import TreeNodeMultipleChoiceField @@ -450,6 +451,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple pass +class AnnotatedMultipleChoiceField(forms.MultipleChoiceField): + """ + Render a set of static choices with each choice annotated to include a count of related objects. For example, this + field can be used to display a list of all available device statuses along with the number of devices currently + assigned to each status. + """ + + def annotate_choices(self): + queryset = self.annotate.values( + self.annotate_field + ).annotate( + count=Count(self.annotate_field) + ).order_by( + self.annotate_field + ) + choice_counts = { + c[self.annotate_field]: c['count'] for c in queryset + } + annotated_choices = [ + (c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices + ] + + return annotated_choices + + def __init__(self, choices, annotate, annotate_field, *args, **kwargs): + self.annotate = annotate + self.annotate_field = annotate_field + self.static_choices = choices + + super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs) + + class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 06b992203..e049767ae 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -13,9 +13,9 @@ from ipam.models import IPAddress from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice ) from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -361,13 +361,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments'] -def vm_status_choices(): - status_counts = {} - for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'): - status_counts[status['status']] = status['count'] - return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES] - - class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VirtualMachine q = forms.CharField(required=False, label='Search') @@ -395,7 +388,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): to_field_name='slug', null_label='-- None --' ) - status = forms.MultipleChoiceField(choices=vm_status_choices, required=False) + status = AnnotatedMultipleChoiceField( + choices=VM_STATUS_CHOICES, + annotate=VirtualMachine.objects.all(), + annotate_field='status', + required=False + ) tenant = FilterChoiceField( queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')), to_field_name='slug', From 7c043d9b4fe8ce7e4173846b4e68448b08c4b6cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 7 Mar 2018 17:01:51 -0500 Subject: [PATCH 19/36] Replaced tagged/untagged VLAN assignment widgets with a VLAN table; separate view for adding VLANs --- netbox/dcim/forms.py | 237 ++++++---------------- netbox/dcim/models.py | 10 + netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 6 + netbox/templates/dcim/interface_edit.html | 60 +++++- 5 files changed, 132 insertions(+), 182 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d5089cc53..0a62b460e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1652,63 +1652,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # Interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - display_field='display_name' - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - display_field='display_name' - ) - ) +class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans', + 'mode', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'device': forms.HiddenInput(), } + labels = { + 'mode': '802.1Q Mode', + } + help_texts = { + 'mode': "Nullifying the mode will clear any associated VLANs." + } def __init__(self, *args, **kwargs): super(InterfaceForm, self).__init__(*args, **kwargs) @@ -1725,58 +1685,66 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG ) - # Limit the queryset for the site to only include the interface's device's site - if device and device.site: - self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) - self.fields['site'].initial = None + def clean(self): + + super(InterfaceForm, self).clean() + + # Validate VLAN assignments + untagged_vlan = self.cleaned_data['untagged_vlan'] + tagged_vlans = self.cleaned_data['tagged_vlans'] + + if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + if untagged_vlan and untagged_vlan in tagged_vlans: + raise forms.ValidationError("VLAN {} cannot be both tagged and untagged.".format(untagged_vlan)) + +class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): + vlans = forms.MultipleChoiceField( + choices=[], + label='VLANs', + widget=forms.SelectMultiple(attrs={'size': 20}) + ) + tagged = forms.BooleanField( + required=False, + initial=True + ) + + class Meta: + model = Interface + fields = [] + + def __init__(self, *args, **kwargs): + + super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs) + + # Initialize VLAN choices + device = self.instance.device + vlan_choices = [ + ('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None)]), + (device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None)]), + ] + for group in VLANGroup.objects.filter(site=device.site): + vlan_choices.append(( + '{} / {}'.format(group.site.name, group.name), + [(vlan.pk, vlan) for vlan in VLAN.objects.filter(group=group)] + )) + self.fields['vlans'].choices = vlan_choices + + def save(self, *args, **kwargs): + + if self.cleaned_data['tagged']: + for vlan in self.cleaned_data['vlans']: + self.instance.tagged_vlans.add(vlan) else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None + self.instance.untagged_vlan = self.cleaned_data['vlans'][0] - # Limit the initial vlan choices - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - elif self.initial.get('untagged_vlan'): - filter_dict = { - 'group_id': self.instance.untagged_vlan.group, - 'site_id': self.instance.untagged_vlan.site, - } - elif self.initial.get('tagged_vlans'): - filter_dict = { - 'group_id': self.instance.tagged_vlans.first().group, - 'site_id': self.instance.tagged_vlans.first().site, - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - - def clean_tagged_vlans(self): - """ - Because tagged_vlans is a many-to-many relationship, validation must be done in the form - """ - if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']: - raise forms.ValidationError( - "An Access interface cannot have tagged VLANs." - ) - - if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']: - raise forms.ValidationError( - "Interface mode Tagged All implies all VLANs are tagged. " - "Do not select any tagged VLANs." - ) - - return self.cleaned_data['tagged_vlans'] + return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) -class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): +class InterfaceCreateForm(ComponentForm, forms.ModelForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) @@ -1790,50 +1758,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): ) description = forms.CharField(max_length=100, required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN Site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) def __init__(self, *args, **kwargs): @@ -1851,39 +1775,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): else: self.fields['lag'].queryset = Interface.objects.none() - # Limit the queryset for the site to only include the interface's device's site - if self.parent is not None and self.parent.site: - self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id) - self.fields['site'].initial = None - else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None - - # Limit the initial vlan choices - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - elif self.initial.get('untagged_vlan'): - filter_dict = { - 'group_id': self.untagged_vlan.group, - 'site_id': self.untagged_vlan.site, - } - elif self.initial.get('tagged_vlans'): - filter_dict = { - 'group_id': self.tagged_vlans.first().group, - 'site_id': self.tagged_vlans.first().site, - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index cfae0d6d2..3d8e41068 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1455,6 +1455,16 @@ class Interface(models.Model): "device/VM, or it must be global".format(self.untagged_vlan) }) + def save(self, *args, **kwargs): + + if self.mode is None: + self.untagged_vlan = None + self.tagged_vlans = [] + elif self.mode is IFACE_MODE_ACCESS: + self.tagged_vlans = [] + + return super(Interface, self).save(*args, **kwargs) + @property def parent(self): return self.device or self.virtual_machine diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e7e1e41df..5682bd8e7 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -185,6 +185,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2842cd549..a4449c9b0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1645,6 +1645,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): template_name = 'dcim/interface_edit.html' +class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_interface' + model = Interface + model_form = forms.InterfaceAssignVLANsForm + + class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 648d73151..071276e38 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -13,16 +13,58 @@ {% render_field form.mtu %} {% render_field form.mgmt_only %} {% render_field form.description %} - - -
-
802.1Q Encapsulation
-
{% render_field form.mode %} - {% render_field form.site %} - {% render_field form.vlan_group %} - {% render_field form.untagged_vlan %} - {% render_field form.tagged_vlans %}
+ {% with interface=form.instance %} + {% if interface.mode %} +
+
802.1Q VLANs
+ + + + + + + + {% if interface.untagged_vlan %} + + + + + + + {% endif %} + {% for vlan in interface.tagged_vlans.all %} + + + + + + + {% endfor %} + {% if not interface.untagged_vlan and not interface.tagged_vlans.exists %} + + + + {% endif %} +
VIDNameUntaggedTagged
{{ interface.untagged_vlan.vid }}{{ interface.untagged_vlan.name }} + + + +
{{ vlan.vid }}{{ vlan.name }} + + + +
+ No VLANs assigned +
+ +
+ {% endif %} + {% endwith %} {% endblock %} From 70625a5cb00eb5358b96591235d9109cfea0e44e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Mar 2018 13:25:51 -0500 Subject: [PATCH 20/36] Improved validation and workflow --- netbox/dcim/forms.py | 45 ++++++-- netbox/dcim/models.py | 8 +- netbox/templates/dcim/interface_edit.html | 134 ++++++++++++++-------- netbox/templates/utilities/obj_edit.html | 16 +-- 4 files changed, 137 insertions(+), 66 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 0a62b460e..794c310c4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1667,7 +1667,9 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): 'mode': '802.1Q Mode', } help_texts = { - 'mode': "Nullifying the mode will clear any associated VLANs." + 'mode': "Access: One untagged VLAN
" + "Tagged: One untagged VLAN and/or one or more tagged VLANs
" + "Tagged All: Implies all VLANs are available (w/optional untagged VLAN)" } def __init__(self, *args, **kwargs): @@ -1693,13 +1695,20 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): untagged_vlan = self.cleaned_data['untagged_vlan'] tagged_vlans = self.cleaned_data['tagged_vlans'] + # A VLAN cannot be both tagged and untagged + if untagged_vlan and untagged_vlan in tagged_vlans: + raise forms.ValidationError("VLAN {} cannot be both tagged and untagged.".format(untagged_vlan)) + + # Untagged interfaces cannot be assigned tagged VLANs if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: raise forms.ValidationError({ 'mode': "An access interface cannot have tagged VLANs assigned." }) - if untagged_vlan and untagged_vlan in tagged_vlans: - raise forms.ValidationError("VLAN {} cannot be both tagged and untagged.".format(untagged_vlan)) + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): vlans = forms.MultipleChoiceField( @@ -1720,31 +1729,51 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs) + if self.instance.mode == IFACE_MODE_ACCESS: + self.initial['tagged'] = False + + # Find all VLANs already assigned to the interface for exclusion from the list + assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()] + if self.instance.untagged_vlan is not None: + assigned_vlans.append(self.instance.untagged_vlan.pk) + # Initialize VLAN choices device = self.instance.device vlan_choices = [ - ('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None)]), - (device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None)]), + ('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None).exclude(pk__in=assigned_vlans)]), + (device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None).exclude(pk__in=assigned_vlans)]), ] for group in VLANGroup.objects.filter(site=device.site): vlan_choices.append(( '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in VLAN.objects.filter(group=group)] + [(vlan.pk, vlan) for vlan in VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)] )) self.fields['vlans'].choices = vlan_choices + def clean(self): + + super(InterfaceAssignVLANsForm, self).clean() + + # Only untagged VLANs permitted on an access interface + if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1: + raise forms.ValidationError("Only one VLAN may be assigned to an access interface.") + + # 'tagged' is required if more than one VLAN is selected + if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1: + raise forms.ValidationError("Only one untagged VLAN may be selected.") + def save(self, *args, **kwargs): if self.cleaned_data['tagged']: for vlan in self.cleaned_data['vlans']: self.instance.tagged_vlans.add(vlan) else: - self.instance.untagged_vlan = self.cleaned_data['vlans'][0] + self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) -class InterfaceCreateForm(ComponentForm, forms.ModelForm): +class InterfaceCreateForm(ComponentForm, forms.Form): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 3d8e41068..cb5a40c88 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1457,11 +1457,13 @@ class Interface(models.Model): def save(self, *args, **kwargs): + # Remove untagged VLAN assignment for non-802.1Q interfaces if self.mode is None: self.untagged_vlan = None - self.tagged_vlans = [] - elif self.mode is IFACE_MODE_ACCESS: - self.tagged_vlans = [] + + # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) + if self.mode is not IFACE_MODE_TAGGED: + self.tagged_vlans.clear() return super(Interface, self).save(*args, **kwargs) diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 071276e38..cef4a772b 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -16,55 +16,93 @@ {% render_field form.mode %} - {% with interface=form.instance %} - {% if interface.mode %} -
-
802.1Q VLANs
- + {% if obj.mode %} +
+
802.1Q VLANs
+
+ + + + + + + {% if obj.untagged_vlan %} - - - - + + + + - {% if interface.untagged_vlan %} - - - - - - - {% endif %} - {% for vlan in interface.tagged_vlans.all %} - - - - - - - {% endfor %} - {% if not interface.untagged_vlan and not interface.tagged_vlans.exists %} - - - - {% endif %} -
VIDNameUntaggedTagged
VIDNameUntaggedTagged + {{ obj.untagged_vlan.vid }} + {{ obj.untagged_vlan.name }} + + + +
{{ interface.untagged_vlan.vid }}{{ interface.untagged_vlan.name }} - - - -
{{ vlan.vid }}{{ vlan.name }} - - - -
- No VLANs assigned -
- + {% endif %} + {% for vlan in obj.tagged_vlans.all %} + + + {{ vlan.vid }} + + {{ vlan.name }} + + + + + + + + {% endfor %} + {% if not obj.untagged_vlan and not obj.tagged_vlans.exists %} + + + No VLANs assigned + + + {% else %} + + + + Clear + + + Clear All + + + {% endif %} + + - {% endif %} - {% endwith %} +
+ {% endif %} +{% endblock %} + +{% block buttons %} + {% if obj.pk %} + + + {% else %} + + + {% endif %} + Cancel +{% endblock %} + +{% block javascript %} + {% endblock %} diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 2b24208fd..16acc32ed 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/netbox/templates/utilities/obj_edit.html @@ -31,13 +31,15 @@
- {% if obj.pk %} - - {% else %} - - - {% endif %} - Cancel + {% block buttons %} + {% if obj.pk %} + + {% else %} + + + {% endif %} + Cancel + {% endblock %}
From aa8442a345284309652483fcee987af5f0b470f1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Mar 2018 13:29:08 -0500 Subject: [PATCH 21/36] Removed VLAN assignments from interface bulk editing --- netbox/dcim/forms.py | 70 ++------------------------------------------ 1 file changed, 2 insertions(+), 68 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 794c310c4..e13a78460 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1805,7 +1805,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): +class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) @@ -1814,53 +1814,9 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') description = forms.CharField(max_length=100, required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN Site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) class Meta: - nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans'] + nullable_fields = ['lag', 'mtu', 'description', 'mode'] def __init__(self, *args, **kwargs): super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) @@ -1875,28 +1831,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): else: self.fields['lag'].choices = [] - # Limit the queryset for the site to only include the interface's device's site - if device and device.site: - self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) - self.fields['site'].initial = None - else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None - - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - class InterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) From a75d7079dfa29492cdde9b6b482832701b2ddb65 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 8 Mar 2018 13:36:14 -0500 Subject: [PATCH 22/36] Fixed tests --- netbox/dcim/models.py | 2 +- netbox/dcim/tests/test_api.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index cb5a40c88..ac1affdef 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1462,7 +1462,7 @@ class Interface(models.Model): self.untagged_vlan = None # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.) - if self.mode is not IFACE_MODE_TAGGED: + if self.pk and self.mode is not IFACE_MODE_TAGGED: self.tagged_vlans.clear() return super(Interface, self).save(*args, **kwargs) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ef17a8786..37743b499 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -5,7 +5,9 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT +from dcim.constants import ( + IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, +) from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, @@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): data = { 'device': self.device.pk, 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id, self.vlan2.id], 'untagged_vlan': self.vlan3.id } @@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase): { 'device': self.device.pk, 'name': 'Test Interface 4', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, }, { 'device': self.device.pk, 'name': 'Test Interface 5', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, }, { 'device': self.device.pk, 'name': 'Test Interface 6', + 'mode': IFACE_MODE_TAGGED, 'tagged_vlans': [self.vlan1.id], 'untagged_vlan': self.vlan2.id, }, From b22744b03152a1a49a2e0445b86f770e0a853a10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 9 Mar 2018 13:51:17 -0500 Subject: [PATCH 23/36] Removed validation constraint prohibitting a VLAN from being both tagged and untagged --- netbox/dcim/forms.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e13a78460..ab17831a1 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1692,13 +1692,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): super(InterfaceForm, self).clean() # Validate VLAN assignments - untagged_vlan = self.cleaned_data['untagged_vlan'] tagged_vlans = self.cleaned_data['tagged_vlans'] - # A VLAN cannot be both tagged and untagged - if untagged_vlan and untagged_vlan in tagged_vlans: - raise forms.ValidationError("VLAN {} cannot be both tagged and untagged.".format(untagged_vlan)) - # Untagged interfaces cannot be assigned tagged VLANs if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: raise forms.ValidationError({ From ae6848b19440ad43f8460d073d91a45b4ffa71cd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Mar 2018 10:30:55 -0400 Subject: [PATCH 24/36] Fixed Slack URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d3bbe0e8..4027f22e0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss), -or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)! +or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)! ### Build Status From 8bd268d81cb80cff67d1ab9cdcea919acd8c14ce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Mar 2018 14:53:28 -0400 Subject: [PATCH 25/36] Closes #1944: Enable assigning VLANs to virtual machine interfaces --- netbox/dcim/forms.py | 63 +++++++++++++------ .../dcim/inc/interface_vlans_table.html | 55 ++++++++++++++++ netbox/templates/dcim/interface_edit.html | 54 +--------------- .../virtualization/interface_edit.html | 53 ++++++++++++++++ netbox/virtualization/forms.py | 31 ++++++++- netbox/virtualization/models.py | 3 +- netbox/virtualization/views.py | 1 + 7 files changed, 183 insertions(+), 77 deletions(-) create mode 100644 netbox/templates/dcim/inc/interface_vlans_table.html create mode 100644 netbox/templates/virtualization/interface_edit.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ab17831a1..6795726a6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, - BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, - ChainedModelMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, - FilterChoiceField, FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, - SelectWithPK, SmallTextarea, SlugField, + AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, + FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, ) from virtualization.models import Cluster from .constants import ( @@ -37,6 +36,12 @@ from .models import ( DEVICE_BY_PK_RE = '{\d+\}' +INTERFACE_MODE_HELP_TEXT = """ +Access: One untagged VLAN
+Tagged: One untagged VLAN and/or one or more tagged VLANs
+Tagged All: Implies all VLANs are available (w/optional untagged VLAN) +""" + def get_device_by_name_or_pk(name): """ @@ -1657,7 +1662,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', ] widgets = { @@ -1667,9 +1672,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): 'mode': '802.1Q Mode', } help_texts = { - 'mode': "Access: One untagged VLAN
" - "Tagged: One untagged VLAN and/or one or more tagged VLANs
" - "Tagged All: Implies all VLANs are available (w/optional untagged VLAN)" + 'mode': INTERFACE_MODE_HELP_TEXT, } def __init__(self, *args, **kwargs): @@ -1732,17 +1735,37 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): if self.instance.untagged_vlan is not None: assigned_vlans.append(self.instance.untagged_vlan.pk) - # Initialize VLAN choices - device = self.instance.device - vlan_choices = [ - ('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None).exclude(pk__in=assigned_vlans)]), - (device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None).exclude(pk__in=assigned_vlans)]), - ] - for group in VLANGroup.objects.filter(site=device.site): - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)] - )) + # Compile VLAN choices + vlan_choices = [] + + # Add global VLANs + global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) + vlan_choices.append(( + 'Global', [(vlan.pk, vlan) for vlan in global_vlans]) + ) + + # Add grouped global VLANs + for group in VLANGroup.objects.filter(site=None): + global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) + vlan_choices.append( + (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) + ) + + parent = self.instance.parent + if parent is not None: + + # Add site VLANs + site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans) + vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans])) + + # Add grouped site VLANs + for group in VLANGroup.objects.filter(site=parent.site): + site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) + vlan_choices.append(( + '{} / {}'.format(group.site.name, group.name), + [(vlan.pk, vlan) for vlan in site_group_vlans] + )) + self.fields['vlans'].choices = vlan_choices def clean(self): diff --git a/netbox/templates/dcim/inc/interface_vlans_table.html b/netbox/templates/dcim/inc/interface_vlans_table.html new file mode 100644 index 000000000..863921b0d --- /dev/null +++ b/netbox/templates/dcim/inc/interface_vlans_table.html @@ -0,0 +1,55 @@ + + + + + + + + {% with tagged_vlans=obj.tagged_vlans.all %} + {% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %} + + + + + + + {% endif %} + {% for vlan in tagged_vlans %} + + + + + + + {% endfor %} + {% if not obj.untagged_vlan and not tagged_vlans %} + + + + {% else %} + + + + + + {% endif %} + {% endwith %} +
VIDNameUntaggedTagged
+ {{ obj.untagged_vlan.vid }} + {{ obj.untagged_vlan.name }} + + + +
+ {{ vlan.vid }} + {{ vlan.name }} + + + +
+ No VLANs assigned +
+ Clear + + Clear All +
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index cef4a772b..0e212cf3e 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -19,59 +19,7 @@ {% if obj.mode %}
802.1Q VLANs
- - - - - - - - {% if obj.untagged_vlan %} - - - - - - - {% endif %} - {% for vlan in obj.tagged_vlans.all %} - - - - - - - {% endfor %} - {% if not obj.untagged_vlan and not obj.tagged_vlans.exists %} - - - - {% else %} - - - - - - {% endif %} -
VIDNameUntaggedTagged
- {{ obj.untagged_vlan.vid }} - {{ obj.untagged_vlan.name }} - - - -
- {{ vlan.vid }} - {{ vlan.name }} - - - -
- No VLANs assigned -
- Clear - - Clear All -
+ {% include 'dcim/inc/interface_vlans_table.html' %}