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.
190 lines
6.5 KiB
190 lines
6.5 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]:
|
|
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)
|
|
|
|
|
|
|
|
|
|
|