From 1fdfff6be20739f480c6de1cf86e1819b0f2828d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Oct 2025 14:02:58 -0400 Subject: [PATCH] Split base form classes into separate modules under netbox.forms --- netbox/extras/forms/filtersets.py | 2 +- netbox/netbox/forms/__init__.py | 62 +-------- netbox/netbox/forms/base.py | 202 ----------------------------- netbox/netbox/forms/bulk_edit.py | 67 ++++++++++ netbox/netbox/forms/bulk_import.py | 40 ++++++ netbox/netbox/forms/filtersets.py | 46 +++++++ netbox/netbox/forms/model_forms.py | 76 +++++++++++ netbox/netbox/forms/search.py | 55 ++++++++ 8 files changed, 290 insertions(+), 260 deletions(-) delete mode 100644 netbox/netbox/forms/base.py create mode 100644 netbox/netbox/forms/bulk_edit.py create mode 100644 netbox/netbox/forms/bulk_import.py create mode 100644 netbox/netbox/forms/filtersets.py create mode 100644 netbox/netbox/forms/model_forms.py create mode 100644 netbox/netbox/forms/search.py diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 759c1fc4b..c542b6404 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -6,7 +6,7 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from extras.choices import * from extras.models import * from netbox.events import get_event_type_choices -from netbox.forms.base import NetBoxModelFilterSetForm +from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from tenancy.models import Tenant, TenantGroup from users.models import Group, User diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index f88fb18bc..fa06fafa0 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -1,57 +1,5 @@ -import re - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from netbox.search import LookupTypes -from netbox.search.backends import search_backend - -from .base import * - -LOOKUP_CHOICES = ( - ('', _('Partial match')), - (LookupTypes.EXACT, _('Exact match')), - (LookupTypes.STARTSWITH, _('Starts with')), - (LookupTypes.ENDSWITH, _('Ends with')), - (LookupTypes.REGEX, _('Regex')), -) - - -class SearchForm(forms.Form): - q = forms.CharField( - label=_('Search'), - widget=forms.TextInput( - attrs={ - 'hx-get': '', - 'hx-target': '#object_list', - 'hx-trigger': 'keyup[target.value.length >= 3] changed delay:500ms', - } - ) - ) - obj_types = forms.MultipleChoiceField( - choices=[], - required=False, - label=_('Object type(s)') - ) - lookup = forms.ChoiceField( - choices=LOOKUP_CHOICES, - initial=LookupTypes.PARTIAL, - required=False, - label=_('Lookup') - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['obj_types'].choices = search_backend.get_object_types() - - def clean(self): - - # Validate regular expressions - if self.cleaned_data['lookup'] == LookupTypes.REGEX: - try: - re.compile(self.cleaned_data['q']) - except re.error as e: - raise forms.ValidationError({ - 'q': f'Invalid regular expression: {e}' - }) +from .model_forms import * +from .bulk_import import * +from .bulk_edit import * +from .filtersets import * +from .search import * diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py deleted file mode 100644 index 3c7cf0348..000000000 --- a/netbox/netbox/forms/base.py +++ /dev/null @@ -1,202 +0,0 @@ -import json - -from django import forms -from django.contrib.contenttypes.models import ContentType -from django.db.models import Q -from django.utils.translation import gettext_lazy as _ - -from core.models import ObjectType -from extras.choices import * -from extras.models import CustomField, Tag -from users.models import Owner -from utilities.forms import BulkEditForm, CSVModelForm -from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField, CSVModelChoiceField -from utilities.forms.mixins import CheckLastUpdatedMixin -from .mixins import ChangelogMessageMixin, CustomFieldsMixin, OwnerMixin, SavedFiltersMixin, TagsMixin - -__all__ = ( - 'NetBoxModelForm', - 'NetBoxModelImportForm', - 'NetBoxModelBulkEditForm', - 'NetBoxModelFilterSetForm', -) - - -class NetBoxModelForm( - ChangelogMessageMixin, - CheckLastUpdatedMixin, - CustomFieldsMixin, - OwnerMixin, - TagsMixin, - forms.ModelForm -): - """ - Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. - - Attributes: - fieldsets: An iterable of FieldSets which define a name and set of fields to display per section of - the rendered form (optional). If not defined, the all fields will be rendered as a single section. - """ - fieldsets = () - - def _get_content_type(self): - return ContentType.objects.get_for_model(self._meta.model) - - def _get_form_field(self, customfield): - if self.instance.pk: - form_field = customfield.to_form_field(set_initial=False) - initial = self.instance.custom_field_data.get(customfield.name) - if customfield.type == CustomFieldTypeChoices.TYPE_JSON: - form_field.initial = json.dumps(initial) - else: - form_field.initial = initial - return form_field - - return customfield.to_form_field() - - def clean(self): - - # Save custom field data on instance - for cf_name, customfield in self.custom_fields.items(): - if cf_name not in self.fields: - # Custom fields may be absent when performing bulk updates via import - continue - key = cf_name[3:] # Strip "cf_" from field name - value = self.cleaned_data.get(cf_name) - - # Convert "empty" values to null - if value in self.fields[cf_name].empty_values: - self.instance.custom_field_data[key] = None - else: - if customfield.type == CustomFieldTypeChoices.TYPE_JSON and type(value) is str: - value = json.loads(value) - self.instance.custom_field_data[key] = customfield.serialize(value) - - return super().clean() - - def _post_clean(self): - """ - Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance. - """ - self.instance._m2m_values = {} - for field in self.instance._meta.local_many_to_many: - if field.name in self.cleaned_data: - self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name]) - - return super()._post_clean() - - -class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): - """ - Base form for creating NetBox objects from CSV data. Used for bulk importing. - """ - owner = CSVModelChoiceField( - queryset=Owner.objects.all(), - required=False, - to_field_name='name', - help_text=_("Name of the object's owner") - ) - tags = CSVModelMultipleChoiceField( - label=_('Tags'), - queryset=Tag.objects.all(), - required=False, - to_field_name='slug', - help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")') - ) - - def _get_custom_fields(self, content_type): - return CustomField.objects.filter( - object_types=content_type, - ui_editable=CustomFieldUIEditableChoices.YES - ) - - def _get_form_field(self, customfield): - return customfield.to_form_field(for_csv_import=True) - - -class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, OwnerMixin, BulkEditForm): - """ - Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom - fields and adding/removing tags. - - Attributes: - fieldsets: An iterable of two-tuples which define a heading and field set to display per section of - the rendered form (optional). If not defined, the all fields will be rendered as a single section. - """ - fieldsets = None - - pk = forms.ModelMultipleChoiceField( - queryset=None, # Set from self.model on init - widget=forms.MultipleHiddenInput - ) - add_tags = DynamicModelMultipleChoiceField( - label=_('Add tags'), - queryset=Tag.objects.all(), - required=False - ) - remove_tags = DynamicModelMultipleChoiceField( - label=_('Remove tags'), - queryset=Tag.objects.all(), - required=False - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['pk'].queryset = self.model.objects.all() - - # Restrict tag fields by model - object_type = ObjectType.objects.get_for_model(self.model) - self.fields['add_tags'].widget.add_query_param('for_object_type_id', object_type.pk) - self.fields['remove_tags'].widget.add_query_param('for_object_type_id', object_type.pk) - - self._extend_nullable_fields() - - def _get_form_field(self, customfield): - return customfield.to_form_field(set_initial=False, enforce_required=False) - - def _extend_nullable_fields(self): - nullable_common_fields = ['owner'] - nullable_custom_fields = [ - name for name, customfield in self.custom_fields.items() - if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES) - ] - self.nullable_fields = ( - *self.nullable_fields, - *nullable_common_fields, - *nullable_custom_fields, - ) - - -class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form): - """ - Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the - corresponding FilterSet *must* provide a `q` filter. - - Attributes: - model: The model class associated with the form - fieldsets: An iterable of two-tuples which define a heading and field set to display per section of - the rendered form (optional). If not defined, the all fields will be rendered as a single section. - selector_fields: An iterable of names of fields to display by default when rendering the form as - a selector widget - """ - q = forms.CharField( - required=False, - label=_('Search') - ) - owner_id = DynamicModelMultipleChoiceField( - queryset=Owner.objects.all(), - required=False, - label=_('Owner'), - ) - - selector_fields = ('filter_id', 'q') - - def _get_custom_fields(self, content_type): - return super()._get_custom_fields(content_type).exclude( - Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | - Q(type=CustomFieldTypeChoices.TYPE_JSON) - ) - - def _get_form_field(self, customfield): - return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False) diff --git a/netbox/netbox/forms/bulk_edit.py b/netbox/netbox/forms/bulk_edit.py new file mode 100644 index 000000000..e0d3ad348 --- /dev/null +++ b/netbox/netbox/forms/bulk_edit.py @@ -0,0 +1,67 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from core.models import ObjectType +from extras.choices import * +from extras.models import Tag +from utilities.forms import BulkEditForm +from utilities.forms.fields import DynamicModelMultipleChoiceField +from .mixins import ChangelogMessageMixin, CustomFieldsMixin, OwnerMixin + +__all__ = ( + 'NetBoxModelBulkEditForm', +) + + +class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, OwnerMixin, BulkEditForm): + """ + Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom + fields and adding/removing tags. + + Attributes: + fieldsets: An iterable of two-tuples which define a heading and field set to display per section of + the rendered form (optional). If not defined, the all fields will be rendered as a single section. + """ + fieldsets = None + + pk = forms.ModelMultipleChoiceField( + queryset=None, # Set from self.model on init + widget=forms.MultipleHiddenInput + ) + add_tags = DynamicModelMultipleChoiceField( + label=_('Add tags'), + queryset=Tag.objects.all(), + required=False + ) + remove_tags = DynamicModelMultipleChoiceField( + label=_('Remove tags'), + queryset=Tag.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['pk'].queryset = self.model.objects.all() + + # Restrict tag fields by model + object_type = ObjectType.objects.get_for_model(self.model) + self.fields['add_tags'].widget.add_query_param('for_object_type_id', object_type.pk) + self.fields['remove_tags'].widget.add_query_param('for_object_type_id', object_type.pk) + + self._extend_nullable_fields() + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) + + def _extend_nullable_fields(self): + nullable_common_fields = ['owner'] + nullable_custom_fields = [ + name for name, customfield in self.custom_fields.items() + if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES) + ] + self.nullable_fields = ( + *self.nullable_fields, + *nullable_common_fields, + *nullable_custom_fields, + ) diff --git a/netbox/netbox/forms/bulk_import.py b/netbox/netbox/forms/bulk_import.py new file mode 100644 index 000000000..3504844de --- /dev/null +++ b/netbox/netbox/forms/bulk_import.py @@ -0,0 +1,40 @@ +from django.utils.translation import gettext_lazy as _ + +from extras.choices import * +from extras.models import CustomField, Tag +from users.models import Owner +from utilities.forms import CSVModelForm +from utilities.forms.fields import CSVModelMultipleChoiceField, CSVModelChoiceField +from .model_forms import NetBoxModelForm + +__all__ = ( + 'NetBoxModelImportForm', +) + + +class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): + """ + Base form for creating NetBox objects from CSV data. Used for bulk importing. + """ + owner = CSVModelChoiceField( + queryset=Owner.objects.all(), + required=False, + to_field_name='name', + help_text=_("Name of the object's owner") + ) + tags = CSVModelMultipleChoiceField( + label=_('Tags'), + queryset=Tag.objects.all(), + required=False, + to_field_name='slug', + help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")') + ) + + def _get_custom_fields(self, content_type): + return CustomField.objects.filter( + object_types=content_type, + ui_editable=CustomFieldUIEditableChoices.YES + ) + + def _get_form_field(self, customfield): + return customfield.to_form_field(for_csv_import=True) diff --git a/netbox/netbox/forms/filtersets.py b/netbox/netbox/forms/filtersets.py new file mode 100644 index 000000000..fb4e496c8 --- /dev/null +++ b/netbox/netbox/forms/filtersets.py @@ -0,0 +1,46 @@ +from django import forms +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from extras.choices import * +from users.models import Owner +from utilities.forms.fields import DynamicModelMultipleChoiceField +from .mixins import CustomFieldsMixin, SavedFiltersMixin + +__all__ = ( + 'NetBoxModelFilterSetForm', +) + + +class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form): + """ + Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the + corresponding FilterSet *must* provide a `q` filter. + + Attributes: + model: The model class associated with the form + fieldsets: An iterable of two-tuples which define a heading and field set to display per section of + the rendered form (optional). If not defined, the all fields will be rendered as a single section. + selector_fields: An iterable of names of fields to display by default when rendering the form as + a selector widget + """ + q = forms.CharField( + required=False, + label=_('Search') + ) + owner_id = DynamicModelMultipleChoiceField( + queryset=Owner.objects.all(), + required=False, + label=_('Owner'), + ) + + selector_fields = ('filter_id', 'q') + + def _get_custom_fields(self, content_type): + return super()._get_custom_fields(content_type).exclude( + Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | + Q(type=CustomFieldTypeChoices.TYPE_JSON) + ) + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False) diff --git a/netbox/netbox/forms/model_forms.py b/netbox/netbox/forms/model_forms.py new file mode 100644 index 000000000..6766abc6d --- /dev/null +++ b/netbox/netbox/forms/model_forms.py @@ -0,0 +1,76 @@ +import json + +from django import forms +from django.contrib.contenttypes.models import ContentType + +from extras.choices import * +from utilities.forms.mixins import CheckLastUpdatedMixin +from .mixins import ChangelogMessageMixin, CustomFieldsMixin, OwnerMixin, TagsMixin + +__all__ = ( + 'NetBoxModelForm', +) + + +class NetBoxModelForm( + ChangelogMessageMixin, + CheckLastUpdatedMixin, + CustomFieldsMixin, + OwnerMixin, + TagsMixin, + forms.ModelForm +): + """ + Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. + + Attributes: + fieldsets: An iterable of FieldSets which define a name and set of fields to display per section of + the rendered form (optional). If not defined, the all fields will be rendered as a single section. + """ + fieldsets = () + + def _get_content_type(self): + return ContentType.objects.get_for_model(self._meta.model) + + def _get_form_field(self, customfield): + if self.instance.pk: + form_field = customfield.to_form_field(set_initial=False) + initial = self.instance.custom_field_data.get(customfield.name) + if customfield.type == CustomFieldTypeChoices.TYPE_JSON: + form_field.initial = json.dumps(initial) + else: + form_field.initial = initial + return form_field + + return customfield.to_form_field() + + def clean(self): + + # Save custom field data on instance + for cf_name, customfield in self.custom_fields.items(): + if cf_name not in self.fields: + # Custom fields may be absent when performing bulk updates via import + continue + key = cf_name[3:] # Strip "cf_" from field name + value = self.cleaned_data.get(cf_name) + + # Convert "empty" values to null + if value in self.fields[cf_name].empty_values: + self.instance.custom_field_data[key] = None + else: + if customfield.type == CustomFieldTypeChoices.TYPE_JSON and type(value) is str: + value = json.loads(value) + self.instance.custom_field_data[key] = customfield.serialize(value) + + return super().clean() + + def _post_clean(self): + """ + Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance. + """ + self.instance._m2m_values = {} + for field in self.instance._meta.local_many_to_many: + if field.name in self.cleaned_data: + self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name]) + + return super()._post_clean() diff --git a/netbox/netbox/forms/search.py b/netbox/netbox/forms/search.py new file mode 100644 index 000000000..855c8e273 --- /dev/null +++ b/netbox/netbox/forms/search.py @@ -0,0 +1,55 @@ +import re + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from netbox.search import LookupTypes +from netbox.search.backends import search_backend + +LOOKUP_CHOICES = ( + ('', _('Partial match')), + (LookupTypes.EXACT, _('Exact match')), + (LookupTypes.STARTSWITH, _('Starts with')), + (LookupTypes.ENDSWITH, _('Ends with')), + (LookupTypes.REGEX, _('Regex')), +) + + +class SearchForm(forms.Form): + q = forms.CharField( + label=_('Search'), + widget=forms.TextInput( + attrs={ + 'hx-get': '', + 'hx-target': '#object_list', + 'hx-trigger': 'keyup[target.value.length >= 3] changed delay:500ms', + } + ) + ) + obj_types = forms.MultipleChoiceField( + choices=[], + required=False, + label=_('Object type(s)') + ) + lookup = forms.ChoiceField( + choices=LOOKUP_CHOICES, + initial=LookupTypes.PARTIAL, + required=False, + label=_('Lookup') + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['obj_types'].choices = search_backend.get_object_types() + + def clean(self): + + # Validate regular expressions + if self.cleaned_data['lookup'] == LookupTypes.REGEX: + try: + re.compile(self.cleaned_data['q']) + except re.error as e: + raise forms.ValidationError({ + 'q': f'Invalid regular expression: {e}' + })