Clean up forms modules

This commit is contained in:
jeremystretch 2023-04-13 16:31:35 -04:00
parent 2dc50b6108
commit 47af8b0208
10 changed files with 245 additions and 231 deletions

View File

@ -1,5 +1,6 @@
from .constants import *
from .fields import *
from .forms import *
from .mixins import *
from .utils import *
from .widgets import *

View File

@ -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__ = (

View File

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

View 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

View File

@ -0,0 +1,5 @@
from .apiselect import *
from .array import *
from .datetime import *
from .misc import *
from .select import *

View File

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

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

View 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'

View 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'

View 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'