diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py
index e8825ad18..9dd9328b8 100644
--- a/netbox/ipam/constants.py
+++ b/netbox/ipam/constants.py
@@ -59,6 +59,11 @@ IPADDRESS_ROLES_NONUNIQUE = (
VLAN_VID_MIN = 1
VLAN_VID_MAX = 4094
+# models values for ContentTypes which may be VLANGroup scope types
+VLANGROUP_SCOPE_TYPES = (
+ 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
+)
+
#
# Services
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index 5da20ea4d..18dfd985f 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -1,4 +1,5 @@
from django import forms
+from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
@@ -9,9 +10,10 @@ from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
- add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, DatePicker,
- DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
- ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+ add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField,
+ CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
+ NumericArrayField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+ BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
from .choices import *
@@ -1137,6 +1139,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
#
class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
+ scope_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
+ required=False
+ )
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1223,9 +1229,11 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
super().clean()
# Assign scope based on scope_type
- if self.cleaned_data['scope_type']:
+ if self.cleaned_data.get('scope_type'):
scope_field = self.cleaned_data['scope_type'].model
self.instance.scope = self.cleaned_data.get(scope_field)
+ else:
+ self.instance.scope_id = None
class VLANGroupCSVForm(CustomFieldModelCSVForm):
diff --git a/netbox/ipam/migrations/0045_vlangroup_scope.py b/netbox/ipam/migrations/0045_vlangroup_scope.py
index 8795750d2..c1f3c013f 100644
--- a/netbox/ipam/migrations/0045_vlangroup_scope.py
+++ b/netbox/ipam/migrations/0045_vlangroup_scope.py
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='vlangroup',
name='scope_type',
- field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
+ field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=('region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'),
),
migrations.AlterModelOptions(
name='vlangroup',
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 040d23746..d0f5375e2 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -35,9 +35,7 @@ class VLANGroup(OrganizationalModel):
scope_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
- limit_choices_to=Q(
- model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']
- ),
+ limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
blank=True,
null=True
)
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
index 07481ed60..001f50bfa 100644
--- a/netbox/utilities/forms/fields.py
+++ b/netbox/utilities/forms/fields.py
@@ -20,6 +20,7 @@ from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
__all__ = (
'CommentField',
+ 'ContentTypeChoiceField',
'CSVChoiceField',
'CSVContentTypeField',
'CSVDataField',
@@ -36,6 +37,99 @@ __all__ = (
)
+class CommentField(forms.CharField):
+ """
+ A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text.
+ """
+ widget = forms.Textarea
+ default_label = ''
+ # TODO: Port Markdown cheat sheet to internal documentation
+ default_helptext = ' '\
+ ''\
+ 'Markdown syntax is supported'
+
+ def __init__(self, *args, **kwargs):
+ required = kwargs.pop('required', False)
+ label = kwargs.pop('label', self.default_label)
+ help_text = kwargs.pop('help_text', self.default_helptext)
+ super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
+
+
+class SlugField(forms.SlugField):
+ """
+ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
+ """
+ def __init__(self, slug_source='name', *args, **kwargs):
+ label = kwargs.pop('label', "Slug")
+ help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
+ widget = kwargs.pop('widget', widgets.SlugWidget)
+ super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
+ self.widget.attrs['slug-source'] = slug_source
+
+
+class TagFilterField(forms.MultipleChoiceField):
+ """
+ A filter field for the tags of a model. Only the tags used by a model are displayed.
+
+ :param model: The model of the filter
+ """
+ widget = widgets.StaticSelect2Multiple
+
+ def __init__(self, model, *args, **kwargs):
+ def get_choices():
+ tags = model.tags.annotate(
+ count=Count('extras_taggeditem_items')
+ ).order_by('name')
+ return [
+ (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
+ ]
+
+ # Choices are fetched each time the form is initialized
+ super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
+
+
+class LaxURLField(forms.URLField):
+ """
+ Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
+ (e.g. http://myserver/ is valid)
+ """
+ default_validators = [EnhancedURLValidator()]
+
+
+class JSONField(_JSONField):
+ """
+ Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not self.help_text:
+ self.help_text = 'Enter context data in JSON format.'
+ self.widget.attrs['placeholder'] = ''
+
+ def prepare_value(self, value):
+ if isinstance(value, InvalidJSONInput):
+ return value
+ if value is None:
+ return ''
+ return json.dumps(value, sort_keys=True, indent=4)
+
+
+class ContentTypeChoiceField(forms.ModelChoiceField):
+
+ def __init__(self, queryset, *args, **kwargs):
+ # Order ContentTypes by app_label
+ queryset = queryset.order_by('app_label', 'model')
+ super().__init__(queryset, *args, **kwargs)
+
+ def label_from_instance(self, obj):
+ meta = obj.model_class()._meta
+ return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
+
+
+#
+# CSV fields
+#
+
class CSVDataField(forms.CharField):
"""
A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first
@@ -167,6 +261,10 @@ class CSVContentTypeField(CSVModelChoiceField):
raise forms.ValidationError(f'Invalid object type')
+#
+# Expansion fields
+#
+
class ExpandableNameField(forms.CharField):
"""
A field which allows for numeric range expansion
@@ -212,56 +310,9 @@ class ExpandableIPAddressField(forms.CharField):
return [value]
-class CommentField(forms.CharField):
- """
- A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text.
- """
- widget = forms.Textarea
- default_label = ''
- # TODO: Port Markdown cheat sheet to internal documentation
- default_helptext = ' '\
- ''\
- 'Markdown syntax is supported'
-
- def __init__(self, *args, **kwargs):
- required = kwargs.pop('required', False)
- label = kwargs.pop('label', self.default_label)
- help_text = kwargs.pop('help_text', self.default_helptext)
- super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)
-
-
-class SlugField(forms.SlugField):
- """
- Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
- """
- def __init__(self, slug_source='name', *args, **kwargs):
- label = kwargs.pop('label', "Slug")
- help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
- widget = kwargs.pop('widget', widgets.SlugWidget)
- super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
- self.widget.attrs['slug-source'] = slug_source
-
-
-class TagFilterField(forms.MultipleChoiceField):
- """
- A filter field for the tags of a model. Only the tags used by a model are displayed.
-
- :param model: The model of the filter
- """
- widget = widgets.StaticSelect2Multiple
-
- def __init__(self, model, *args, **kwargs):
- def get_choices():
- tags = model.tags.annotate(
- count=Count('extras_taggeditem_items')
- ).order_by('name')
- return [
- (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags
- ]
-
- # Choices are fetched each time the form is initialized
- super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
-
+#
+# Dynamic fields
+#
class DynamicModelChoiceMixin:
"""
@@ -373,29 +424,3 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
"""
filter = django_filters.ModelMultipleChoiceFilter
widget = widgets.APISelectMultiple
-
-
-class LaxURLField(forms.URLField):
- """
- Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
- (e.g. http://myserver/ is valid)
- """
- default_validators = [EnhancedURLValidator()]
-
-
-class JSONField(_JSONField):
- """
- Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
- """
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- if not self.help_text:
- self.help_text = 'Enter context data in JSON format.'
- self.widget.attrs['placeholder'] = ''
-
- def prepare_value(self, value):
- if isinstance(value, InvalidJSONInput):
- return value
- if value is None:
- return ''
- return json.dumps(value, sort_keys=True, indent=4)