from __future__ import annotations from copy import deepcopy from typing import Any, TypeVar, TYPE_CHECKING from collections.abc import Iterable, Sequence from django import forms from django.db.models import Field, Model 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.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 if TYPE_CHECKING: # We depend here or `django-stubs` internal `_FieldsetSpec`, # in case it changes, change import or define this internally. from django.contrib.admin.options import _FieldsetSpec _ModelT = TypeVar("_ModelT", bound=Model) class TranslationBaseModelAdmin(BaseModelAdmin[_ModelT]): _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: _ModelT | None = None ) -> _FieldsetSpec | 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[misc] # 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 _patch_fieldsets(self, fieldsets: _FieldsetSpec) -> _FieldsetSpec: fieldsets_new = list(fieldsets) for name, dct in fieldsets: if "fields" in dct: dct["fields"] = self.replace_orig_field(dct["fields"]) return fieldsets_new def formfield_for_dbfield( self, db_field: Field, request: HttpRequest, **kwargs: Any ) -> forms.Field | None: if 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) if orig_formfield is None: return 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_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 # type: ignore[misc] def _get_form_or_formset( self, request: HttpRequest, obj: Model | None, **kwargs: Any ) -> dict[str, Any]: """ Generic code shared by get_form and get_formset. """ exclude = self.get_exclude(request, obj) # type: ignore[arg-type] if exclude is None: exclude = [] else: exclude = list(exclude) exclude.extend(self.get_readonly_fields(request, obj)) # type: ignore[arg-type] 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: _ModelT | None = None ) -> _FieldsetSpec | 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: _ModelT | 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_readonly_fields( self, request: HttpRequest, obj: _ModelT | None = None ) -> _ListOrTuple[str]: """ Hook to specify custom readonly fields. """ return self.replace_orig_field(self.readonly_fields) class TranslationAdmin(TranslationBaseModelAdmin[_ModelT], admin.ModelAdmin[_ModelT]): # 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 # type: ignore[misc] 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() # type: ignore[union-attr] 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: _ModelT | 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: _ModelT | None = None) -> _FieldsetSpec: 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 ) ) _ChildModelT = TypeVar("_ChildModelT", bound=Model) _ParentModelT = TypeVar("_ParentModelT", bound=Model) class TranslationInlineModelAdmin( TranslationBaseModelAdmin[_ChildModelT], InlineModelAdmin[_ChildModelT, _ParentModelT] ): def get_formset( self, request: HttpRequest, obj: _ParentModelT | 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: _ChildModelT | 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 # type: ignore[arg-type] return self._get_fieldsets_post_form_or_formset(request, form, obj) class TranslationTabularInline( TranslationInlineModelAdmin[_ChildModelT, _ParentModelT], admin.TabularInline[_ChildModelT, _ParentModelT], ): pass class TranslationStackedInline( TranslationInlineModelAdmin[_ChildModelT, _ParentModelT], admin.StackedInline[_ChildModelT, _ParentModelT], ): pass class TranslationGenericTabularInline( TranslationInlineModelAdmin[_ChildModelT, _ParentModelT], GenericTabularInline ): pass class TranslationGenericStackedInline( TranslationInlineModelAdmin[_ChildModelT, _ParentModelT], GenericStackedInline ): pass class TabbedDjangoJqueryTranslationAdmin(TranslationAdmin[_ModelT]): """ 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[_ModelT]): """ 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