Source code for lback.forms.forms
import logging
import copy
from typing import Dict, Any, Optional, List
from .fields import Field
from .validation import ValidationError
logger = logging.getLogger(__name__)
[docs]
class FormMetaclass(type):
"""
Metaclass for Form classes.
Automatically collects Field instances defined as class attributes
into a _declared_fields dictionary.
"""
def __new__(cls, name, bases, attrs):
declared_fields = {}
for key, value in attrs.items():
if isinstance(value, Field):
declared_fields[key] = value
if value.name is None:
value.name = key
attrs['_declared_fields'] = declared_fields
for field_name in declared_fields.keys():
attrs.pop(field_name)
new_class = super().__new__(cls, name, bases, attrs)
fields_from_bases = {}
for base in reversed(new_class.__mro__):
if hasattr(base, '_declared_fields'):
fields_from_bases.update(base._declared_fields)
new_class._declared_fields = fields_from_bases
new_class._declared_fields.update(declared_fields)
logger.debug(f"FormMetaclass: Created form class '{name}' with declared fields: {list(new_class._declared_fields.keys())}")
return new_class
[docs]
class Form(metaclass=FormMetaclass):
"""
Base class for all forms.
Forms are collections of fields that handle data binding and validation.
Uses FormMetaclass to automatically collect fields defined as class attributes.
"""
[docs]
def __init__(self, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None, initial: Optional[Dict[str, Any]] = None):
"""
Initializes a form instance.
Args:
data: A dictionary of data to bind to the form (e.g., from request.POST).
files: A dictionary of files to bind to the form (e.g., from request.FILES).
initial: A dictionary of initial data to populate the form with.
"""
self.data = data
self.files = files
self.initial = initial if initial is not None else {}
self.is_bound = data is not None or files is not None
self.fields: Dict[str, Field] = {}
for name, field_instance in self._declared_fields.items():
field_instance_copy = copy.deepcopy(field_instance)
field_instance_copy.name = name
self.fields[name] = field_instance_copy
self._errors: Optional[Dict[str, List[ValidationError]]] = None
self._non_field_errors: List[ValidationError] = []
self._cleaned_data: Dict[str, Any] = {}
if self.is_bound:
self._bind_fields()
[docs]
def add_error(self, field: Optional[str], error: ValidationError):
"""
Adds an error to a specific field or to the form's non-field errors.
Args:
field: The name of the field to associate the error with.
If None, the error is added to the form's non-field errors.
error: The ValidationError object containing the error message and code.
"""
if field is None:
self._non_field_errors.append(error)
logger.debug(f"Form: Added non-field error: {error.message}")
else:
if field in self.fields:
self.fields[field].errors.append(error)
logger.debug(f"Form: Added error to field '{field}': {error.message}")
else:
logger.warning(f"Form: Attempted to add error to non-existent field '{field}'. Error: {error.message}")
def _bind_fields(self):
"""Binds data and files from the request to each field."""
logger.debug(f"Form: Binding data to fields. Is bound: {self.is_bound}")
if not self.is_bound:
logger.warning("Form: _bind_fields called on an unbound form.")
return
for name, field in self.fields.items():
field.bind(name, self.data if self.data is not None else {}, self.files if self.files is not None else {})
logger.debug(f"Form: Bound field '{name}'.")
[docs]
def is_valid(self) -> bool:
self._errors = {}
self._non_field_errors = []
self._cleaned_data = {}
all_fields_valid = True
for name, field_instance in self.fields.items():
if not field_instance.is_valid():
all_fields_valid = False
self._errors[name] = field_instance.errors
else:
self._cleaned_data[name] = field_instance.cleaned_value
try:
self.clean()
except ValidationError as e:
self.add_error(None, e)
all_fields_valid = False
except Exception as e:
logger.exception(f"Form '{self.__class__.__name__}': Unexpected error in clean method: {e}")
self.add_error(None, ValidationError(f"An internal error occurred: {e}", code='internal_error'))
all_fields_valid = False
if self._non_field_errors:
self._errors['__all__'] = self._non_field_errors
return not bool(self._errors)
@property
def errors(self) -> Dict[str, List[ValidationError]]:
"""
Returns a dictionary of errors for the form.
Keys are field names, values are lists of ValidationError instances.
Includes '__all__' key for non-field errors.
Calls is_valid() implicitly if not already called.
"""
if self._errors is None:
self.is_valid()
return self._errors if self._errors is not None else {}
@property
def non_field_errors(self) -> List[ValidationError]:
"""Returns a list of non-field errors."""
if self._errors is None:
self.is_valid()
return self.errors.get('__all__', [])
@property
def cleaned_data(self) -> Dict[str, Any]:
"""
Returns a dictionary of validated and cleaned data for each field.
Only available if the form is valid.
"""
if self._errors is None:
self.is_valid()
return self._cleaned_data
def _render_field(self, name: str, field: Field) -> str:
"""Helper to render a single field including label, errors, and help text."""
output = []
widget_attrs = field.widget.build_attrs()
input_id = widget_attrs.get("id", f"id_{name}")
label_html = f'<label for="{input_id}">{field.label or name}:</label>'
output.append(label_html)
field_html = field.render()
output.append(field_html)
if field.errors:
errors_html = '<ul class="errorlist">'
for error in field.errors:
errors_html += f'<li>{error}</li>'
errors_html += '</ul>'
output.append(errors_html)
if field.help_text:
help_text_html = f'<p class="helptext">{field.help_text}</p>'
output.append(help_text_html)
return "".join(output)
[docs]
def as_p(self) -> str:
"""Renders the form as HTML <p> tags."""
output = []
if self.non_field_errors:
output.append('<ul class="errorlist">')
for error in self.non_field_errors:
output.append(f'<li>{error}</li>')
output.append('</ul>')
for name, field in self.fields.items():
field_content_html = self._render_field(name, field)
output.append(f'<p>{field_content_html}</p>')
return "\n".join(output)
[docs]
def as_ul(self) -> str:
"""Renders the form as HTML <ul> tags."""
output = []
if self.non_field_errors:
output.append('<ul class="errorlist">')
for error in self.non_field_errors:
output.append(f'<li>{error}</li>')
output.append('</ul>')
output.append('<ul>')
for name, field in self.fields.items():
field_content_html = self._render_field(name, field)
output.append(f'<li>{field_content_html}</li>')
output.append('</ul>')
return "\n".join(output)
[docs]
def as_table(self) -> str:
"""Renders the form as an HTML <table>."""
output = []
output.append('<table>')
if self.non_field_errors:
output.append('<tr><td colspan="2"><ul class="errorlist">')
for error in self.non_field_errors:
output.append(f'<li>{error}</li>')
output.append('</ul></td></tr>')
for name, field in self.fields.items():
widget_attrs = field.widget.build_attrs()
input_id = widget_attrs.get("id", f"id_{name}")
label_html = f'<label for="{input_id}">{field.label or name}:</label>'
label_cell = f'<th>{label_html}</th>'
field_html = field.render()
errors_html = ''
if field.errors:
errors_html = '<ul class="errorlist">'
for error in field.errors:
errors_html += f'<li>{error}</li>'
errors_html += '</ul>'
help_text_html = ''
if field.help_text:
help_text_html = f'<p class="helptext">{field.help_text}</p>'
field_cell_content = f'{field_html} {errors_html} {help_text_html}'
field_cell = f'<td>{field_cell_content}</td>'
output.append('<tr>')
output.append(label_cell)
output.append(field_cell)
output.append('</tr>')
output.append('</table>')
return "\n".join(output)
[docs]
def clean(self):
"""
Performs form-level validation that depends on multiple fields.
Should raise ValidationError for non-field errors.
Can also modify self.cleaned_data.
Subclasses should override this method if form-level validation is needed.
"""
pass