from __future__ import annotations from typing import Any, Optional import json from django.conf import settings from django.forms.widgets import Media, Widget from django.template.loader import get_template try: from dj_language.models import Language # type: ignore except Exception: # pragma: no cover - fallback when app is missing Language = None # type: ignore from unfold.widgets import ( UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget, ) from unfold.contrib.forms.widgets import WysiwygWidget class MultiLanguageJSONWidget(Widget): """ Unfold-styled widget for JSONField storing list of objects with keys: - language_code - title Renders a horizontal, scrollable list of active language codes; clicking a code toggles the corresponding input rendered using the provided input widget class (UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget, or WysiwygWidget). The widget submits values via sub-inputs named as "__" and converts them in value_from_datadict to the required JSON string (list[dict]). """ template_name = "utils/widgets/multilang_json_widget.html" def __init__( self, input_widget_class: type[Widget] | None = None, attrs: Optional[dict[str, Any]] = None, ) -> None: super().__init__(attrs) if input_widget_class is None: input_widget_class = UnfoldAdminTextInputWidget self.input_widget_class: type[Widget] = input_widget_class self.input_widget: Widget = input_widget_class() @property def media(self) -> Media: # type: ignore[override] # Only include child media (e.g. trix for Wysiwyg). JS is inlined in template. try: child_media = self.input_widget.media # type: ignore[attr-defined] except Exception: child_media = Media() return child_media def _get_active_language_codes(self) -> list[str]: codes: list[str] = [] if Language is not None: try: codes = list( Language.objects.filter(status=True).values_list("code", flat=True) # type: ignore[attr-defined] ) except Exception: try: codes = list(Language.objects.values_list("code", flat=True)) except Exception: codes = [] if not codes: codes = [code for code, _ in getattr(settings, "LANGUAGES", [("en", "English")])] return list(dict.fromkeys(codes)) def _normalize_value(self, value: Any) -> dict[str, Any]: mapping: dict[str, Any] = {} if not value: return mapping if isinstance(value, str): try: value = json.loads(value) except Exception: return mapping if isinstance(value, list): for item in value: if not isinstance(item, dict): continue code = ( item.get("language_code") or item.get("code") or item.get("lang") or item.get("language") ) text = item.get("title") or item.get("value") or item.get("text") if code and text is not None: mapping[str(code)] = text elif isinstance(value, dict): if "language_code" in value and "title" in value: mapping[str(value["language_code"])] = value["title"] else: for code, text in value.items(): mapping[str(code)] = text return mapping def get_context(self, name: str, value: Any, attrs: Optional[dict[str, Any]]): context = super().get_context(name, value, attrs) languages = self._get_active_language_codes() values_map = self._normalize_value(value) # Ensure languages include any language codes present in value for code in values_map.keys(): if code not in languages: languages.append(code) # Reorder: languages with existing values first codes_with_values = [code for code in languages if values_map.get(code) not in (None, "")] codes_without_values = [code for code in languages if code not in codes_with_values] languages = [*codes_with_values, *codes_without_values] # Build per-language rendered inputs using the child widget rendered_inputs: list[dict[str, str]] = [] for code in languages: input_name = f"{name}__{code}" rendered_html = self.input_widget.render(input_name, values_map.get(code, ""), attrs) rendered_inputs.append({"code": code, "html": rendered_html}) # Prepare serialized hidden value (JSON string) serialized_list: list[dict[str, Any]] = [] for code in languages: text_value = values_map.get(code) if text_value not in (None, ""): serialized_list.append({"language_code": code, "title": text_value}) context["widget"].update( { "languages": languages, "inputs": rendered_inputs, "values_map": values_map, "field_name": name, "serialized": json.dumps(serialized_list, ensure_ascii=False), "has_value_codes": codes_with_values, } ) return context def value_from_datadict(self, data, files, name): hidden_value = data.get(name) if hidden_value not in (None, ""): return hidden_value prefix = f"{name}__" results: list[dict[str, Any]] = [] for key in data.keys(): if not key.startswith(prefix): continue code = key[len(prefix) :] text = data.get(key) if text not in (None, ""): results.append({"language_code": code, "title": text}) return json.dumps(results, ensure_ascii=False) def value_omitted_from_data(self, data, files, name): prefix = f"{name}__" return not any(k.startswith(prefix) for k in data.keys()) def render(self, name, value, attrs=None, renderer=None): """ Override render method to use regular Django template loader instead of form renderer """ if value is None: value = '' context = self.get_context(name, value, attrs) template = get_template(self.template_name) return template.render(context)