"""Form fields for the django-tomselect package."""
__all__ = [
"TomSelectChoiceField",
"TomSelectMultipleChoiceField",
"TomSelectModelChoiceField",
"TomSelectModelMultipleChoiceField",
"TomSelectTokenField",
]
from typing import Any
from django import forms
from django.core.exceptions import FieldError, ImproperlyConfigured, ValidationError
from django.db.models import QuerySet
from django.forms.widgets import Widget
from django_tomselect.app_settings import (
GLOBAL_DEFAULT_CONFIG,
TomSelectConfig,
merge_configs,
)
from django_tomselect.lazy_utils import LazyView
from django_tomselect.logging import get_logger
from django_tomselect.models import EmptyModel
from django_tomselect.widgets import (
TomSelectIterablesMultipleWidget,
TomSelectIterablesWidget,
TomSelectModelMultipleWidget,
TomSelectModelWidget,
TomSelectTokenWidget,
)
logger = get_logger(__name__)
[docs]
class BaseTomSelectMixin:
"""Mixin providing common initialization logic for TomSelect fields.
Extracts TomSelectConfig-related kwargs, sets up widget config and attrs.
Handles merging of configuration from global defaults and instance-specific
settings, managing widget attributes, and proper widget initialization.
"""
field_base_class: type[forms.Field] = forms.Field
widget_class: type[Widget] | None = None # To be defined by subclasses
config: TomSelectConfig
widget: Widget
@staticmethod
def _resolve_config(
config: TomSelectConfig | dict[str, Any] | None,
raise_type_error: bool = False,
) -> TomSelectConfig:
"""Resolve config input to a merged TomSelectConfig.
Handles None, dict, and TomSelectConfig inputs, merging with global defaults.
Args:
config: The config input from the field constructor.
raise_type_error: If True, re-raise TypeError instead of swallowing it.
Returns:
A fully merged TomSelectConfig.
Raises:
TypeError: If config dict has invalid keys and raise_type_error is True.
"""
if config is not None and not isinstance(config, TomSelectConfig):
try:
config = TomSelectConfig(**config)
except TypeError as e:
if raise_type_error:
logger.error("Failed to create TomSelectConfig from dict: %s", e)
raise TypeError(f"Invalid configuration: {e}") from e
logger.error("Failed to create TomSelectConfig from dict: %s", e, exc_info=True)
config = None
except ValueError as e:
logger.error("Error creating TomSelectConfig: %s", e, exc_info=True)
config = None
return merge_configs(GLOBAL_DEFAULT_CONFIG, config)
def _create_widget(self, kwargs: dict[str, Any]) -> None:
"""Create and configure the widget from config and kwargs.
Merges attrs from config and kwargs, validates widget_class,
and instantiates the widget.
Args:
kwargs: The remaining kwargs dict (attrs will be popped from it).
Raises:
ValueError: If widget_class is not defined on the subclass.
"""
attrs: dict[str, Any] = kwargs.pop("attrs", {}) or {}
if self.config.attrs:
attrs = {**self.config.attrs, **attrs}
logger.debug("Final attrs to be passed to widget: %s", attrs)
if not self.widget_class:
logger.error("Widget class not defined for %s", self.__class__.__name__)
raise ValueError(f"Widget class not defined for {self.__class__.__name__}")
self.widget = self.widget_class(config=self.config) # type: ignore[call-arg]
self.widget.attrs = attrs
[docs]
def __init__(
self, *args: Any, choices: Any = None, config: TomSelectConfig | dict[str, Any] | None = None, **kwargs: Any
) -> None:
"""Initialize a TomSelect field with optional configuration."""
try:
if choices is not None:
logger.warning("There is no need to pass choices to a TomSelectField. It will be ignored.")
# Extract and pop widget-specific arguments for TomSelectConfig
for k in [k for k in kwargs if hasattr(TomSelectConfig, k) and not hasattr(self.field_base_class, k)]:
kwargs.pop(k, None)
self.config = self._resolve_config(config)
logger.debug("Final config to be passed to widget: %s", self.config)
self._create_widget(kwargs)
super().__init__(*args, **kwargs)
except (TypeError, ValueError, AttributeError, ImproperlyConfigured) as e:
logger.error("Error initializing %s: %s", self.__class__.__name__, e, exc_info=True)
raise
[docs]
class BaseTomSelectModelMixin:
"""Mixin providing common initialization logic for TomSelect model fields.
Similar to BaseTomSelectMixin but also handles queryset defaults and provides
specialized validation for model-based selections. Manages configuration merging,
widget attributes, and proper widget initialization with model querysets.
"""
field_base_class: type[forms.Field] = forms.Field
widget_class: type[Widget] | None = None # To be defined by subclasses
config: TomSelectConfig
widget: Widget
queryset: QuerySet
instance: Any
_lazy_view: LazyView | None
[docs]
def __init__(
self,
*args: Any,
queryset: QuerySet | None = None,
config: TomSelectConfig | dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize a TomSelect model field with optional configuration."""
if queryset is not None:
logger.warning("There is no need to pass a queryset to a TomSelectModelField. It will be ignored.")
self.instance: Any = kwargs.get("instance")
# Extract and pop widget-specific arguments for TomSelectConfig
for k in [k for k in kwargs if hasattr(TomSelectConfig, k) and not hasattr(self.field_base_class, k)]:
kwargs.pop(k, None)
# Resolve config, build the widget, and call parent init under one guard so
# configuration errors (e.g. ImproperlyConfigured from an invalid label_field)
# are logged consistently before propagating, matching BaseTomSelectMixin.
try:
self.config = BaseTomSelectMixin._resolve_config(config, raise_type_error=True)
logger.debug("Final config to be passed to widget: %s", self.config)
self._lazy_view = None
# Create widget with merged attrs
BaseTomSelectMixin._create_widget(self, kwargs)
# Set to_field_name based on value_field configuration to aid ModelChoiceField validation
if hasattr(self.config, "value_field") and self.config.value_field:
if self.config.value_field != "pk":
kwargs["to_field_name"] = self.config.value_field
logger.debug("Set to_field_name to: %s", self.config.value_field)
# Use EmptyModel queryset initially - the actual queryset will be resolved lazily
# when needed (during clean/validation). This avoids circular imports that occur
# when URL resolution is triggered during form class definition.
if queryset is None:
queryset = EmptyModel.objects.none()
super().__init__(queryset, *args, **kwargs) # type: ignore[call-arg]
except (TypeError, ValueError, AttributeError, ImproperlyConfigured) as e:
logger.error("Error initializing %s: %s", self.__class__.__name__, e, exc_info=True)
raise
[docs]
def clean(self, value: Any) -> Any:
"""Validate the selected value(s) against the queryset.
Updates the field's queryset from the widget before performing validation
to ensure that validation is performed against the most current data.
Args:
value: The value to validate
Returns:
The validated value
Raises:
ValidationError: If the value cannot be validated
"""
try:
# Update queryset from widget before cleaning
widget_queryset = getattr(self.widget, "get_queryset", lambda: None)()
if widget_queryset is not None and hasattr(widget_queryset, "model"):
# Only update if it's not EmptyModel
if widget_queryset.model != EmptyModel:
self.queryset = widget_queryset
logger.debug("Updated queryset in clean to: %s", widget_queryset.model)
else:
logger.debug(
"Widget queryset is EmptyModel, keeping original queryset. "
"This may indicate a URL configuration issue or unresolved lazy view."
)
# Make sure to_field_name is set correctly based on value_field
if hasattr(self.config, "value_field") and self.config.value_field:
if self.config.value_field != "pk":
self.to_field_name = self.config.value_field
logger.debug("Set/Updated to_field_name in clean to: %s", self.config.value_field)
else:
# Explicitly set to None if value_field is 'pk'
self.to_field_name = None
logger.debug("Reset to_field_name to None for pk-based lookup")
# Clean the value before passing to validation
cleaned_value = self._clean_value(value)
return super().clean(cleaned_value) # type: ignore[misc]
except ValidationError:
raise
except (AttributeError, TypeError, FieldError) as e:
logger.error("Error in clean method of %s: %s", self.__class__.__name__, e, exc_info=True)
raise ValidationError(f"An unexpected error occurred: {str(e)}") from e
def _clean_value(self, value: Any) -> Any:
"""Clean the input value by removing surrounding quotes if needed."""
if value is None:
return value
# Convert to string for processing
if not isinstance(value, str):
return value
# Remove surrounding quotes if present (e.g.: "'uuid-string'" >> "uuid-string")
cleaned_value = value.strip()
# Remove outer quotes if they exist
if len(cleaned_value) >= 2:
if (cleaned_value.startswith("'") and cleaned_value.endswith("'")) or (
cleaned_value.startswith('"') and cleaned_value.endswith('"')
):
cleaned_value = cleaned_value[1:-1]
return cleaned_value
[docs]
class TomSelectChoiceField(BaseTomSelectMixin, forms.ChoiceField):
"""Single-select field for Tom Select.
Provides a form field for selecting a single value from options provided
by a TomSelect autocomplete source. Validates that the selected value
is among the allowed choices.
"""
field_base_class = forms.ChoiceField # type: ignore[assignment]
widget_class = TomSelectIterablesWidget # type: ignore[assignment]
[docs]
def clean(self, value: Any) -> Any:
"""Validate that the selected value is among the allowed choices.
Retrieves the autocomplete view and checks that the submitted value
is in the set of allowed values.
Args:
value: The value to validate
Returns:
The validated value
Raises:
ValidationError: If the value is not among the allowed choices
"""
if not self.required and not value:
return None
try:
str_value = str(value)
autocomplete_view = getattr(self.widget, "get_autocomplete_view", lambda: None)()
if not autocomplete_view:
logger.error("%s: Could not determine autocomplete view", self.__class__.__name__)
raise ValidationError("Could not determine allowed choices")
try:
all_items = autocomplete_view.get_iterable()
allowed_values = {str(item["value"]) for item in all_items}
except (AttributeError, TypeError, KeyError) as e:
logger.error("Error getting choices from autocomplete view: %s", e, exc_info=True)
raise ValidationError(f"Error determining allowed choices: {str(e)}") from e
if str_value not in allowed_values:
logger.debug("Invalid choice in %s: %s", self.__class__.__name__, value)
raise ValidationError(
self.error_messages["invalid_choice"],
code="invalid_choice",
params={"value": value},
)
return value
except ValidationError:
raise
except (AttributeError, TypeError) as e:
logger.error("Error in clean method of %s: %s", self.__class__.__name__, e, exc_info=True)
raise ValidationError(f"An unexpected error occurred: {str(e)}") from e
[docs]
class TomSelectMultipleChoiceField(BaseTomSelectMixin, forms.MultipleChoiceField):
"""Multi-select field for Tom Select.
Provides a form field for selecting multiple values from options provided
by a TomSelect autocomplete source. Validates that all selected values
are among the allowed choices.
"""
field_base_class = forms.MultipleChoiceField # type: ignore[assignment]
widget_class = TomSelectIterablesMultipleWidget # type: ignore[assignment]
[docs]
def clean(self, value: Any) -> list[Any]: # noqa: C901
"""Validate that all selected values are allowed.
Retrieves the autocomplete view and checks that all submitted values
are in the set of allowed values.
Args:
value: The value or values to validate
Returns:
The validated value list
Raises:
ValidationError: If any of the values are not among the allowed choices
"""
if not value:
if self.required:
raise ValidationError(self.error_messages["required"], code="required")
return []
try:
# Ensure value is iterable
if not hasattr(value, "__iter__") or isinstance(value, str):
value = [value]
str_values = [str(v) for v in value]
autocomplete_view = getattr(self.widget, "get_autocomplete_view", lambda: None)()
if not autocomplete_view:
logger.error("%s: Could not determine autocomplete view", self.__class__.__name__)
raise ValidationError("Could not determine allowed choices")
try:
all_items = autocomplete_view.get_iterable()
allowed_values = {str(item["value"]) for item in all_items}
except (AttributeError, TypeError, KeyError) as e:
logger.error("Error getting choices from autocomplete view: %s", e, exc_info=True)
raise ValidationError(f"Error determining allowed choices: {str(e)}") from e
invalid_values = [val for val in str_values if val not in allowed_values]
if invalid_values:
logger.debug("Invalid choice(s) in %s: %s", self.__class__.__name__, invalid_values)
raise ValidationError(
self.error_messages["invalid_choice"],
code="invalid_choice",
params={"value": invalid_values[0]},
)
return value
except ValidationError:
raise
except (AttributeError, TypeError) as e:
logger.error("Error in clean method of %s: %s", self.__class__.__name__, e, exc_info=True)
raise ValidationError(f"An unexpected error occurred: {str(e)}") from e
[docs]
class TomSelectModelChoiceField(BaseTomSelectModelMixin, forms.ModelChoiceField):
"""Wraps the TomSelectModelWidget as a form field.
Provides a form field for selecting a single model instance from options
provided by a TomSelect autocomplete source. Leverages Django's built-in
ModelChoiceField validation with TomSelect UI enhancements.
"""
field_base_class = forms.ModelChoiceField # type: ignore[assignment]
widget_class = TomSelectModelWidget # type: ignore[assignment]
[docs]
class TomSelectModelMultipleChoiceField(BaseTomSelectModelMixin, forms.ModelMultipleChoiceField):
"""Wraps the TomSelectModelMultipleWidget as a form field.
Provides a form field for selecting multiple model instances from options
provided by a TomSelect autocomplete source. Leverages Django's built-in
ModelMultipleChoiceField validation with TomSelect UI enhancements.
"""
field_base_class = forms.ModelMultipleChoiceField # type: ignore[assignment]
widget_class = TomSelectModelMultipleWidget # type: ignore[assignment]
class TomSelectTokenField(forms.CharField):
"""A CharField that parses and validates a token-style query string.
The form value is a single canonical token string like
``author:42 category:5 some free text``. ``clean()`` parses the value
against the bound :class:`~django_tomselect.autocompletes.CompositeAutocompleteView`
and raises :class:`ValidationError` for unknown operators, unterminated
quotes, cap overflows, empty operator values, ``allow_free_text=False``
violations, or ``Operator.max_count`` / ``min_count`` failures.
NOTE: there is intentionally no class-level ``widget = TomSelectTokenWidget``
because Django would instantiate that class with no args during field setup,
but the widget requires ``composite_view``. The widget is constructed in
``__init__`` and passed via the ``widget`` kwarg.
"""
def __init__(
self,
composite_view: str,
*,
allow_free_text: bool = True,
max_query_length: int = 4096,
max_tokens: int = 32,
max_values_per_operator: int = 16,
widget_kwargs: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the field, auto-constructing a token widget for URL-name composites."""
self.composite_view = composite_view
self.allow_free_text = allow_free_text
self.max_query_length = max_query_length
self.max_tokens = max_tokens
self.max_values_per_operator = max_values_per_operator
# Construct the widget here so composite_view (required) and the field's
# caps + allow_free_text propagate to the JS plugin. Caller can pass
# extra widget-only kwargs (placeholder, css_framework, attrs) via
# widget_kwargs.
#
# The browser-side widget needs a URL name (the JS plugin makes XHRs).
# When the field is constructed with a class reference (typically in
# server-side / unit-test contexts where a URL is not registered), we
# fall back to a plain TextInput. The field's clean() and parse() still
# work - only the rich UI is unavailable.
if "widget" not in kwargs:
if isinstance(composite_view, str):
kwargs["widget"] = TomSelectTokenWidget(
composite_view=composite_view,
allow_free_text=allow_free_text,
max_query_length=max_query_length,
max_tokens=max_tokens,
**(widget_kwargs or {}),
)
else:
# forms.TextInput only accepts ``attrs``. Translate token-only
# kwargs (``placeholder``, ``css_framework``) into something
# TextInput can use, and ignore the ones it can't.
wk = dict(widget_kwargs or {})
attrs = dict(wk.pop("attrs", None) or {})
placeholder = wk.pop("placeholder", None)
if placeholder and "placeholder" not in attrs:
attrs["placeholder"] = placeholder
# css_framework is rich-widget-only; drop silently.
wk.pop("css_framework", None)
# Any remaining keys are unrecognized for TextInput; drop them
# rather than crashing - the rich UI isn't being rendered anyway.
kwargs["widget"] = forms.TextInput(attrs=attrs)
super().__init__(**kwargs)
def clean(self, value: Any) -> str:
"""Validate parser-level concerns and operator count rules.
ORM-coercion validation (e.g. typed-but-not-selected values for id-based
operators) happens at apply-time in :meth:`ParsedQuery.apply` - the field
does not have access to the parent queryset. Calling code should catch
``ValidationError`` from ``apply()`` and route it via
``form.add_error("q", e)``; see the docs for the canonical pattern.
"""
cleaned: str = super().clean(value)
if not cleaned:
return cleaned
parsed = self.parse(cleaned)
if parsed.errors:
raise ValidationError(parsed.format_errors())
for token in parsed.tokens:
if not any(v.strip() for v in token.values):
raise ValidationError(f"Operator {token.key!r} requires a value.")
if not self.allow_free_text and parsed.free_text:
raise ValidationError("Free-text input is not allowed in this filter.")
self._enforce_count_constraints(parsed)
return cleaned
def _enforce_count_constraints(self, parsed: Any) -> None:
"""Per-operator max_count / min_count enforcement."""
composite_cls = self._resolve_composite_class()
if composite_cls is None:
return
counts: dict[str, int] = {}
for t in parsed.tokens:
counts[t.key] = counts.get(t.key, 0) + 1
for op in composite_cls.operators:
count = counts.get(op.key, 0)
if op.max_count is not None and count > op.max_count:
raise ValidationError(f"Operator {op.key!r} may appear at most {op.max_count} time(s); got {count}.")
if op.min_count and count < op.min_count:
raise ValidationError(f"Operator {op.key!r} is required (at least {op.min_count} occurrence(s)).")
def parse(self, value: str):
"""Parse a value against the bound composite view.
Returns a :class:`ParsedQuery`. Form ``clean()`` methods can use this to
write cross-operator validation rules:
.. code-block:: python
def clean(self):
cleaned = super().clean()
parsed = self.fields["q"].parse(cleaned.get("q", ""))
if not parsed.has("asset") and not parsed.has("account"):
raise forms.ValidationError("Provide asset or account.")
return cleaned
"""
from django_tomselect.query import parse_query
return parse_query(
value,
self.composite_view,
max_raw_length=self.max_query_length,
max_tokens=self.max_tokens,
max_values_per_operator=self.max_values_per_operator,
)
def _resolve_composite_class(self):
"""Resolve composite_view (URL name or class) to a class for clean().
When resolved from a URL name, baked-in ``as_view(operators=[...])``
kwargs are promoted to class attributes via a lightweight subclass so
``clean()`` sees the same operator registry the view actually serves.
"""
if isinstance(self.composite_view, str):
try:
from django_tomselect.lazy_utils import resolve_view_class
view_class, view_initkwargs = resolve_view_class(self.composite_view)
if view_initkwargs:
view_class = type(
f"_ResolvedComposite_{view_class.__name__}",
(view_class,),
dict(view_initkwargs),
)
return view_class
except Exception:
return None
return self.composite_view