Source code for django_tomselect.forms

"""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