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.db.models import Model, Q, QuerySet
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
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
if value_field == "pk":
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"
from django.core.exceptions import FieldDoesNotExist
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(),
}
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])