Split base form classes into separate modules under netbox.forms

This commit is contained in:
Jeremy Stretch
2025-10-21 14:02:58 -04:00
parent 3a212cc192
commit 1fdfff6be2
8 changed files with 290 additions and 260 deletions

View File

@@ -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

View File

@@ -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 *

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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}'
})