You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

359 lines
12 KiB

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 "<field_name>__<langcode>"
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)