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]: return [ "ar", # Arabic "tr", # Turkish "fa", # Persian (Farsi) "ur", # Urdu "bn", # Bengali "id", # Indonesian "fr", # French "ru", # Russian "uz", # Uzbek "ky", # Kyrgyz "tg", # Tajik "az", # Azerbaijani "en", # English "de", # German "zh", # Mandarin Chinese "ha", # Hausa "sw", # Swahili "es", # Spanish ] 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) class MultiLanguageAddressWidget(MultiLanguageJSONWidget): """ Unfold-styled widget for JSONField storing localized address parts. Each language contains a list of address parts (e.g. book, chapter, verse). """ template_name = "utils/widgets/multilang_address_widget.html" def __init__(self, attrs: Optional[dict[str, Any]] = None) -> None: super().__init__(input_widget_class=None, attrs=attrs) def _normalize_value(self, value: Any) -> dict[str, list[str]]: mapping: dict[str, list[str]] = {} 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") ) title = item.get("title") if code and isinstance(title, list): mapping[str(code)] = [str(x) for x in title] elif isinstance(value, dict): for code, title in value.items(): if isinstance(title, list): mapping[str(code)] = [str(x) for x in title] else: mapping[str(code)] = [str(title)] return mapping def get_context(self, name: str, value: Any, attrs: Optional[dict[str, Any]]): context = Widget.get_context(self, 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)] codes_without_values = [code for code in languages if code not in codes_with_values] languages = [*codes_with_values, *codes_without_values] rendered_languages: list[dict[str, Any]] = [] for code in languages: parts = values_map.get(code) or [] rendered_languages.append({ "code": code, "parts": parts, }) # Prepare serialized hidden value (JSON string) serialized_list: list[dict[str, Any]] = [] for code in languages: parts = values_map.get(code) if parts: filtered_parts = [str(x).strip() for x in parts if str(x).strip()] if filtered_parts: serialized_list.append({"language_code": code, "title": filtered_parts}) context["widget"].update( { "languages": languages, "rendered_languages": rendered_languages, "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 return "[]" def value_omitted_from_data(self, data, files, name): return name not in data class LinksJSONWidget(Widget): """ Unfold-styled widget for JSONField storing a list of links (title, link). """ template_name = "utils/widgets/links_json_widget.html" def __init__(self, attrs: Optional[dict[str, Any]] = None) -> None: super().__init__(attrs) def _normalize_value(self, value: Any) -> list[dict[str, str]]: if not value: return [] if isinstance(value, str): try: value = json.loads(value) except Exception: return [] if isinstance(value, list): results = [] for item in value: if isinstance(item, dict): title = item.get("title", "") link = item.get("link", "") results.append({ "title": str(title), "link": str(link) }) return results return [] def get_context(self, name: str, value: Any, attrs: Optional[dict[str, Any]]): context = super().get_context(name, value, attrs) links = self._normalize_value(value) # Prepare serialized hidden value (JSON string) serialized_list = [l for l in links if l.get("title") or l.get("link")] context["widget"].update({ "field_name": name, "links": links, "serialized": json.dumps(serialized_list, ensure_ascii=False), }) return context def value_from_datadict(self, data, files, name): hidden_value = data.get(name) if hidden_value not in (None, ""): return hidden_value return "[]" def value_omitted_from_data(self, data, files, name): return name not in data def render(self, name, value, attrs=None, renderer=None): if value is None: value = '' context = self.get_context(name, value, attrs) template = get_template(self.template_name) return template.render(context)