Source code for django_tomselect.widgets

"""Form widgets for the django-tomselect package."""

__all__ = [
    "TomSelectWidgetMixin",
    "TomSelectModelWidget",
    "TomSelectModelMultipleWidget",
    "TomSelectIterablesWidget",
    "TomSelectIterablesMultipleWidget",
    "TomSelectTokenWidget",
]

import html
import json
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Literal, cast

from django import forms
from django.core.exceptions import FieldDoesNotExist
from django.db.models import IntegerField, Model, Q, QuerySet, UUIDField
from django.forms.renderers import BaseRenderer
from django.http import HttpRequest
from django.urls import NoReverseMatch
from django.utils.html import escape

from django_tomselect.app_settings import (
    GLOBAL_DEFAULT_CONFIG,
    AllowedCSSFrameworks,
    FilterSpec,
    StrOrPromise,
    TomSelectConfig,
    merge_configs,
)
from django_tomselect.autocompletes import (
    AutocompleteIterablesView,
    AutocompleteModelView,
)
from django_tomselect.constants import EXCLUDEBY_VAR, FILTERBY_VAR, PAGE_VAR, SEARCH_VAR
from django_tomselect.lazy_utils import LazyView
from django_tomselect.logging import get_logger
from django_tomselect.middleware import get_current_request
from django_tomselect.models import EmptyModel
from django_tomselect.utils import safe_reverse, safe_reverse_lazy

logger = get_logger(__name__)

if TYPE_CHECKING:
    _MixinBase = forms.Select
else:
    _MixinBase = object


