mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 17:26:10 -06:00
Clean up forms modules
This commit is contained in:
parent
2dc50b6108
commit
47af8b0208
@ -1,5 +1,6 @@
|
||||
from .constants import *
|
||||
from .fields import *
|
||||
from .forms import *
|
||||
from .mixins import *
|
||||
from .utils import *
|
||||
from .widgets import *
|
||||
|
@ -1,14 +1,9 @@
|
||||
import csv
|
||||
from io import StringIO
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.choices import unpack_grouped_choices
|
||||
from utilities.forms.utils import parse_csv, validate_csv
|
||||
from utilities.utils import content_type_identifier
|
||||
|
||||
__all__ = (
|
||||
|
@ -2,11 +2,9 @@ import re
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from .widgets import APISelect, APISelectMultiple, ClearableFileInput
|
||||
from .mixins import BootstrapMixin
|
||||
|
||||
__all__ = (
|
||||
'BootstrapMixin',
|
||||
'BulkEditForm',
|
||||
'BulkRenameForm',
|
||||
'ConfirmationForm',
|
||||
@ -17,69 +15,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Mixins
|
||||
#
|
||||
|
||||
class BootstrapMixin:
|
||||
"""
|
||||
Add the base Bootstrap CSS classes to form elements.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
exempt_widgets = [
|
||||
forms.FileInput,
|
||||
forms.RadioSelect,
|
||||
APISelect,
|
||||
APISelectMultiple,
|
||||
ClearableFileInput,
|
||||
]
|
||||
|
||||
for field_name, field in self.fields.items():
|
||||
css = field.widget.attrs.get('class', '')
|
||||
|
||||
if field.widget.__class__ in exempt_widgets:
|
||||
continue
|
||||
|
||||
elif isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs['class'] = f'{css} form-check-input'
|
||||
|
||||
elif isinstance(field.widget, forms.SelectMultiple):
|
||||
if 'size' not in field.widget.attrs:
|
||||
field.widget.attrs['class'] = f'{css} netbox-static-select'
|
||||
|
||||
elif isinstance(field.widget, forms.Select):
|
||||
field.widget.attrs['class'] = f'{css} netbox-static-select'
|
||||
|
||||
else:
|
||||
field.widget.attrs['class'] = f'{css} form-control'
|
||||
|
||||
if field.required and not isinstance(field.widget, forms.FileInput):
|
||||
field.widget.attrs['required'] = 'required'
|
||||
|
||||
if 'placeholder' not in field.widget.attrs and field.label is not None:
|
||||
field.widget.attrs['placeholder'] = field.label
|
||||
|
||||
def is_valid(self):
|
||||
is_valid = super().is_valid()
|
||||
|
||||
# Apply is-invalid CSS class to fields with errors
|
||||
if not is_valid:
|
||||
for field_name in self.errors:
|
||||
# Ignore e.g. __all__
|
||||
if field := self.fields.get(field_name):
|
||||
css = field.widget.attrs.get('class', '')
|
||||
field.widget.attrs['class'] = f'{css} is-invalid'
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
#
|
||||
# Form classes
|
||||
#
|
||||
|
||||
class ReturnURLForm(forms.Form):
|
||||
"""
|
||||
Provides a hidden return URL field to control where the user is directed after the form is submitted.
|
||||
|
62
netbox/utilities/forms/mixins.py
Normal file
62
netbox/utilities/forms/mixins.py
Normal file
@ -0,0 +1,62 @@
|
||||
from django import forms
|
||||
|
||||
from .widgets import APISelect, APISelectMultiple, ClearableFileInput
|
||||
|
||||
__all__ = (
|
||||
'BootstrapMixin',
|
||||
)
|
||||
|
||||
|
||||
class BootstrapMixin:
|
||||
"""
|
||||
Add the base Bootstrap CSS classes to form elements.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
exempt_widgets = [
|
||||
forms.FileInput,
|
||||
forms.RadioSelect,
|
||||
APISelect,
|
||||
APISelectMultiple,
|
||||
ClearableFileInput,
|
||||
]
|
||||
|
||||
for field_name, field in self.fields.items():
|
||||
css = field.widget.attrs.get('class', '')
|
||||
|
||||
if field.widget.__class__ in exempt_widgets:
|
||||
continue
|
||||
|
||||
elif isinstance(field.widget, forms.CheckboxInput):
|
||||
field.widget.attrs['class'] = f'{css} form-check-input'
|
||||
|
||||
elif isinstance(field.widget, forms.SelectMultiple):
|
||||
if 'size' not in field.widget.attrs:
|
||||
field.widget.attrs['class'] = f'{css} netbox-static-select'
|
||||
|
||||
elif isinstance(field.widget, forms.Select):
|
||||
field.widget.attrs['class'] = f'{css} netbox-static-select'
|
||||
|
||||
else:
|
||||
field.widget.attrs['class'] = f'{css} form-control'
|
||||
|
||||
if field.required and not isinstance(field.widget, forms.FileInput):
|
||||
field.widget.attrs['required'] = 'required'
|
||||
|
||||
if 'placeholder' not in field.widget.attrs and field.label is not None:
|
||||
field.widget.attrs['placeholder'] = field.label
|
||||
|
||||
def is_valid(self):
|
||||
is_valid = super().is_valid()
|
||||
|
||||
# Apply is-invalid CSS class to fields with errors
|
||||
if not is_valid:
|
||||
for field_name in self.errors:
|
||||
# Ignore e.g. __all__
|
||||
if field := self.fields.get(field_name):
|
||||
css = field.widget.attrs.get('class', '')
|
||||
field.widget.attrs['class'] = f'{css} is-invalid'
|
||||
|
||||
return is_valid
|
5
netbox/utilities/forms/widgets/__init__.py
Normal file
5
netbox/utilities/forms/widgets/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from .apiselect import *
|
||||
from .array import *
|
||||
from .datetime import *
|
||||
from .misc import *
|
||||
from .select import *
|
@ -1,120 +1,14 @@
|
||||
import json
|
||||
from typing import Dict, Sequence, List, Tuple, Union
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
|
||||
from utilities.choices import ColorChoices
|
||||
from .utils import add_blank_choice, parse_numeric_range
|
||||
|
||||
__all__ = (
|
||||
'APISelect',
|
||||
'APISelectMultiple',
|
||||
'BulkEditNullBooleanSelect',
|
||||
'ClearableFileInput',
|
||||
'ColorSelect',
|
||||
'DatePicker',
|
||||
'DateTimePicker',
|
||||
'HTMXSelect',
|
||||
'MarkdownWidget',
|
||||
'NumericArrayField',
|
||||
'SelectDurationWidget',
|
||||
'SelectSpeedWidget',
|
||||
'SelectWithPK',
|
||||
'SlugWidget',
|
||||
'TimePicker',
|
||||
)
|
||||
|
||||
JSONPrimitive = Union[str, bool, int, float, None]
|
||||
QueryParamValue = Union[JSONPrimitive, Sequence[JSONPrimitive]]
|
||||
QueryParam = Dict[str, QueryParamValue]
|
||||
ProcessedParams = Sequence[Dict[str, Sequence[JSONPrimitive]]]
|
||||
|
||||
|
||||
class SlugWidget(forms.TextInput):
|
||||
"""
|
||||
Subclass TextInput and add a slug regeneration button next to the form field.
|
||||
"""
|
||||
template_name = 'widgets/sluginput.html'
|
||||
|
||||
|
||||
class ColorSelect(forms.Select):
|
||||
"""
|
||||
Extends the built-in Select widget to colorize each <option>.
|
||||
"""
|
||||
option_template_name = 'widgets/colorselect_option.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = add_blank_choice(ColorChoices)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'netbox-color-select'
|
||||
|
||||
|
||||
class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
|
||||
"""
|
||||
A Select widget for NullBooleanFields
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Override the built-in choice labels
|
||||
self.choices = (
|
||||
('1', '---------'),
|
||||
('2', 'Yes'),
|
||||
('3', 'No'),
|
||||
)
|
||||
self.attrs['class'] = 'netbox-static-select'
|
||||
|
||||
|
||||
class SelectWithPK(forms.Select):
|
||||
"""
|
||||
Include the primary key of each option in the option label (e.g. "Router7 (4721)").
|
||||
"""
|
||||
option_template_name = 'widgets/select_option_with_pk.html'
|
||||
|
||||
|
||||
class SelectSpeedWidget(forms.NumberInput):
|
||||
"""
|
||||
Speed field with dropdown selections for convenience.
|
||||
"""
|
||||
template_name = 'widgets/select_speed.html'
|
||||
|
||||
|
||||
class SelectDurationWidget(forms.NumberInput):
|
||||
"""
|
||||
Dropdown to select one of several common options for a time duration (in minutes).
|
||||
"""
|
||||
template_name = 'widgets/select_duration.html'
|
||||
|
||||
|
||||
class MarkdownWidget(forms.Textarea):
|
||||
template_name = 'widgets/markdown_input.html'
|
||||
|
||||
|
||||
class NumericArrayField(SimpleArrayField):
|
||||
|
||||
def clean(self, value):
|
||||
if value and not self.to_python(value):
|
||||
raise forms.ValidationError(f'Invalid list ({value}). '
|
||||
f'Must be numeric and ranges must be in ascending order')
|
||||
return super().clean(value)
|
||||
|
||||
def to_python(self, value):
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
value = ','.join([str(n) for n in parse_numeric_range(value)])
|
||||
return super().to_python(value)
|
||||
|
||||
|
||||
class ClearableFileInput(forms.ClearableFileInput):
|
||||
"""
|
||||
Override Django's stock ClearableFileInput with a custom template.
|
||||
"""
|
||||
template_name = 'widgets/clearable_file_input.html'
|
||||
|
||||
|
||||
class APISelect(forms.Select):
|
||||
"""
|
||||
@ -144,7 +38,7 @@ class APISelect(forms.Select):
|
||||
result.static_params = {}
|
||||
return result
|
||||
|
||||
def _process_query_param(self, key: str, value: JSONPrimitive) -> None:
|
||||
def _process_query_param(self, key, value) -> None:
|
||||
"""
|
||||
Based on query param value's type and value, update instance's dynamic/static params.
|
||||
"""
|
||||
@ -187,7 +81,7 @@ class APISelect(forms.Select):
|
||||
else:
|
||||
self.static_params[key] = [value]
|
||||
|
||||
def _process_query_params(self, query_params: QueryParam) -> None:
|
||||
def _process_query_params(self, query_params):
|
||||
"""
|
||||
Process an entire query_params dictionary, and handle primitive or list values.
|
||||
"""
|
||||
@ -199,7 +93,7 @@ class APISelect(forms.Select):
|
||||
else:
|
||||
self._process_query_param(key, value)
|
||||
|
||||
def _serialize_params(self, key: str, params: ProcessedParams) -> None:
|
||||
def _serialize_params(self, key, params):
|
||||
"""
|
||||
Serialize dynamic or static query params to JSON and add the serialized value to
|
||||
the widget attributes by `key`.
|
||||
@ -214,7 +108,7 @@ class APISelect(forms.Select):
|
||||
# attributes to HTML elements and parsed on the client.
|
||||
self.attrs[key] = json.dumps([*current, *params], separators=(',', ':'))
|
||||
|
||||
def _add_dynamic_params(self) -> None:
|
||||
def _add_dynamic_params(self):
|
||||
"""
|
||||
Convert post-processed dynamic query params to data structure expected by front-
|
||||
end, serialize the value to JSON, and add it to the widget attributes.
|
||||
@ -227,7 +121,7 @@ class APISelect(forms.Select):
|
||||
except IndexError as error:
|
||||
raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
|
||||
|
||||
def _add_static_params(self) -> None:
|
||||
def _add_static_params(self):
|
||||
"""
|
||||
Convert post-processed static query params to data structure expected by front-
|
||||
end, serialize the value to JSON, and add it to the widget attributes.
|
||||
@ -240,7 +134,7 @@ class APISelect(forms.Select):
|
||||
except IndexError as error:
|
||||
raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
|
||||
|
||||
def add_query_params(self, query_params: QueryParam) -> None:
|
||||
def add_query_params(self, query_params):
|
||||
"""
|
||||
Proccess & add a dictionary of URL query parameters to the widget attributes.
|
||||
"""
|
||||
@ -251,7 +145,7 @@ class APISelect(forms.Select):
|
||||
# Add processed static parameters to widget attributes.
|
||||
self._add_static_params()
|
||||
|
||||
def add_query_param(self, key: str, value: QueryParamValue) -> None:
|
||||
def add_query_param(self, key, value) -> None:
|
||||
"""
|
||||
Process & add a key/value pair of URL query parameters to the widget attributes.
|
||||
"""
|
||||
@ -264,49 +158,3 @@ class APISelectMultiple(APISelect, forms.SelectMultiple):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.attrs['data-multiple'] = 1
|
||||
|
||||
|
||||
class DatePicker(forms.TextInput):
|
||||
"""
|
||||
Date picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'date-picker'
|
||||
self.attrs['placeholder'] = 'YYYY-MM-DD'
|
||||
|
||||
|
||||
class DateTimePicker(forms.TextInput):
|
||||
"""
|
||||
DateTime picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'datetime-picker'
|
||||
self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
|
||||
|
||||
|
||||
class TimePicker(forms.TextInput):
|
||||
"""
|
||||
Time picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'time-picker'
|
||||
self.attrs['placeholder'] = 'hh:mm:ss'
|
||||
|
||||
|
||||
class HTMXSelect(forms.Select):
|
||||
"""
|
||||
Selection widget that will re-generate the HTML form upon the selection of a new option.
|
||||
"""
|
||||
def __init__(self, hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs):
|
||||
_attrs = {
|
||||
'hx-get': hx_url,
|
||||
'hx-include': f'#{hx_target_id}',
|
||||
'hx-target': f'#{hx_target_id}',
|
||||
}
|
||||
if attrs:
|
||||
_attrs.update(attrs)
|
||||
|
||||
super().__init__(attrs=_attrs, **kwargs)
|
24
netbox/utilities/forms/widgets/array.py
Normal file
24
netbox/utilities/forms/widgets/array.py
Normal file
@ -0,0 +1,24 @@
|
||||
from django import forms
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
|
||||
from ..utils import parse_numeric_range
|
||||
|
||||
__all__ = (
|
||||
'NumericArrayField',
|
||||
)
|
||||
|
||||
|
||||
class NumericArrayField(SimpleArrayField):
|
||||
|
||||
def clean(self, value):
|
||||
if value and not self.to_python(value):
|
||||
raise forms.ValidationError(f'Invalid list ({value}). '
|
||||
f'Must be numeric and ranges must be in ascending order')
|
||||
return super().clean(value)
|
||||
|
||||
def to_python(self, value):
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
value = ','.join([str(n) for n in parse_numeric_range(value)])
|
||||
return super().to_python(value)
|
37
netbox/utilities/forms/widgets/datetime.py
Normal file
37
netbox/utilities/forms/widgets/datetime.py
Normal file
@ -0,0 +1,37 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'DatePicker',
|
||||
'DateTimePicker',
|
||||
'TimePicker',
|
||||
)
|
||||
|
||||
|
||||
class DatePicker(forms.TextInput):
|
||||
"""
|
||||
Date picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'date-picker'
|
||||
self.attrs['placeholder'] = 'YYYY-MM-DD'
|
||||
|
||||
|
||||
class DateTimePicker(forms.TextInput):
|
||||
"""
|
||||
DateTime picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'datetime-picker'
|
||||
self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss'
|
||||
|
||||
|
||||
class TimePicker(forms.TextInput):
|
||||
"""
|
||||
Time picker using Flatpickr.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'time-picker'
|
||||
self.attrs['placeholder'] = 'hh:mm:ss'
|
28
netbox/utilities/forms/widgets/misc.py
Normal file
28
netbox/utilities/forms/widgets/misc.py
Normal file
@ -0,0 +1,28 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'ClearableFileInput',
|
||||
'MarkdownWidget',
|
||||
'SlugWidget',
|
||||
)
|
||||
|
||||
|
||||
class ClearableFileInput(forms.ClearableFileInput):
|
||||
"""
|
||||
Override Django's stock ClearableFileInput with a custom template.
|
||||
"""
|
||||
template_name = 'widgets/clearable_file_input.html'
|
||||
|
||||
|
||||
class MarkdownWidget(forms.Textarea):
|
||||
"""
|
||||
Provide a live preview for Markdown-formatted content.
|
||||
"""
|
||||
template_name = 'widgets/markdown_input.html'
|
||||
|
||||
|
||||
class SlugWidget(forms.TextInput):
|
||||
"""
|
||||
Subclass TextInput and add a slug regeneration button next to the form field.
|
||||
"""
|
||||
template_name = 'widgets/sluginput.html'
|
79
netbox/utilities/forms/widgets/select.py
Normal file
79
netbox/utilities/forms/widgets/select.py
Normal file
@ -0,0 +1,79 @@
|
||||
from django import forms
|
||||
|
||||
from utilities.choices import ColorChoices
|
||||
from ..utils import add_blank_choice
|
||||
|
||||
__all__ = (
|
||||
'BulkEditNullBooleanSelect',
|
||||
'ColorSelect',
|
||||
'HTMXSelect',
|
||||
'SelectDurationWidget',
|
||||
'SelectSpeedWidget',
|
||||
'SelectWithPK',
|
||||
)
|
||||
|
||||
|
||||
class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
|
||||
"""
|
||||
A Select widget for NullBooleanFields
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Override the built-in choice labels
|
||||
self.choices = (
|
||||
('1', '---------'),
|
||||
('2', 'Yes'),
|
||||
('3', 'No'),
|
||||
)
|
||||
self.attrs['class'] = 'netbox-static-select'
|
||||
|
||||
|
||||
class ColorSelect(forms.Select):
|
||||
"""
|
||||
Extends the built-in Select widget to colorize each <option>.
|
||||
"""
|
||||
option_template_name = 'widgets/colorselect_option.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = add_blank_choice(ColorChoices)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs['class'] = 'netbox-color-select'
|
||||
|
||||
|
||||
class HTMXSelect(forms.Select):
|
||||
"""
|
||||
Selection widget that will re-generate the HTML form upon the selection of a new option.
|
||||
"""
|
||||
def __init__(self, hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs):
|
||||
_attrs = {
|
||||
'hx-get': hx_url,
|
||||
'hx-include': f'#{hx_target_id}',
|
||||
'hx-target': f'#{hx_target_id}',
|
||||
}
|
||||
if attrs:
|
||||
_attrs.update(attrs)
|
||||
|
||||
super().__init__(attrs=_attrs, **kwargs)
|
||||
|
||||
|
||||
class SelectWithPK(forms.Select):
|
||||
"""
|
||||
Include the primary key of each option in the option label (e.g. "Router7 (4721)").
|
||||
"""
|
||||
option_template_name = 'widgets/select_option_with_pk.html'
|
||||
|
||||
|
||||
class SelectDurationWidget(forms.NumberInput):
|
||||
"""
|
||||
Dropdown to select one of several common options for a time duration (in minutes).
|
||||
"""
|
||||
template_name = 'widgets/select_duration.html'
|
||||
|
||||
|
||||
class SelectSpeedWidget(forms.NumberInput):
|
||||
"""
|
||||
Speed field with dropdown selections for convenience.
|
||||
"""
|
||||
template_name = 'widgets/select_speed.html'
|
Loading…
Reference in New Issue
Block a user