import logging
import re
import os
from typing import Any, Dict, Optional, List, Type, Callable, Tuple
from .widgets import Widget, TextInput, CheckboxInput, Select, FileInput
from .validation import ValidationError
logger = logging.getLogger(__name__)
[docs]
class Field:
"""
Base class for all form fields.
Fields manage data validation and rendering using a widget.
"""
widget: Type[Widget] = TextInput
[docs]
def __init__(
self,
required: bool = True,
label: Optional[str] = None,
initial: Any = None,
widget: Optional[Widget] = None,
attrs: Optional[Dict[str, Any]] = None,
help_text: Optional[str] = None,
error_messages: Optional[Dict[str, str]] = None,
validators: Optional[List[Callable[[Any], None]]] = None
):
"""
Initializes a form field.
Args:
required: If True, the field is required. Defaults to True.
label: The label for the field in the form. If None, a label will be generated.
initial: The initial value of the field when the form is displayed.
widget: The widget instance to use for rendering. Defaults to an instance of self.widget.
attrs: A dictionary of HTML attributes for the widget.
help_text: Optional help text for the field.
error_messages: A dictionary of custom error messages for validation errors.
validators: A list of validator functions to run after basic field validation.
Each validator function should accept one argument (the cleaned value)
and raise ValidationError if validation fails.
"""
self.required = required
self.label = label
self.initial = initial
self.widget = widget if widget is not None else self.widget(attrs=attrs)
self.attrs = attrs if attrs is not None else {}
self.help_text = help_text
self.error_messages = {
'required': 'This field is required.',
'invalid': 'Enter a valid value.',
}
if error_messages:
self.error_messages.update(error_messages)
self.validators = validators if validators is not None else []
self.name: Optional[str] = None
self.value: Any = None
self.cleaned_value: Any = None
self.errors: List[ValidationError] = []
self.is_bound = False
[docs]
def bind(self, name: str, data: Dict[str, Any], files: Dict[str, Any]):
"""
Binds the field to data from the request.
Called by the Form when data is submitted.
Args:
name: The name of the field (from the form definition).
data: The dictionary of data (e.g., request.POST).
files: The dictionary of files (e.g., request.FILES).
"""
self.name = name
self.is_bound = True
self.value = self.widget.value_from_datadict(data, files, name)
logger.debug(f"Field '{self.name}': Bound with raw value: {repr(self.value)}")
[docs]
def is_valid(self) -> bool:
"""
Validates the field's data.
Returns True if the field is valid, False otherwise.
Validation errors are stored in self.errors.
"""
self.errors = []
if self.required and (self.value is None or (isinstance(self.value, str) and self.value.strip() == '' and not isinstance(self, FileField))):
if isinstance(self, FileField) and self.value is None:
self.errors.append(ValidationError(self.error_messages['required'], code='required'))
logger.debug(f"Field '{self.name}': Validation failed - Required FileField is missing.")
return False
if not isinstance(self, FileField) and (self.value is None or (isinstance(self.value, str) and self.value.strip() == '')):
self.errors.append(ValidationError(self.error_messages['required'], code='required'))
logger.debug(f"Field '{self.name}': Validation failed - Required field is missing/empty.")
return False
try:
self.cleaned_value = self.to_python(self.value)
logger.debug(f"Field '{self.name}': to_python successful. Cleaned value: {repr(self.cleaned_value)}")
if not self.errors:
for validator in self.validators:
try:
validator(self.cleaned_value)
logger.debug(f"Field '{self.name}': Custom validator {getattr(validator, '__name__', str(validator))} passed.")
except ValidationError as e:
self.errors.append(e)
logger.debug(f"Field '{self.name}': Custom validator {getattr(validator, '__name__', str(validator))} failed: {e.message}")
except Exception as e:
logger.exception(f"Field '{self.name}': Unexpected error running custom validator {getattr(validator, '__name__', str(validator))}: {e}")
self.errors.append(ValidationError(f"Validator error: {e}", code='validator_error'))
except ValidationError as e:
self.errors.append(e)
logger.debug(f"Field '{self.name}': Validation failed in to_python: {e.message}")
except Exception as e:
logger.exception(f"Field '{self.name}': Unexpected error during to_python: {e}")
self.errors.append(ValidationError(f"Internal error: {e}", code='internal_error'))
return not bool(self.errors)
[docs]
def to_python(self, value: Any) -> Any:
"""
Converts the raw value from the widget into a Python object.
This method performs basic type conversion and may raise ValidationError.
Subclasses must implement this method.
Args:
value: The raw value obtained from the widget.
Returns:
The converted Python object.
Raises:
ValidationError: If the value cannot be converted or is invalid.
"""
raise NotImplementedError("Subclasses must implement the 'to_python' method.")
[docs]
def render(self, value: Any = None, attrs: Optional[Dict[str, Any]] = None) -> str:
"""
Renders the field using its widget.
Args:
value: The value to render. Defaults to self.value if bound, self.initial if unbound.
Explicitly passing a value here overrides the default.
attrs: Additional HTML attributes for the widget. Merged with self.attrs.
Returns:
An HTML string for the field's input element.
"""
if value is None:
value_to_render = self.value if self.is_bound else self.initial
else:
value_to_render = value
return self.widget.render(name=self.name, value=value_to_render, attrs=self.attrs)
[docs]
class CharField(Field):
"""
A field that handles text input.
Validates that the input is a string.
"""
widget = TextInput
[docs]
def __init__(
self,
max_length: Optional[int] = None,
min_length: Optional[int] = None,
strip: bool = True,
empty_value: str = '',
**kwargs: Any
):
"""
Initializes a CharField.
"""
self.max_length = max_length
self.min_length = min_length
self.strip = strip
self.empty_value = empty_value
super().__init__(**kwargs)
self.error_messages.update({
'max_length': 'Ensure this value has at most %(limit_value)d characters (it has %(show_value)d).',
'min_length': 'Ensure this value has at least %(limit_value)d characters (it has %(show_value)d).',
})
[docs]
def to_python(self, value: Any) -> Optional[str]:
"""
Converts the input value to a string and performs basic validation.
"""
if value is None or (isinstance(value, str) and value.strip() == ''):
return self.empty_value if not self.required else None
try:
value = str(value)
except Exception:
raise ValidationError(self.error_messages['invalid'], code='invalid')
if self.strip:
value = value.strip()
if value == '' and not self.required:
return self.empty_value
if self.max_length is not None and len(value) > self.max_length:
raise ValidationError(
self.error_messages['max_length'],
code='max_length',
params={'limit_value': self.max_length, 'show_value': len(value)}
)
if self.min_length is not None and len(value) < self.min_length and value != '':
raise ValidationError(
self.error_messages['min_length'],
code='min_length',
params={'limit_value': self.min_length, 'show_value': len(value)}
)
return value
[docs]
class IntegerField(Field):
"""
A field that handles integer input.
Validates that the input can be converted to an integer.
"""
widget = TextInput
[docs]
def __init__(
self,
min_value: Optional[int] = None,
max_value: Optional[int] = None,
empty_value: Optional[int] = None,
**kwargs: Any
):
"""
Initializes an IntegerField.
"""
self.min_value = min_value
self.max_value = max_value
self.empty_value = empty_value
super().__init__(**kwargs)
self.error_messages.update({
'invalid': 'Enter a valid integer.',
'min_value': 'Ensure this value is greater than or equal to %(limit_value)d.',
'max_value': 'Ensure this value is less than or equal to %(limit_value)d.',
})
[docs]
def to_python(self, value: Any) -> Optional[int]:
"""
Converts the input value to an integer and performs validation.
"""
if value is None or (isinstance(value, str) and value.strip() == ''):
return self.empty_value if not self.required else None
try:
if isinstance(value, str):
value = value.strip()
cleaned_value = int(value)
except (ValueError, TypeError):
raise ValidationError(self.error_messages['invalid'], code='invalid')
if self.min_value is not None and cleaned_value < self.min_value:
raise ValidationError(
self.error_messages['min_value'],
code='min_value',
params={'limit_value': self.min_value}
)
if self.max_value is not None and cleaned_value > self.max_value:
raise ValidationError(
self.error_messages['max_value'],
code='max_value',
params={'limit_value': self.max_value}
)
return cleaned_value
[docs]
class BooleanField(Field):
"""
A field that handles boolean input, typically from a checkbox.
"""
widget = CheckboxInput
[docs]
def __init__(
self,
required: bool = False,
initial: bool = False,
empty_value: bool = False,
**kwargs: Any
):
"""
Initializes a BooleanField.
"""
kwargs['required'] = required
kwargs['initial'] = initial
self.empty_value = empty_value
super().__init__(**kwargs)
self.error_messages.update({
'invalid': 'Enter a valid boolean value.',
})
[docs]
def to_python(self, value: Any) -> bool:
"""
Converts the input value to a boolean.
"""
if isinstance(value, bool):
return value
if value is None or (isinstance(value, str) and value.strip() == ''):
return self.empty_value if not self.required else False
if isinstance(value, str):
lower_value = value.strip().lower()
if lower_value in ('on', '1', 'true', 'yes'):
return True
if lower_value in ('0', 'false', 'no'):
return False
if isinstance(value, (int, float)):
return bool(value)
raise ValidationError(self.error_messages['invalid'], code='invalid')
[docs]
class EmailField(CharField):
"""
A field that handles email input.
Inherits from CharField and adds email format validation.
"""
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
[docs]
def __init__(self, **kwargs: Any):
"""
Initializes an EmailField.
"""
super().__init__(**kwargs)
self.error_messages.update({
'invalid': 'Enter a valid email address.',
})
[docs]
def to_python(self, value: Any) -> Optional[str]:
"""
Converts the input value to a string and validates email format.
"""
cleaned_value = super().to_python(value)
if cleaned_value is None or (isinstance(cleaned_value, str) and cleaned_value == ''):
return cleaned_value
if not self.EMAIL_REGEX.match(cleaned_value):
raise ValidationError(self.error_messages['invalid'], code='invalid_email')
return cleaned_value
[docs]
class ChoiceField(Field):
"""
A field that represents a choice from a limited set of options.
Typically rendered with a Select widget.
"""
widget = Select
[docs]
def __init__(
self,
choices: List[Tuple[Any, str]],
empty_value: Optional[Any] = None,
**kwargs: Any
):
"""
Initializes a ChoiceField.
"""
self.choices = choices
self.empty_value = empty_value
if 'widget' not in kwargs:
kwargs['widget'] = Select(choices=choices, attrs=kwargs.get('attrs'))
kwargs.pop('attrs', None)
super().__init__(**kwargs)
self.error_messages.update({
'invalid_choice': 'Select a valid choice. %(value)s is not one of the available choices.',
})
self._valid_values = {str(value) for value, label in choices}
[docs]
def to_python(self, value: Any) -> Optional[Any]:
"""
Validates that the selected value is one of the available choices
and returns the cleaned value.
"""
if value is None or (isinstance(value, str) and value.strip() == ''):
return self.empty_value if not self.required else None
submitted_value_str = str(value)
if submitted_value_str not in self._valid_values:
raise ValidationError(
self.error_messages['invalid_choice'],
code='invalid_choice',
params={'value': value}
)
cleaned_value = None
for option_value, _ in self.choices:
if str(option_value) == submitted_value_str:
cleaned_value = option_value
break
if cleaned_value is None:
logger.warning(f"ChoiceField '{self.name}': Could not find original value for submitted string '{submitted_value_str}' in choices. Returning submitted value as is.")
cleaned_value = value
return cleaned_value
[docs]
class FileField(Field):
"""
A field that handles file uploads.
The cleaned value is the uploaded file object itself.
"""
widget = FileInput
[docs]
def __init__(
self,
required: bool = True,
empty_value: Optional[Any] = None,
allow_empty_file: bool = False,
max_size: Optional[int] = None,
allowed_extensions: Optional[List[str]] = None,
**kwargs: Any
):
"""
Initializes a FileField.
Args:
required: If True, a file must be uploaded. Defaults to True.
empty_value: The cleaned value to return if the field is not required and no file is uploaded.
Defaults to None.
**kwargs: Additional arguments for the base Field class.
"""
self.empty_value = empty_value
self.allow_empty_file = allow_empty_file
self.max_size = max_size
self.allowed_extensions = allowed_extensions
super().__init__(required=required, **kwargs)
self.error_messages.update({
'invalid': 'No file was submitted.',
'required': 'This field is required. Please select a file.',
'max_size': 'Ensure this file size is not greater than %(limit_value)s bytes (it is %(show_value)s bytes).',
'allowed_extensions': 'File extension "%(extension)s" is not allowed. Allowed extensions are: %(allowed_extensions)s.',
'empty_file': 'The submitted file is empty.',
})
[docs]
def to_python(self, value: Any) -> Optional[Any]:
"""
Validates the uploaded file value.
The cleaned value is the uploaded file object itself (or None).
"""
if value is None:
return self.empty_value if not self.required else None
if not (hasattr(value, 'read') and callable(value.read) and
hasattr(value, 'name') and isinstance(value.name, str) and
hasattr(value, 'size') and isinstance(value.size, int)):
logger.error(f"FileField: Submitted value does not appear to be a valid file object. Type: {type(value)}, Attributes: {dir(value)}")
raise ValidationError(self.error_messages['invalid'], code='invalid_file_object')
if not self.allow_empty_file and value.size == 0:
raise ValidationError(self.error_messages['empty_file'], code='empty_file')
if self.max_size is not None and value.size > self.max_size:
raise ValidationError(
self.error_messages['max_size'],
code='max_size',
params={'limit_value': self.max_size, 'show_value': value.size}
)
if self.allowed_extensions:
_, file_extension = os.path.splitext(value.name)
if file_extension.lower() not in [ext.lower() for ext in self.allowed_extensions]:
raise ValidationError(
self.error_messages['allowed_extensions'],
code='invalid_extension',
params={'extension': file_extension, 'allowed_extensions': ", ".join(self.allowed_extensions)}
)
return value