[docs] class TomSelectWidgetMixin(_MixinBase): """Mixin to provide methods and properties for all TomSelect widgets.""" template_name: str = "django_tomselect/tomselect.html" @staticmethod def _resolve_widget_config(config: TomSelectConfig | dict[str, Any] | None) -> TomSelectConfig: """Resolve config input to a merged TomSelectConfig for the widget. Args: config: The config input - None, TomSelectConfig, or dict. Returns: A fully merged TomSelectConfig. Raises: TypeError: If config is not a recognized type. """ base_config: TomSelectConfig = GLOBAL_DEFAULT_CONFIG if config is None: return base_config if isinstance(config, TomSelectConfig): return merge_configs(base_config, config) if isinstance(config, dict): return merge_configs(base_config, TomSelectConfig(**config)) raise TypeError(f"config must be a TomSelectConfig or a dictionary, not {type(config)}") @staticmethod def _process_render_attrs(attrs: dict[str, Any]) -> None: """Extract render option/item templates from an attrs dict, in place. Pops the 'render' key if present and converts to data_template_option and data_template_item keys. """ if "render" in attrs: render_data = attrs.pop("render") if isinstance(render_data, dict): if "option" in render_data: attrs["data_template_option"] = render_data["option"] if "item" in render_data: attrs["data_template_item"] = render_data["item"]
[docs] def __init__(self, config: TomSelectConfig | dict[str, Any] | None = None, **kwargs: Any) -> None: """Initialize shared TomSelect configuration.""" final_config = self._resolve_widget_config(config) # URL and field config self.url: str = final_config.url self.value_field: str = final_config.value_field self.label_field: str = final_config.label_field # Store normalized filter lists self.filters: list[FilterSpec] = final_config.get_normalized_filters() self.excludes: list[FilterSpec] = final_config.get_normalized_excludes() # Keep for backwards compatibility self.filter_by = final_config.filter_by self.exclude_by = final_config.exclude_by self.use_htmx: bool = final_config.use_htmx # Behavior config self.minimum_query_length: int = final_config.minimum_query_length self.preload: Literal["focus"] | bool = final_config.preload self.highlight: bool = final_config.highlight self.hide_selected: bool = final_config.hide_selected self.open_on_focus: bool = final_config.open_on_focus self.placeholder: StrOrPromise | None = final_config.placeholder self.max_items: int | None = final_config.max_items self.max_options: int | None = final_config.max_options self.css_framework: str | AllowedCSSFrameworks = final_config.css_framework self.use_minified: bool = final_config.use_minified self.close_after_select: bool | None = final_config.close_after_select self.hide_placeholder: bool | None = final_config.hide_placeholder self.load_throttle: int = final_config.load_throttle self.loading_class: str = final_config.loading_class self.create: bool = final_config.create # Plugin configurations self.plugin_checkbox_options: Any = final_config.plugin_checkbox_options self.plugin_clear_button: Any = final_config.plugin_clear_button self.plugin_dropdown_header: Any = final_config.plugin_dropdown_header self.plugin_dropdown_footer: Any = final_config.plugin_dropdown_footer self.plugin_dropdown_input: Any = final_config.plugin_dropdown_input self.plugin_remove_button: Any = final_config.plugin_remove_button # Explicitly set self.attrs from config.attrs to ensure attributes are properly passed to the widget if hasattr(final_config, "attrs") and final_config.attrs: self.attrs: dict[str, Any] = final_config.attrs.copy() self._process_render_attrs(self.attrs) # Allow kwargs to override any config values for key, value in kwargs.items(): if hasattr(final_config, key): if isinstance(value, dict) and isinstance(getattr(final_config, key), dict): setattr(self, key, {**getattr(final_config, key), **value}) else: setattr(self, key, value) super().__init__(**kwargs) logger.debug("TomSelectWidgetMixin initialized.")
[docs] def render( self, name: str, value: Any, attrs: dict[str, str] | None = None, renderer: BaseRenderer | None = None, ) -> str: """Render the widget.""" context = self.get_context(name, value, attrs) logger.debug( "Rendering TomSelect widget with context: %s and template: %s using %s", context, self.template_name, renderer, ) return str(self._render(self.template_name, context, renderer))
[docs] def get_plugin_context(self) -> dict[str, Any]: """Get context for plugins.""" plugins: dict[str, Any] = {} # Add plugin contexts only if plugin is enabled if self.plugin_clear_button: plugins["clear_button"] = self.plugin_clear_button.as_dict() if self.plugin_remove_button: plugins["remove_button"] = self.plugin_remove_button.as_dict() if self.plugin_dropdown_header: header = self.plugin_dropdown_header plugins["dropdown_header"] = { "title": str(header.title), "header_class": header.header_class, "title_row_class": header.title_row_class, "label_class": header.label_class, "value_field_label": str(header.value_field_label), "label_field_label": str(header.label_field_label), "label_col_class": header.label_col_class, "show_value_field": header.show_value_field, "extra_headers": list(header.extra_columns.values()), "extra_values": list(header.extra_columns.keys()), } if self.plugin_dropdown_footer: plugins["dropdown_footer"] = self.plugin_dropdown_footer.as_dict() # These plugins don't have additional config plugins["checkbox_options"] = bool(self.plugin_checkbox_options) plugins["dropdown_input"] = bool(self.plugin_dropdown_input) logger.debug("Plugins in use: %s", ", ".join(plugins.keys() if plugins else ["None"])) return plugins
[docs] def get_model(self) -> "type[Model] | None": """Get the model class. Overridden in subclasses.""" return None
[docs] def get_autocomplete_view(self) -> Any: """Get the autocomplete view. Overridden in subclasses.""" return None
[docs] def get_lazy_view(self) -> LazyView | None: """Get lazy-loaded view for the TomSelect widget.""" if not hasattr(self, "_lazy_view") or self._lazy_view is None: # Get current user from request if available request = self.get_current_request() user = getattr(request, "user", None) if request else None # Create LazyView with the URL from config self._lazy_view = LazyView(url_name=self.url, model=self.get_model(), user=user) return self._lazy_view
[docs] def get_autocomplete_url(self) -> str: """Hook to specify the autocomplete URL.""" # Special case for widgets that have a lazy view if hasattr(self, "get_lazy_view") and callable(self.get_lazy_view): lazy_view = self.get_lazy_view() if lazy_view: return lazy_view.get_url() # Standard case for direct URL resolution if not hasattr(self, "_cached_url"): logger.debug("Resolving URL for the first time: %s", self.url) try: self._cached_url = safe_reverse(self.url) logger.debug("URL resolved in TomSelectWidgetMixin: %s", self._cached_url) except NoReverseMatch as e: logger.error("Could not reverse URL in TomSelectWidgetMixin: %s - %s", self.url, e) raise return self._cached_url
[docs] def get_autocomplete_params(self) -> str: """Hook for subclasses to specify additional autocomplete query parameters. Override and return a URL-safe `&`-joined string (no leading `&`). """ return ""
[docs] def build_attrs(self, base_attrs: dict[str, Any], extra_attrs: dict[str, Any] | None = None) -> dict[str, Any]: """Build HTML attributes for the widget.""" logger.debug("Building attrs with base_attrs: %s and extra_attrs: %s", base_attrs, extra_attrs) attrs = super().build_attrs(base_attrs, extra_attrs) logger.debug("attrs after `super` in build_attrs: %s", attrs) # Add required data attributes if self.url: attrs["data-autocomplete-url"] = safe_reverse_lazy(self.url) if self.value_field: attrs["data-value-field"] = self.value_field if self.label_field: attrs["data-label-field"] = self.label_field if self.placeholder is not None: attrs["placeholder"] = self.placeholder # Auto-set aria-describedby for help text association if "aria-describedby" not in attrs: widget_id = attrs.get("id", "") if widget_id: attrs["aria-describedby"] = f"{widget_id}_helptext" # Mark as TomSelect widget for dynamic initialization attrs["data-tomselect"] = "true" # Ensure custom templates are JSON-encoded to prevent script injection if "data-template-option" in attrs: attrs["data-template-option"] = json.dumps(attrs["data-template-option"]) if "data-template-item" in attrs: attrs["data-template-item"] = json.dumps(attrs["data-template-item"]) # Handle 'render' attribute if present in the input attributes self._process_render_attrs(attrs) logger.debug("Returning final attrs: %s and extra_attrs: %s", attrs, extra_attrs) return {**attrs, **(extra_attrs or {})}
[docs] def get_url(self, view_name: str, view_type: str = "", **kwargs: Any) -> str: """Reverse the given view name and return the path.""" if not view_name: logger.warning("No URL provided for %s", view_type) return "" try: return cast(str, safe_reverse_lazy(view_name, **kwargs)) except NoReverseMatch as e: logger.warning( "TomSelectWidget requires a resolvable '%s' attribute. Original error: %s", view_type, e, ) return ""
[docs] def get_current_request(self) -> HttpRequest | None: """Get the current request from thread-local storage.""" return get_current_request()
@property def media(self) -> forms.Media: """Return the media for rendering the widget.""" css_paths = self._get_css_paths() js_path = ( "django_tomselect/js/django-tomselect.min.js" if self.use_minified else "django_tomselect/js/django-tomselect.js" ) media = forms.Media( css={"all": css_paths}, js=[js_path], ) logger.debug("Media loaded for TomSelectWidgetMixin.") return media
[docs] def get_url_param_constants(self) -> dict[str, str]: """Get URL parameter constants for use in templates. Returns a dictionary of URL parameter names that can be used in JavaScript for building autocomplete request URLs. """ return { "search_param": SEARCH_VAR, "filter_param": FILTERBY_VAR, "exclude_param": EXCLUDEBY_VAR, "page_param": PAGE_VAR, }
[docs] def get_csp_nonce(self) -> str | None: """Get CSP nonce from the current request, if available. Supports django-csp (request.csp_nonce) and any middleware that sets request._csp_nonce or request.csp_nonce. """ request = self.get_current_request() if not request: return None # django-csp >= 4.0 uses request.csp_nonce nonce = getattr(request, "csp_nonce", None) if not nonce: # Some setups use _csp_nonce nonce = getattr(request, "_csp_nonce", None) return nonce
def _get_css_paths(self) -> list[str]: """Get CSS paths based on framework.""" # Framework-specific paths framework = ( self.css_framework.value if isinstance(self.css_framework, AllowedCSSFrameworks) else str(self.css_framework) ) if framework.lower() == AllowedCSSFrameworks.BOOTSTRAP4.value: css = ( "django_tomselect/vendor/tom-select/css/tom-select.bootstrap4.min.css" if self.use_minified else "django_tomselect/vendor/tom-select/css/tom-select.bootstrap4.css" ) elif framework.lower() == AllowedCSSFrameworks.BOOTSTRAP5.value: css = ( "django_tomselect/vendor/tom-select/css/tom-select.bootstrap5.min.css" if self.use_minified else "django_tomselect/vendor/tom-select/css/tom-select.bootstrap5.css" ) else: css = ( "django_tomselect/vendor/tom-select/css/tom-select.default.min.css" if self.use_minified else "django_tomselect/vendor/tom-select/css/tom-select.default.css" ) paths = [css, "django_tomselect/css/django-tomselect.css"] # Append token-widget chrome only when this widget is a token widget. # Keeps _get_css_paths generic for the existing widgets. if getattr(self, "_token_widget", False): paths.append("django_tomselect/css/django-tomselect-token.css") return paths
[docs] class TomSelectModelWidget(TomSelectWidgetMixin, forms.Select): """A Tom Select widget with model object choices."""
[docs] def __init__(self, config: TomSelectConfig | dict[str, Any] | None = None, **kwargs: Any) -> None: """Initialize widget with model-specific attributes.""" self.model: type[Model] | None = None # Auth override settings self.allow_anonymous: bool = kwargs.pop("allow_anonymous", False) self.skip_authorization: bool = kwargs.pop("skip_authorization", False) # Initialize URL-related attributes self.show_list: bool = False self.show_detail: bool = False self.show_create: bool = False self.show_update: bool = False self.show_delete: bool = False self.create_field: str = "" self.create_filter: str | Callable | None = None self.create_with_htmx: bool = False super().__init__(config=config, **kwargs) # Update from config if provided if config: config = config if isinstance(config, TomSelectConfig) else TomSelectConfig(**config) self.show_list = config.show_list self.show_detail = config.show_detail self.show_create = config.show_create self.show_update = config.show_update self.show_delete = config.show_delete self.create_field = config.create_field self.create_filter = config.create_filter self.create_with_htmx = config.create_with_htmx
[docs] def get_autocomplete_context(self) -> dict[str, Any]: """Get context for autocomplete functionality.""" model_pk_name = self.model._meta.pk.name if self.model else "" # type: ignore[union-attr] model_label_field = getattr(self.model, "name_field", "name") if self.model else "" autocomplete_context: dict[str, Any] = { "value_field": self.value_field or model_pk_name, "label_field": self.label_field or model_label_field, "is_tabular": bool(self.plugin_dropdown_header), "use_htmx": self.use_htmx, "search_lookups": self.get_search_lookups(), "autocomplete_url": self.get_autocomplete_url(), "autocomplete_params": self.get_autocomplete_params(), } logger.debug("Autocomplete context: %s in widget %s", autocomplete_context, self.__class__.__name__) return autocomplete_context
[docs] def get_permissions_context(self, autocomplete_view: AutocompleteModelView) -> dict[str, Any]: """Get permission-related context for the widget.""" request = self.get_current_request() # Base permissions permissions = { "can_create": autocomplete_view.has_permission(request, "create"), "can_view": autocomplete_view.has_permission(request, "view"), "can_update": autocomplete_view.has_permission(request, "update"), "can_delete": autocomplete_view.has_permission(request, "delete"), } # Derived permissions for UI elements ui_permissions = { "show_create": self.show_create and permissions["can_create"], "show_list": self.show_list and permissions["can_view"], "show_detail": self.show_detail and permissions["can_view"], "show_update": self.show_update and permissions["can_update"], "show_delete": self.show_delete and permissions["can_delete"], } # Combine permissions context = {**permissions, **ui_permissions} logger.debug( "Permissions context: %s for model %s with %s in widget %s", context, self.model.__name__ if self.model else None, autocomplete_view, self.__class__.__name__, ) return context
[docs] def get_model_url_context(self, autocomplete_view: AutocompleteModelView) -> dict[str, Any]: """Get URL-related context for a model object. We retrieve & store list and create URLs, because they are model-specific, not instance-specific. These are used when initializing the widget, not when selecting an option. Instance-specific URLs are stored in the selected_options. """ context: dict[str, Any] = {} # Only try to resolve list_url if show_list is True if self.show_list: context["view_list_url"] = self._get_model_url(autocomplete_view, "list_url", "view") else: context["view_list_url"] = None # Only try to resolve create_url if show_create is True if self.show_create: context["view_create_url"] = self._get_model_url(autocomplete_view, "create_url", "create") else: context["view_create_url"] = None logger.debug("Model URL context: %s", context) return context
def _get_model_url(self, view: AutocompleteModelView, url_attr: str, permission: str) -> str | None: """Get model URL if available and permitted.""" request = self.get_current_request() if not hasattr(view, url_attr) or getattr(view, url_attr) in ("", None): logger.warning("No valid %s URL available for model %s", url_attr, self.model) return None if not view.has_permission(request, permission): return None try: return safe_reverse(getattr(view, url_attr)) except NoReverseMatch: logger.warning("Unable to reverse %s for model %s", url_attr, self.model) return None
[docs] def get_instance_url_context( self, obj: Model | dict[str, Any], autocomplete_view: AutocompleteModelView ) -> dict[str, Any]: """Get URL-related context for a selected object. Public subclass-override hook. Returns a dict mapping `detail_url` / `update_url` / `delete_url` to fully built URL strings, omitting keys that are not configured or not permitted. Dicts and instances with no pk return an empty mapping. """ return self._compute_instance_url_context(obj, autocomplete_view, cached_permissions=None)
def _compute_instance_url_context( self, obj: Model | dict[str, Any], autocomplete_view: AutocompleteModelView, cached_permissions: dict[str, bool] | None, ) -> dict[str, Any]: """Shared implementation behind `get_instance_url_context`. When `cached_permissions` is `None`, permissions are looked up once via `get_current_request()`, preserving the original behavior of only calling `has_permission` when both the corresponding `show_*` flag and `*_url` attribute are truthy. When pre-computed permissions are passed in (loop-rendering hot path), the lookup is skipped entirely. """ urls: dict[str, str] = {} if isinstance(obj, dict) or not hasattr(obj, "pk") or obj.pk is None: return urls if cached_permissions is None: # Match the original gating: only call has_permission when BOTH # show_X and the *_url attr are truthy. has_permission has potential # side effects (logging, cache fills), so we preserve when calls happen. request = self.get_current_request() cached_permissions = { "view": ( autocomplete_view.has_permission(request, "view") if self.show_detail and autocomplete_view.detail_url else False ), "update": ( autocomplete_view.has_permission(request, "update") if self.show_update and autocomplete_view.update_url else False ), "delete": ( autocomplete_view.has_permission(request, "delete") if self.show_delete and autocomplete_view.delete_url else False ), } if self.show_detail and autocomplete_view.detail_url and cached_permissions.get("view"): self._add_url_to_context(urls, "detail_url", autocomplete_view.detail_url, obj.pk) if self.show_update and autocomplete_view.update_url and cached_permissions.get("update"): self._add_url_to_context(urls, "update_url", autocomplete_view.update_url, obj.pk) if self.show_delete and autocomplete_view.delete_url and cached_permissions.get("delete"): self._add_url_to_context(urls, "delete_url", autocomplete_view.delete_url, obj.pk) logger.debug("Instance URL context: %s", urls) return urls def _add_url_to_context(self, context: dict[str, str], key: str, url_pattern: str, pk: Any) -> None: """Add URL to context dict if reversible.""" try: context[key] = escape(safe_reverse(url_pattern, args=[pk])) except NoReverseMatch: logger.warning( "Unable to reverse %s %s with pk %s", key, url_pattern, pk, )
[docs] def get_context(self, name: str, value: Any, attrs: dict[str, str] | None = None) -> dict[str, Any]: """Get context for rendering the widget.""" self.get_queryset() # Ensure we have model info # Extract configuration value_field = self.value_field or "id" label_field = self.label_field or "name" # Handle possible string representations of model instances value = self._process_string_value(value, value_field, label_field) # Setup global TomSelect if not already done request = get_current_request() if request and not getattr(request, "_tomselect_global_rendered", False): logger.debug("Rendering global TomSelect setup.") self.template_name = "django_tomselect/tomselect_setup.html" request._tomselect_global_rendered = True # Create base context without autocomplete view base_context = self._create_base_context(name, value, attrs, value_field) # Handle selected options without autocomplete view if isinstance(value, dict) and (value.get(value_field) or value.get("id") or value.get("pk")): return self._add_extracted_selected_option(base_context, value, value_field, label_field) # Get autocomplete view and request autocomplete_view = self.get_autocomplete_view() # Return base context if we can't get more info if not autocomplete_view or not request or not self.validate_request(request): logger.warning("Autocomplete view or request not available, returning base context") return base_context # Build full context with autocomplete view context = self._build_full_context(base_context, attrs, autocomplete_view) # Add selected options if value is provided if value and value != "": context["widget"]["selected_options"] = self._get_selected_options(value, autocomplete_view) return context
@staticmethod def _safely_decode_html(value: str) -> str: """Iteratively unescape HTML entities with a ReDoS-safe iteration limit. Args: value: The HTML-encoded string. Returns: The fully decoded string. """ decoded_value = value max_decode_iterations = 10 prev_value = "" iteration_count = 0 while prev_value != decoded_value and "&" in decoded_value and iteration_count < max_decode_iterations: prev_value = decoded_value decoded_value = html.unescape(decoded_value) iteration_count += 1 if iteration_count >= max_decode_iterations: logger.warning( "HTML decoding reached maximum iterations (%d), possible malicious input", max_decode_iterations ) if decoded_value != value: logger.debug("Decoded value after %d iterations: %s", iteration_count, decoded_value) return decoded_value @staticmethod def _extract_field_values(decoded_value: str, value_field: str, label_field: str) -> dict[str, str]: """Extract field values from a decoded dict-like string using regex patterns. Args: decoded_value: The decoded string to extract values from. value_field: The configured value field name. label_field: The configured label field name. Returns: Dictionary mapping field names to extracted string values. """ extracted_values: dict[str, str] = {} for field_name in [value_field, "id", "pk", "pkid", "name", label_field]: if field_name in extracted_values: continue patterns = [ rf"['\"]({field_name})['\"]\s*:\s*['\"]([^'\"]+)['\"]", rf"['\"]({field_name})['\"]\s*:\s*(\d+)", rf"['\"]({field_name})['\"]\s*:\s*UUID\(['\"]([^'\"]+)['\"]", ] for pattern in patterns: match = re.search(pattern, decoded_value) if match: matched_value = match.group(2).strip() logger.debug("Extracted %s: %s", field_name, matched_value) extracted_values[field_name] = matched_value break return extracted_values def _resolve_model_instance(self, extracted_values: dict[str, str], value_field: str) -> Any: """Look up a model instance from extracted field values, with fallback to the dict. Args: extracted_values: Dictionary of extracted field values. value_field: The configured value field name. Returns: A model instance if found, the extracted_values dict as fallback, or None. """ lookup_field = value_field lookup_value = extracted_values.get(value_field) if not lookup_value: for field in ["id", "pk", "pkid"]: if field in extracted_values: lookup_field = field lookup_value = extracted_values[field] break if not lookup_value: return extracted_values qs = self.get_queryset() if qs is None: return extracted_values try: instance = qs.filter(**{lookup_field: lookup_value}).first() return instance if instance else extracted_values except Exception as e: logger.debug("Error looking up with extracted values: %s", e) return extracted_values def _process_string_value(self, value: Any, value_field: str, label_field: str) -> Any: """Process string value that may represent a model instance. This method attempts to get the model instance or its attributes from a string representation, so we do not end up with weird strings in the widget's selected options. """ if not isinstance(value, str) or ("{" not in value and "&" not in value): return value try: decoded_value = self._safely_decode_html(value) extracted_values = self._extract_field_values(decoded_value, value_field, label_field) has_identifier = any(extracted_values.get(k) for k in [value_field, "id", "pk", "pkid"]) if has_identifier: return self._resolve_model_instance(extracted_values, value_field) except Exception as e: logger.error("Error parsing string representation: %s", e) return value def _create_base_context( self, name: str, value: Any, attrs: dict[str, Any] | None, value_field: str ) -> dict[str, Any]: """Create base context without autocomplete view.""" base_context: dict[str, Any] = { "widget": { "attrs": attrs or {}, "close_after_select": self.close_after_select, "create": self.create, "create_field": self.create_field, "create_with_htmx": self.create_with_htmx, "hide_placeholder": self.hide_placeholder, "hide_selected": self.hide_selected, "highlight": self.highlight, "is_hidden": self.is_hidden, "is_multiple": False, "load_throttle": self.load_throttle, "loading_class": self.loading_class, "max_items": self.max_items, "max_options": self.max_options, "minimum_query_length": self.minimum_query_length, "name": name, "aria_label": (attrs or {}).get("aria-label", name.replace("_", " ").replace("-", " ").title()), "open_on_focus": self.open_on_focus, "placeholder": self.placeholder, "plugins": self.get_plugin_context(), "preload": self.preload, "required": self.is_required, "selected_options": [], "template_name": self.template_name, "value": value, **self.get_autocomplete_context(), **self.get_url_param_constants(), }, "csp_nonce": self.get_csp_nonce(), } # Add filter/exclude configuration - pass normalized lists if self.filters: # Convert FilterSpec objects to dicts for template use base_context["widget"]["filters"] = [ { "source": f.source, "lookup": f.lookup, "source_type": f.source_type, "levels_up": f.levels_up, } for f in self.filters ] # Keep for backwards compatibility with custom templates # Uses first field-type filter for legacy dependent_field field_filters = [f for f in self.filters if f.source_type == "field"] if field_filters: base_context["widget"].update( { "dependent_field": field_filters[0].source, "dependent_field_lookup": field_filters[0].lookup, } ) if self.excludes: # Convert FilterSpec objects to dicts for template use base_context["widget"]["excludes"] = [ { "source": e.source, "lookup": e.lookup, "source_type": e.source_type, "levels_up": e.levels_up, } for e in self.excludes ] # Keep for backwards compatibility with custom templates # Uses first field-type exclude for legacy exclude_field field_excludes = [e for e in self.excludes if e.source_type == "field"] if field_excludes: base_context["widget"].update( { "exclude_field": field_excludes[0].source, "exclude_field_lookup": field_excludes[0].lookup, } ) # Handle model instances directly, if they are provided if value and hasattr(value, "_meta") and hasattr(value, "pk") and value.pk is not None: base_context["widget"]["selected_options"] = [self._get_option_from_instance(value, value_field)] return base_context def _get_option_from_instance(self, instance: Model, value_field: str) -> dict[str, str]: """Get option dict from model instance.""" label_field = self.label_field or "name" # Extract fields based on configuration val = getattr(instance, value_field, instance.pk) label = getattr(instance, label_field, getattr(instance, "name", str(instance))) opt = { "value": str(val), "label": escape(str(label)), } # Add URLs if autocomplete_view is available autocomplete_view = self.get_autocomplete_view() request = self.get_current_request() if autocomplete_view and request and self.validate_request(request): urls = self.get_instance_url_context(instance, autocomplete_view) for url_type, url in urls.items(): if url: opt[url_type] = escape(url) return opt def _add_extracted_selected_option( self, context: dict[str, Any], value: dict[str, Any], value_field: str, label_field: str ) -> dict[str, Any]: """Add selected option from extracted values.""" val = value.get(value_field) or value.get("id") or value.get("pk") or value.get("pkid") label = value.get(label_field) or value.get("name") or str(val) opt = { "value": str(val), "label": escape(str(label)), } context["widget"]["selected_options"] = [opt] return context def _build_full_context( self, base_context: dict[str, Any], attrs: dict[str, Any] | None, autocomplete_view: AutocompleteModelView ) -> dict[str, Any]: """Build full context with autocomplete view.""" attrs = self.build_attrs(self.attrs, attrs) context: dict[str, Any] = { "widget": { **base_context["widget"], "attrs": attrs, **self.get_model_url_context(autocomplete_view), }, "csp_nonce": base_context.get("csp_nonce", ""), } # Add permissions context context["widget"].update(self.get_permissions_context(autocomplete_view)) return context @staticmethod def _value_field_needs_pk_fallback(model: type[Model] | None, value_field: str, selected_values: list[Any]) -> bool: """Return True when value_field is a UUIDField but the incoming value(s) are integer pks. A model whose real primary key is a separate integer column can expose an opaque UUID through ``value_field`` (for example ``value_field="id"`` on a model whose primary key is an integer ``pkid``). A bound ``ModelForm`` renders such a ForeignKey's initial as the related object's integer primary key (``model_to_dict`` reduces it, and ``prepare_value`` only honors ``to_field_name`` for model instances), which a UUID ``value_field`` cannot match. In that case the rows are resolved by primary key instead, while the emitted option value stays the UUID. The guard is intentionally narrow and only fires when all three conditions hold: - every incoming value is an integer pk (a plain ``int`` or its digit-string form, the shape a re-rendered bound form pulls out of the data dict); booleans are excluded since ``bool`` subclasses ``int``, - ``value_field`` resolves to a ``UUIDField`` (so the integer cannot be a direct ``value_field`` match and must be a primary key), and - the model's real primary key is a single integer column, so ``Q(pk__in=...)`` is a valid lookup (this also excludes composite primary keys, where ``pk__in`` expects tuples). Because of the ``UUIDField`` requirement, a legitimately integer-typed ``value_field`` is never rerouted, and a canonical UUID string always contains hyphens (so it never satisfies the digit-string check), so accepting an integer pk only ever rescues a lookup that would otherwise raise against the UUID column. """ if model is None: return False candidate_values = [candidate for candidate in selected_values if candidate is not None] if not candidate_values: return False if not all(TomSelectModelWidget._looks_like_integer_pk(candidate) for candidate in candidate_values): return False try: field = model._meta.get_field(value_field) except (FieldDoesNotExist, AttributeError): return False if not isinstance(field, UUIDField): return False # Only reroute to the primary key when it is a single integer column. A UUID, string, or # composite primary key cannot accept ``Q(pk__in=[<int>])``; rerouting those would either # match nothing or raise, so leave them on the normal value_field path. return isinstance(model._meta.pk, IntegerField) @staticmethod def _looks_like_integer_pk(candidate: Any) -> bool: """Return True when candidate is an integer primary key as an int or a digit-string. ``bool`` is a subclass of ``int`` and is never a primary key, so it is excluded. """ if isinstance(candidate, bool): return False if isinstance(candidate, int): return True return isinstance(candidate, str) and candidate.isdigit() def _get_selected_options(self, value: Any, autocomplete_view: AutocompleteModelView) -> list[dict[str, Any]]: """Get selected options from value.""" selected: list[dict[str, Any]] = [] value_field = self.value_field or "id" # Value is an ID or list of IDs queryset = self.get_queryset() if queryset is None: return [] selected_values = [value] if not isinstance(value, (list, tuple)) else value needs_pk_fallback = value_field != "pk" and self._value_field_needs_pk_fallback( queryset.model, value_field, selected_values ) if value_field == "pk" or needs_pk_fallback: if needs_pk_fallback: # A UUID value_field was handed the integer pk (a bound ModelForm ForeignKey # initial). Resolve by primary key; the option value emitted in the loop below is # still getattr(obj, value_field) = the UUID, so the integer pk is never exposed in # the rendered widget. logger.debug( "value_field %r is a UUIDField but received integer pk(s) %r; resolving by " "primary key and emitting the UUID as the option value.", value_field, selected_values, ) final_filter = Q(pk__in=selected_values) # type: ignore[misc] else: # Filter on the value_field final_filter = Q(**{f"{value_field}__in": selected_values}) # type: ignore[misc] selected_objects = queryset.filter(final_filter) # Detect if label_field is a relation and add select_related to prevent N+1 queries label_field = self.label_field or "name" try: field_obj = selected_objects.model._meta.get_field(label_field) # type: ignore[union-attr] if field_obj.is_relation: selected_objects = selected_objects.select_related(label_field) except (FieldDoesNotExist, AttributeError): pass # Pre-compute permissions once before the loop to avoid N+1 queries # Permissions are model-level, not object-level, so they're the same for all objects request = self.get_current_request() cached_permissions = { "view": autocomplete_view.has_permission(request, "view") if self.show_detail else False, "update": autocomplete_view.has_permission(request, "update") if self.show_update else False, "delete": autocomplete_view.has_permission(request, "delete") if self.show_delete else False, } for obj in selected_objects: # Handle the case where obj is a dictionary (e.g., cleaned_data) if isinstance(obj, dict): val = obj.get(value_field) or obj.get("pk", "") opt: dict[str, str] = { "value": str(val), "label": self.get_label_for_object(obj, autocomplete_view), } else: val = getattr(obj, value_field, obj.pk) opt = { "value": str(val), "label": self.get_label_for_object(obj, autocomplete_view), } # Safely add URLs with proper escaping, using pre-computed permissions. # Bypasses get_instance_url_context() to avoid breaking subclass overrides # that use the documented 2-arg signature; the override path is preserved # for single-instance rendering at _get_option_from_instance. urls = self._compute_instance_url_context(obj, autocomplete_view, cached_permissions) for url_type, url in urls.items(): if url: opt[url_type] = escape(url) selected.append(opt) return selected
[docs] def get_label_for_object(self, obj: Model | dict[str, Any], autocomplete_view: AutocompleteModelView) -> str: """Get the label for an object using the configured label field.""" label_field = self.label_field or "name" try: # Handle dictionary case if isinstance(obj, dict) and label_field in obj: return escape(str(obj[label_field])) # Handle model instance - get the field value directly if hasattr(obj, label_field): label_value = getattr(obj, label_field) if label_value is not None: return escape(str(label_value)) # Check for prepare method on autocomplete view prepare_method = getattr(autocomplete_view, f"prepare_{label_field}", None) if prepare_method and callable(prepare_method): label_value = prepare_method(obj) if label_value is not None: return escape(str(label_value)) except Exception as e: logger.error("Error getting label for object: %s", e) # Fallback to string representation return escape(str(obj))
[docs] def get_model(self) -> type[Model] | None: """Get model from field's choices or queryset.""" if self.model: logger.debug("Model already set in %s: %s", self.__class__.__name__, self.model) return self.model model = None # Try to get model from choices queryset choices = self.choices if hasattr(choices, "queryset") and hasattr(getattr(choices, "queryset", None), "model"): model = choices.queryset.model # Try to get model directly from choices elif hasattr(choices, "model"): model = choices.model # If choices is a list, we can't determine model elif isinstance(choices, list) and choices: model = None logger.debug( "Model retrieved in %s: %s", self.__class__.__name__, model.__name__ if model else "None", ) return model
[docs] def validate_request(self, request: Any) -> bool: """Validate that a request object is valid for permission checking.""" if not request: logger.warning("Request object is missing.") return False # Check if request has required attributes and methods required_attributes = ["user", "method", "GET"] if not all(hasattr(request, attr) for attr in required_attributes): logger.warning("Request object is missing required attributes or methods.") return False # Verify user attribute has required auth methods if not hasattr(request, "user") or not hasattr(request.user, "is_authenticated"): logger.warning("Request object is missing user or is_authenticated method.") return False # Verify request methods are callable if not callable(getattr(request, "get_full_path", None)): logger.warning("Request object is missing get_full_path method.") return False logger.debug("Request object is valid.") return True
[docs] def get_autocomplete_view(self) -> AutocompleteModelView | None: """Get instance of autocomplete view for accessing queryset and search_lookups.""" lazy_view = self.get_lazy_view() if not lazy_view: return None view = lazy_view.get_view() self.model = lazy_view.get_model() logger.debug("Lazy view model: %s", self.model) if not self.model: logger.warning("Model is not a valid Django model.") return None if not isinstance(view, AutocompleteModelView): logger.warning("View is not an instance of AutocompleteModelView.") return None # Add label_field to value_fields if needed self._ensure_label_field_in_view(view) return view
def _ensure_label_field_in_view(self, view: AutocompleteModelView) -> None: """Ensure the label field is exposed by the view's serialized values.""" if not self.label_field or self.label_field in view.value_fields: return logger.warning( "Label field '%s' is not in the autocomplete view's value_fields. This may result in 'undefined' labels.", self.label_field, ) # Rebind rather than append: never mutate the shared class-level list. view.value_fields = [*view.value_fields, self.label_field] # Check if it's a model field if self.model is None: return try: model_fields = [f.name for f in self.model._meta.fields] # type: ignore[union-attr] # A real ORM relation lookup separates field names with "__" in the # middle (e.g. "field__name"). A Python dunder such as "__str__" has # leading/trailing "__" and is NOT a queryable relation, so strip those # before testing, otherwise the dunder is wrongly kept in value_fields # and .values("__str__") raises a FieldError. is_related_field = "__" in self.label_field.strip("_") # If it's not a real field or relation, add to virtual_fields so it is # excluded from the .values() query. The view is responsible for # supplying the value in prepare_results()/hook_prepare_results(). if not (self.label_field in model_fields or is_related_field): current_virtual = list(getattr(view, "virtual_fields", None) or []) if self.label_field not in current_virtual: current_virtual.append(self.label_field) # Rebind rather than append: never mutate the shared class list. view.virtual_fields = current_virtual logger.info( "Label field '%s' added to virtual_fields: %s", self.label_field, view.virtual_fields, ) except (AttributeError, TypeError): # Cases where model is None or doesn't have _meta pass
[docs] def get_queryset(self) -> QuerySet | None: """Get queryset from autocomplete view.""" try: lazy_view = self.get_lazy_view() if lazy_view: queryset = lazy_view.get_queryset() if queryset is not None: return queryset # If we reach here, we need a fallback queryset model = self.get_model() # Explicitly check if model is a valid Django model with objects manager if model and hasattr(model, "_meta") and hasattr(model, "objects"): logger.warning("Using fallback empty queryset for model %s", model) return model.objects.none() # type: ignore[union-attr] logger.warning("Using EmptyModel.objects.none() as last resort") return EmptyModel.objects.none() except Exception as e: # If anything fails, return an empty EmptyModel queryset logger.warning("Error in get_queryset: %s, falling back to EmptyModel.objects.none()", e) return EmptyModel.objects.none()
[docs] def get_search_lookups(self) -> list[str]: """Get search lookups from autocomplete view.""" autocomplete_view = self.get_autocomplete_view() if autocomplete_view: lookups = autocomplete_view.search_lookups logger.debug("Search lookups: %s", lookups) return lookups return []
[docs] class TomSelectModelMultipleWidget(TomSelectModelWidget, forms.SelectMultiple): """A TomSelect widget that allows multiple model object selection."""
[docs] def get_context(self, name: str, value: Any, attrs: dict[str, str] | None = None) -> dict[str, Any]: """Get context for rendering the widget.""" context = super().get_context(name, value, attrs) context["widget"]["is_multiple"] = True return context
[docs] def build_attrs(self, base_attrs: dict[str, Any], extra_attrs: dict[str, Any] | None = None) -> dict[str, Any]: """Build HTML attributes for the widget.""" attrs = super().build_attrs(base_attrs, extra_attrs) attrs["is-multiple"] = True return attrs
[docs] class TomSelectIterablesWidget(TomSelectWidgetMixin, forms.Select): """A Tom Select widget with iterables, TextChoices, or IntegerChoices choices."""
[docs] def set_request(self, request: HttpRequest) -> None: """Iterables do not require a request object.""" logger.warning("Request object is not required for iterables-type Tom Select widgets.")
[docs] def get_autocomplete_context(self) -> dict[str, Any]: """Get context for autocomplete functionality.""" autocomplete_context: dict[str, Any] = { "value_field": self.value_field, "label_field": self.label_field, "is_tabular": bool(self.plugin_dropdown_header), "use_htmx": self.use_htmx, "autocomplete_url": self.get_autocomplete_url(), } logger.debug("Autocomplete context: %s", autocomplete_context) return autocomplete_context
[docs] def get_context(self, name: str, value: Any, attrs: dict[str, str] | None = None) -> dict[str, Any]: """Get context for rendering the widget.""" # Only include the global setup if it hasn't been rendered yet request = get_current_request() if request and not getattr(request, "_tomselect_global_rendered", False): logger.debug("Rendering global TomSelect setup.") self.template_name = "django_tomselect/tomselect_setup.html" request._tomselect_global_rendered = True attrs = self.build_attrs(self.attrs, attrs) context: dict[str, Any] = { "widget": { "attrs": attrs, "close_after_select": self.close_after_select, "create": self.create, "hide_placeholder": self.hide_placeholder, "hide_selected": self.hide_selected, "highlight": self.highlight, "is_hidden": self.is_hidden, "is_multiple": False, "load_throttle": self.load_throttle, "loading_class": self.loading_class, "max_items": self.max_items, "max_options": self.max_options, "minimum_query_length": self.minimum_query_length, "name": name, "aria_label": (attrs or {}).get("aria-label", name.replace("_", " ").replace("-", " ").title()), "open_on_focus": self.open_on_focus, "placeholder": self.placeholder, "plugins": self.get_plugin_context(), "preload": self.preload, "required": self.is_required, "template_name": self.template_name, "value": value, **self.get_autocomplete_context(), **self.get_url_param_constants(), }, "csp_nonce": self.get_csp_nonce(), } # Add filter/exclude configuration - pass normalized lists. Mirrors # TomSelectModelWidget.get_context so dependent (filter_by) and exclude_by # dropdowns also work for iterables-backed fields (TomSelectChoiceField / # TomSelectMultipleChoiceField), not only model-backed ones. if self.filters: context["widget"]["filters"] = [ { "source": f.source, "lookup": f.lookup, "source_type": f.source_type, "levels_up": f.levels_up, } for f in self.filters ] # Keep for backwards compatibility with custom templates # Uses first field-type filter for legacy dependent_field field_filters = [f for f in self.filters if f.source_type == "field"] if field_filters: context["widget"].update( { "dependent_field": field_filters[0].source, "dependent_field_lookup": field_filters[0].lookup, } ) if self.excludes: context["widget"]["excludes"] = [ { "source": e.source, "lookup": e.lookup, "source_type": e.source_type, "levels_up": e.levels_up, } for e in self.excludes ] # Keep for backwards compatibility with custom templates # Uses first field-type exclude for legacy exclude_field field_excludes = [e for e in self.excludes if e.source_type == "field"] if field_excludes: context["widget"].update( { "exclude_field": field_excludes[0].source, "exclude_field_lookup": field_excludes[0].lookup, } ) if value is not None: context["widget"]["selected_options"] = self._get_selected_options(value) return context
def _get_selected_options(self, value: Any) -> list[dict[str, Any]]: """Get selected options based on value.""" try: autocomplete_view = self.get_autocomplete_view() if not autocomplete_view or not hasattr(autocomplete_view, "iterable"): return [] # Handle different types of iterables if self._is_enum_choices(autocomplete_view): return self._get_enum_choices_options(value, autocomplete_view) elif self._is_tuple_iterable(autocomplete_view): return self._get_tuple_iterable_options(value, autocomplete_view) else: # Simple iterables values = [value] if not isinstance(value, (list, tuple)) else value return [{"value": str(val), "label": escape(str(val))} for val in values] except Exception as e: logger.error("Error getting selected options: %s", e, exc_info=True) # Fallback to just returning the value as both value and label values = [value] if not isinstance(value, (list, tuple)) else value return [{"value": str(val), "label": escape(str(val))} for val in values] def _is_enum_choices(self, view: AutocompleteIterablesView) -> bool: """Check if view's iterable is an enum choices class.""" try: return hasattr(view, "iterable") and isinstance(view.iterable, type) and hasattr(view.iterable, "choices") except (AttributeError, TypeError): logger.warning("Error checking enum choices format", exc_info=True) return False def _is_tuple_iterable(self, view: AutocompleteIterablesView) -> bool: """Check if view's iterable is a tuple-based iterable.""" try: return ( isinstance(view.iterable, (tuple, list)) and len(view.iterable) > 0 and isinstance(view.iterable[0], tuple) ) except (AttributeError, IndexError, TypeError): logger.warning("Error checking tuple iterable format", exc_info=True) return False def _get_enum_choices_options(self, value: Any, view: AutocompleteIterablesView) -> list[dict[str, Any]]: """Get options from enum choices.""" values = [value] if not isinstance(value, (list, tuple)) else value selected: list[dict[str, Any]] = [] iterable = view.iterable if iterable is None or not isinstance(iterable, type) or not hasattr(iterable, "choices"): return selected for val in values: for choice_value, choice_label in iterable.choices: if str(val) == str(choice_value): selected.append({"value": str(val), "label": escape(str(choice_label))}) break else: selected.append({"value": str(val), "label": escape(str(val))}) return selected def _get_tuple_iterable_options(self, value: Any, view: AutocompleteIterablesView) -> list[dict[str, Any]]: """Get options from tuple-based iterable.""" values = [value] if not isinstance(value, (list, tuple)) else value selected: list[dict[str, Any]] = [] iterable = view.iterable if not isinstance(iterable, (list, tuple)): return selected for val in values: for item in iterable: try: if str(item[0]) == str(val): # Handle tuples of different lengths properly if len(item) > 1: label = f"{item[0]}-{item[1]}" else: label = str(item[0]) selected.append({"value": str(val), "label": escape(label)}) break except (IndexError, TypeError): # Skip malformed tuple items logger.warning("Malformed tuple in iterable: %s", item) continue else: selected.append({"value": str(val), "label": escape(str(val))}) return selected
[docs] def get_lazy_view(self) -> LazyView | None: """Get lazy-loaded view for the TomSelect iterables widget.""" if not hasattr(self, "_lazy_view") or self._lazy_view is None: # Create LazyView with the URL from config self._lazy_view = LazyView(url_name=self.url) return self._lazy_view
[docs] def get_autocomplete_view(self) -> AutocompleteIterablesView | None: """Get instance of autocomplete view for accessing iterable.""" lazy_view = self.get_lazy_view() if not lazy_view: return None view = lazy_view.get_view() # Check if view has get_iterable method if view and hasattr(view, "get_iterable") and callable(view.get_iterable): return view # If not iterables view but has get_iterable, it's compatible if view and not issubclass(view.__class__, AutocompleteIterablesView): if not hasattr(view, "get_iterable"): raise ValueError( "The autocomplete view must either be a subclass of " "AutocompleteIterablesView or implement get_iterable()" ) return view
[docs] def get_iterable(self) -> list | tuple | type: """Get iterable or choices from autocomplete view.""" autocomplete_view = self.get_autocomplete_view() if autocomplete_view: iterable = autocomplete_view.get_iterable() logger.debug("Iterable: %s", iterable) return iterable return []
[docs] class TomSelectIterablesMultipleWidget(TomSelectIterablesWidget, forms.SelectMultiple): """A TomSelect widget for multiple selection of iterables, TextChoices, or IntegerChoices."""
[docs] def get_context(self, name: str, value: Any, attrs: dict[str, str] | None = None) -> dict[str, Any]: """Get context for rendering the widget.""" context = super().get_context(name, value, attrs) context["widget"]["is_multiple"] = True return context
[docs] def build_attrs(self, base_attrs: dict[str, Any], extra_attrs: dict[str, Any] | None = None) -> dict[str, Any]: """Build HTML attributes for the widget.""" attrs = super().build_attrs(base_attrs, extra_attrs) attrs["is-multiple"] = True return attrs
class TomSelectTokenWidget(TomSelectWidgetMixin, forms.TextInput): """A token-style input that multiplexes multiple autocomplete views. Pairs with :class:`~django_tomselect.autocompletes.CompositeAutocompleteView` on the server. The serialized form value is a single canonical token string (e.g. ``author:42 category:5 some free text``) stored in a ``CharField``. Validation lives on :class:`~django_tomselect.forms.TomSelectTokenField`, not here - Django widgets do not run ``clean()``. """ template_name = "django_tomselect/tomselect_token.html" _token_widget = True def __init__( self, composite_view: str, *, placeholder: str = "", allow_free_text: bool = True, max_query_length: int = 4096, max_tokens: int = 32, css_framework: str | None = None, attrs: dict[str, Any] | None = None, ) -> None: """Initialize the token widget. Args: composite_view: A URL name (``"app:url-name"``) for the composite autocomplete endpoint. **Must be a URL name, not a class reference** - the browser plugin needs a URL to call. Use a URL name even when you have a class reference handy; the example app demonstrates the convention. placeholder: Text shown before any tokens are entered. allow_free_text: When False, un-prefixed input is treated as an error by the field's clean() and the JS plugin disables the free-text affordance. max_query_length: utf-8 byte length cap for the serialized value. max_tokens: maximum number of tokens (operator + free-text). css_framework: explicit "default" / "bootstrap4" / "bootstrap5" override for the CSS framework. Otherwise inherited from the project-level ``TOMSELECT.DEFAULT_CSS_FRAMEWORK`` setting. attrs: HTML attributes to set on the underlying ``<input>``. Raises: ImproperlyConfigured: If ``composite_view`` is not a string. The browser plugin needs a URL to call; class references would emit dead markup the JS cannot bootstrap. """ if not isinstance(composite_view, str): from django.core.exceptions import ImproperlyConfigured raise ImproperlyConfigured( "TomSelectTokenWidget requires composite_view to be a URL name " "(string), not a class reference - the browser plugin needs a " "URL to call. Pass the URL name registered for " "CompositeAutocompleteView.as_view()." ) self.composite_view = composite_view self.allow_free_text = allow_free_text self.max_query_length = max_query_length self.max_tokens = max_tokens # Thread placeholder into attrs so forms.TextInput renders it natively. merged_attrs: dict[str, Any] = dict(attrs or {}) if placeholder and "placeholder" not in merged_attrs: merged_attrs["placeholder"] = placeholder # Skip TomSelectWidgetMixin.__init__: it merges in TomSelectConfig defaults # designed for model autocomplete widgets (autocomplete URL, value/label # fields, plugin configs) which would pollute the token widget's attrs # and shape. Initialize the underlying TextInput directly, then assign # just the asset-loading attributes the mixin's _get_css_paths and media # property need. forms.TextInput.__init__(self, attrs=merged_attrs) self.css_framework = css_framework or GLOBAL_DEFAULT_CONFIG.css_framework self.use_minified = GLOBAL_DEFAULT_CONFIG.use_minified # Plugin context is empty for token widget - no dropdown_header etc. self.plugin_clear_button = None self.plugin_remove_button = None self.plugin_dropdown_header = None self.plugin_dropdown_footer = None self.plugin_checkbox_options = None self.plugin_dropdown_input = None def build_attrs( self, base_attrs: dict[str, Any], extra_attrs: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build HTML attributes - bypasses the mixin's model-autocomplete attrs. The mixin's ``build_attrs`` injects ``data-autocomplete-url`` and other attrs that don't apply to the token widget (which reads from a separate JSON config blob). Use the plain TextInput build_attrs. """ return forms.TextInput.build_attrs(self, base_attrs, extra_attrs) def get_context( self, name: str, value: Any, attrs: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the widget render context. Bypasses :class:`TomSelectWidgetMixin`'s autocomplete-URL plumbing (``get_lazy_view`` / ``get_autocomplete_url`` are designed for single-endpoint model autocomplete views, not the composite view's ``mode=…`` envelope). The token plugin reads our JSON config blob instead. ``token_config`` is JSON-serialized here (not as a raw dict) - Django's default template rendering would emit Python ``repr()`` syntax (single quotes, ``True``/``None``) which JSON.parse() cannot consume. """ ctx = forms.TextInput.get_context(self, name, value, attrs) ctx["widget"]["composite_view_url"] = self._resolve_composite_url() ctx["widget"]["token_config"] = json.dumps(self._build_token_config()) ctx["widget"]["css_framework"] = ( self.css_framework.value if isinstance(self.css_framework, AllowedCSSFrameworks) else str(self.css_framework) ) # CSP nonce is referenced at top-level by the inline init script. ctx["csp_nonce"] = self.get_csp_nonce() or "" return ctx def _resolve_composite_url(self) -> str: """Resolve the composite_view URL name. Raises ImproperlyConfigured. ``composite_view`` is a string per __init__'s ImproperlyConfigured check. If the URL doesn't reverse, fail fast at render time rather than emit an inert widget - silently rendering markup the JS can't bootstrap is the same dead-widget failure mode that class refs were rejected to prevent. """ from django.core.exceptions import ImproperlyConfigured try: return safe_reverse(self.composite_view) except NoReverseMatch as exc: raise ImproperlyConfigured( f"TomSelectTokenWidget: cannot reverse composite_view URL " f"{self.composite_view!r}. Make sure the URL is registered " "before the form is rendered." ) from exc def _build_token_config(self) -> dict[str, Any]: """Serialize widget settings the JS plugin needs.""" return { "allow_free_text": self.allow_free_text, "max_query_length": self.max_query_length, "max_tokens": self.max_tokens, } @property def media(self) -> forms.Media: """Return media - same JS bundle as the standard widgets, plus token CSS.""" css_paths = self._get_css_paths() js_path = ( "django_tomselect/js/django-tomselect.min.js" if self.use_minified else "django_tomselect/js/django-tomselect.js" ) return forms.Media(css={"all": css_paths}, js=[js_path])