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)