django-modeltranslation/modeltranslation/admin.py
Serhii Tereshchenko 67d26e6f63
chore: Use poetry deps for testings (#741)
Also, add lsp optional group to install python-lsp-server
2024-06-01 09:03:48 +03:00

461 lines
19 KiB
Python

from __future__ import annotations
from copy import deepcopy
from typing import Any, Iterable, Sequence
from django import forms
from django.db.models import Field
from django.contrib import admin
from django.contrib.admin.options import BaseModelAdmin, InlineModelAdmin, flatten_fieldsets
from django.contrib.contenttypes.admin import GenericStackedInline, GenericTabularInline
from django.forms.models import BaseInlineFormSet
from django.http.request import HttpRequest
from modeltranslation import settings as mt_settings
from modeltranslation.fields import TranslationField
from modeltranslation.translator import translator
from modeltranslation.utils import (
build_css_class,
build_localized_fieldname,
get_language,
get_language_bidi,
get_translation_fields,
unique,
)
from modeltranslation.widgets import ClearableWidgetWrapper
from modeltranslation._typing import _ListOrTuple
class TranslationBaseModelAdmin(BaseModelAdmin):
_orig_was_required: dict[str, bool] = {}
both_empty_values_fields = ()
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.trans_opts = translator.get_options_for_model(self.model)
self._patch_prepopulated_fields()
def _get_declared_fieldsets(
self, request: HttpRequest, obj: Any | None = None
) -> _ListOrTuple[tuple[str | None, dict[str, Any]]] | None:
# Take custom modelform fields option into account
if not self.fields and hasattr(self.form, "_meta") and self.form._meta.fields:
self.fields = self.form._meta.fields # type: ignore[assignment]
# takes into account non-standard add_fieldsets attribute used by UserAdmin
fieldsets = (
self.add_fieldsets
if getattr(self, "add_fieldsets", None) and obj is None
else self.fieldsets
)
if fieldsets:
return self._patch_fieldsets(fieldsets)
elif self.fields:
return [(None, {"fields": self.replace_orig_field(self.get_fields(request, obj))})]
return None
def formfield_for_dbfield(
self, db_field: Field, request: HttpRequest, **kwargs: Any
) -> forms.Field:
field = super().formfield_for_dbfield(db_field, request, **kwargs)
self.patch_translation_field(db_field, field, request, **kwargs)
return field
def patch_translation_field(
self, db_field: Field, field: forms.Field, request: HttpRequest, **kwargs: Any
) -> None:
if db_field.name in self.trans_opts.all_fields:
if field.required:
field.required = False
field.blank = True
self._orig_was_required["%s.%s" % (db_field.model._meta, db_field.name)] = True
# For every localized field copy the widget from the original field
# and add a css class to identify a modeltranslation widget.
try:
orig_field = db_field.translated_field
except AttributeError:
pass
else:
orig_formfield = self.formfield_for_dbfield(orig_field, request, **kwargs)
field.widget = deepcopy(orig_formfield.widget)
attrs = field.widget.attrs
# if any widget attrs are defined on the form they should be copied
try:
# this is a class:
field.widget = deepcopy(self.form._meta.widgets[orig_field.name]) # type: ignore[index]
if isinstance(field.widget, type): # if not initialized
field.widget = field.widget(attrs) # initialize form widget with attrs
except (AttributeError, TypeError, KeyError):
pass
# field.widget = deepcopy(orig_formfield.widget)
if orig_field.name in self.both_empty_values_fields:
from modeltranslation.forms import NullableField, NullCharField
form_class = field.__class__
if issubclass(form_class, NullCharField):
# NullableField don't work with NullCharField
form_class.__bases__ = tuple(
b for b in form_class.__bases__ if b != NullCharField
)
field.__class__ = type(
"Nullable%s" % form_class.__name__, (NullableField, form_class), {}
)
if (
db_field.empty_value == "both" or orig_field.name in self.both_empty_values_fields
) and isinstance(field.widget, (forms.TextInput, forms.Textarea)):
field.widget = ClearableWidgetWrapper(field.widget)
css_classes = self._get_widget_from_field(field).attrs.get("class", "").split(" ")
css_classes.append("mt")
# Add localized fieldname css class
css_classes.append(build_css_class(db_field.name, "mt-field"))
# Add mt-bidi css class if language is bidirectional
if get_language_bidi(db_field.language):
css_classes.append("mt-bidi")
if db_field.language == mt_settings.DEFAULT_LANGUAGE:
# Add another css class to identify a default modeltranslation widget
css_classes.append("mt-default")
if orig_formfield.required or self._orig_was_required.get(
"%s.%s" % (orig_field.model._meta, orig_field.name)
):
# In case the original form field was required, make the
# default translation field required instead.
orig_formfield.required = False
orig_formfield.blank = True
field.required = True
field.blank = False
# Hide clearable widget for required fields
if isinstance(field.widget, ClearableWidgetWrapper):
field.widget = field.widget.widget
self._get_widget_from_field(field).attrs["class"] = " ".join(css_classes)
def _get_widget_from_field(self, field: forms.Field) -> Any:
# retrieve "nested" widget in case of related field
if isinstance(field.widget, admin.widgets.RelatedFieldWidgetWrapper):
return field.widget.widget
else:
return field.widget
def _exclude_original_fields(self, exclude: _ListOrTuple[str] | None = None) -> tuple[str, ...]:
if exclude is None:
exclude = tuple()
if exclude:
exclude_new = tuple(exclude)
return exclude_new + tuple(self.trans_opts.all_fields.keys())
return tuple(self.trans_opts.all_fields.keys())
def replace_orig_field(self, option: Iterable[str | Sequence[str]]) -> _ListOrTuple[str]:
"""
Replaces each original field in `option` that is registered for
translation by its translation fields.
Returns a new list with replaced fields. If `option` contains no
registered fields, it is returned unmodified.
>>> self = TranslationAdmin() # PyFlakes
>>> print(self.trans_opts.fields.keys())
['title',]
>>> get_translation_fields(self.trans_opts.fields.keys()[0])
['title_de', 'title_en']
>>> self.replace_orig_field(['title', 'url'])
['title_de', 'title_en', 'url']
Note that grouped fields are flattened. We do this because:
1. They are hard to handle in the jquery-ui tabs implementation
2. They don't scale well with more than a few languages
3. It's better than not handling them at all (okay that's weak)
>>> self.replace_orig_field((('title', 'url'), 'email', 'text'))
['title_de', 'title_en', 'url_de', 'url_en', 'email_de', 'email_en', 'text']
"""
if option:
option_new = list(option)
for opt in option:
if opt in self.trans_opts.all_fields:
index = option_new.index(opt)
option_new[index : index + 1] = get_translation_fields(opt) # type: ignore[arg-type]
elif isinstance(opt, (tuple, list)) and (
[o for o in opt if o in self.trans_opts.all_fields]
):
index = option_new.index(opt)
option_new[index : index + 1] = self.replace_orig_field(opt)
option = option_new
return option # type: ignore[return-value]
def _patch_fieldsets(
self, fieldsets: _ListOrTuple[tuple[str | None, dict[str, Any]]]
) -> _ListOrTuple[tuple[str | None, dict[str, Any]]]:
if fieldsets:
fieldsets_new = list(fieldsets)
for name, dct in fieldsets:
if "fields" in dct:
dct["fields"] = self.replace_orig_field(dct["fields"])
fieldsets = fieldsets_new
return fieldsets
def _patch_prepopulated_fields(self) -> None:
def localize(sources: Sequence[str], lang: str) -> tuple[str, ...]:
"Append lang suffix (if applicable) to field list"
def append_lang(source: str) -> str:
if source in self.trans_opts.all_fields:
return build_localized_fieldname(source, lang)
return source
return tuple(map(append_lang, sources))
prepopulated_fields: dict[str, Sequence[str]] = {}
for dest, sources in self.prepopulated_fields.items():
if dest in self.trans_opts.all_fields:
for lang in mt_settings.AVAILABLE_LANGUAGES:
key = build_localized_fieldname(dest, lang)
prepopulated_fields[key] = localize(sources, lang)
else:
lang = mt_settings.PREPOPULATE_LANGUAGE or get_language()
prepopulated_fields[dest] = localize(sources, lang)
self.prepopulated_fields = prepopulated_fields
def _get_form_or_formset(
self, request: HttpRequest, obj: Any | None, **kwargs: Any
) -> dict[str, Any]:
"""
Generic code shared by get_form and get_formset.
"""
exclude = self.get_exclude(request, obj)
if exclude is None:
exclude = []
else:
exclude = list(exclude)
exclude.extend(self.get_readonly_fields(request, obj))
if not exclude and hasattr(self.form, "_meta") and self.form._meta.exclude:
# Take the custom ModelForm's Meta.exclude into account only if the
# ModelAdmin doesn't define its own.
exclude.extend(self.form._meta.exclude)
# If exclude is an empty list we pass None to be consistent with the
# default on modelform_factory
exclude = self.replace_orig_field(exclude) or None
exclude = self._exclude_original_fields(exclude)
kwargs.update({"exclude": exclude})
return kwargs
def _get_fieldsets_pre_form_or_formset(
self, request: HttpRequest, obj: Any | None = None
) -> _ListOrTuple[tuple[str | None, dict[str, Any]]] | None:
"""
Generic get_fieldsets code, shared by
TranslationAdmin and TranslationInlineModelAdmin.
"""
return self._get_declared_fieldsets(request, obj)
def _get_fieldsets_post_form_or_formset(
self, request: HttpRequest, form: type[forms.ModelForm], obj: Any | None = None
) -> list:
"""
Generic get_fieldsets code, shared by
TranslationAdmin and TranslationInlineModelAdmin.
"""
base_fields = self.replace_orig_field(form.base_fields.keys())
fields = list(base_fields) + list(self.get_readonly_fields(request, obj))
return [(None, {"fields": self.replace_orig_field(fields)})]
def get_translation_field_excludes(
self, exclude_languages: list[str] | None = None
) -> tuple[TranslationField, ...]:
"""
Returns a tuple of translation field names to exclude based on
`exclude_languages` arg.
TODO: Currently unused?
"""
if exclude_languages is None:
exclude_languages = []
excl_languages = []
if exclude_languages:
excl_languages = exclude_languages
exclude = []
for orig_fieldname, translation_fields in self.trans_opts.all_fields.items():
for tfield in translation_fields:
if tfield.language in excl_languages and tfield not in exclude:
exclude.append(tfield)
return tuple(exclude)
def get_readonly_fields(
self, request: HttpRequest, obj: Any | None = None
) -> _ListOrTuple[str]:
"""
Hook to specify custom readonly fields.
"""
return self.replace_orig_field(self.readonly_fields)
class TranslationAdmin(TranslationBaseModelAdmin, admin.ModelAdmin):
# TODO: Consider addition of a setting which allows to override the fallback to True
group_fieldsets = False
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._patch_list_editable()
def _patch_list_editable(self) -> None:
if self.list_editable:
editable_new = list(self.list_editable)
display_new = list(self.list_display)
for field in self.list_editable:
if field in self.trans_opts.all_fields:
index = editable_new.index(field)
display_index = display_new.index(field)
translation_fields = get_translation_fields(field)
editable_new[index : index + 1] = translation_fields
display_new[display_index : display_index + 1] = translation_fields
self.list_editable = editable_new
self.list_display = display_new
def _group_fieldsets(self, fieldsets: list) -> list:
# Fieldsets are not grouped by default. The function is activated by
# setting TranslationAdmin.group_fieldsets to True. If the admin class
# already defines a fieldset, we leave it alone and assume the author
# has done whatever grouping for translated fields they desire.
if self.group_fieldsets is True:
flattened_fieldsets = flatten_fieldsets(fieldsets)
# Create a fieldset to group each translated field's localized fields
fields = sorted(f for f in self.opts.get_fields() if f.concrete) # type: ignore[type-var]
untranslated_fields = [
f.name
for f in fields
if
(
# Exclude the primary key field
f is not self.opts.auto_field
# Exclude non-editable fields
and f.editable
# Exclude the translation fields
and not hasattr(f, "translated_field")
# Honour field arguments. We rely on the fact that the
# passed fieldsets argument is already fully filtered
# and takes options like exclude into account.
and f.name in flattened_fieldsets
)
]
# TODO: Allow setting a label
fieldsets = (
[
(
"",
{"fields": untranslated_fields},
)
]
if untranslated_fields
else []
)
temp_fieldsets = {}
for orig_field, trans_fields in self.trans_opts.all_fields.items():
trans_fieldnames = [f.name for f in sorted(trans_fields, key=lambda x: x.name)]
if any(f in trans_fieldnames for f in flattened_fieldsets):
# Extract the original field's verbose_name for use as this
# fieldset's label - using gettext_lazy in your model
# declaration can make that translatable.
label = self.model._meta.get_field(orig_field).verbose_name.capitalize()
temp_fieldsets[orig_field] = (
label,
{"fields": trans_fieldnames, "classes": ("mt-fieldset",)},
)
fields_order = unique(
f.translated_field.name
for f in self.opts.fields
if hasattr(f, "translated_field") and f.name in flattened_fieldsets
)
for field_name in fields_order:
fieldsets.append(temp_fieldsets.pop(field_name))
assert not temp_fieldsets # cleaned
return fieldsets
def get_form(
self, request: HttpRequest, obj: Any | None = None, **kwargs: Any
) -> type[forms.ModelForm]:
kwargs = self._get_form_or_formset(request, obj, **kwargs)
return super().get_form(request, obj, **kwargs)
def get_fieldsets(
self, request: HttpRequest, obj: Any | None = None
) -> _ListOrTuple[tuple[str | None, dict[str, Any]]]:
return self._get_fieldsets_pre_form_or_formset(request, obj) or self._group_fieldsets(
self._get_fieldsets_post_form_or_formset(
request, self.get_form(request, obj, fields=None), obj
)
)
class TranslationInlineModelAdmin(TranslationBaseModelAdmin, InlineModelAdmin):
def get_formset(
self, request: HttpRequest, obj: Any | None = None, **kwargs: Any
) -> type[BaseInlineFormSet]:
kwargs = self._get_form_or_formset(request, obj, **kwargs)
return super().get_formset(request, obj, **kwargs)
def get_fieldsets(self, request: HttpRequest, obj: Any | None = None):
# FIXME: If fieldsets are declared on an inline some kind of ghost
# fieldset line with just the original model verbose_name of the model
# is displayed above the new fieldsets.
declared_fieldsets = self._get_fieldsets_pre_form_or_formset(request, obj)
if declared_fieldsets:
return declared_fieldsets
form = self.get_formset(request, obj, fields=None).form
return self._get_fieldsets_post_form_or_formset(request, form, obj)
class TranslationTabularInline(TranslationInlineModelAdmin, admin.TabularInline):
pass
class TranslationStackedInline(TranslationInlineModelAdmin, admin.StackedInline):
pass
class TranslationGenericTabularInline(TranslationInlineModelAdmin, GenericTabularInline):
pass
class TranslationGenericStackedInline(TranslationInlineModelAdmin, GenericStackedInline):
pass
class TabbedDjangoJqueryTranslationAdmin(TranslationAdmin):
"""
Convenience class which includes the necessary media files for tabbed
translation fields. Reuses Django's internal jquery version.
"""
class Media:
js = (
"admin/js/jquery.init.js",
"modeltranslation/js/force_jquery.js",
mt_settings.JQUERY_UI_URL,
"modeltranslation/js/tabbed_translation_fields.js",
)
css = {
"all": ("modeltranslation/css/tabbed_translation_fields.css",),
}
class TabbedExternalJqueryTranslationAdmin(TranslationAdmin):
"""
Convenience class which includes the necessary media files for tabbed
translation fields. Loads recent jquery version from a cdn.
"""
class Media:
js = (
mt_settings.JQUERY_URL,
mt_settings.JQUERY_UI_URL,
"modeltranslation/js/tabbed_translation_fields.js",
)
css = {
"screen": ("modeltranslation/css/tabbed_translation_fields.css",),
}
TabbedTranslationAdmin = TabbedDjangoJqueryTranslationAdmin