import copy from typing import List, Union from django import forms from django.conf import settings from django.forms import ( BaseForm, BaseInlineFormSet, BaseModelForm, BaseModelFormSet, ) from django.forms.forms import DeclarativeFieldsMetaclass from django.forms.models import ModelFormMetaclass from django.utils.safestring import mark_safe from .strings import LazyI18nString class I18nWidget(forms.MultiWidget): """ The default form widget for I18nCharField and I18nTextField. It makes use of Django's MultiWidget mechanism and does some magic to save you time. """ widget = forms.TextInput def __init__(self, locales: List[str], field: forms.Field, attrs=None): widgets = [] self.locales = locales self.enabled_locales = locales self.field = field for lng in self.locales: a = copy.copy(attrs) or {} a['lang'] = lng widgets.append(self.widget(attrs=a)) super().__init__(widgets, attrs) def decompress(self, value) -> List[Union[str, None]]: data = [] first_enabled = None any_enabled_filled = False if not isinstance(value, LazyI18nString): value = LazyI18nString(value) for i, lng in enumerate(self.locales): dataline = ( value.data[lng] if value is not None and ( isinstance(value.data, dict) or isinstance(value.data, LazyI18nString.LazyGettextProxy) ) and lng in value.data else None ) if lng in self.enabled_locales: if not first_enabled: first_enabled = i if dataline: any_enabled_filled = True data.append(dataline) if value and not isinstance(value.data, dict) and not isinstance(value.data, LazyI18nString.LazyGettextProxy): data[first_enabled] = value.data elif value and not any_enabled_filled: data[first_enabled] = value.localize(self.enabled_locales[0]) return data def render(self, name: str, value, attrs=None, renderer=None) -> str: if self.is_localized: for widget in self.widgets: widget.is_localized = self.is_localized # value is a list of values, each corresponding to a widget # in self.widgets. if not isinstance(value, list): value = self.decompress(value) output = [] final_attrs = self.build_attrs(attrs or dict()) id_ = final_attrs.get('id', None) for i, widget in enumerate(self.widgets): if self.locales[i] not in self.enabled_locales: continue try: widget_value = value[i] except IndexError: widget_value = None if id_: final_attrs = dict( final_attrs, id='%s_%s' % (id_, i), title=self.locales[i] ) output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer)) return mark_safe(self.format_output(output)) def format_output(self, rendered_widgets) -> str: return '<div class="i18n-form-group%s">%s</div>' % ( ' i18n-form-single-language' if len(rendered_widgets) <= 1 else '', ''.join(rendered_widgets), ) class I18nTextInput(I18nWidget): """ The default form widget for I18nCharField. It makes use of Django's MultiWidget mechanism and does some magic to save you time. """ widget = forms.TextInput class I18nTextarea(I18nWidget): """ The default form widget for I18nTextField. It makes use of Django's MultiWidget mechanism and does some magic to save you time. """ widget = forms.Textarea class I18nFormField(forms.MultiValueField): """ The form field that is used by I18nCharField and I18nTextField. It makes use of Django's MultiValueField mechanism to create one sub-field per available language. It contains special treatment to make sure that a field marked as "required" is validated as "filled out correctly" if *at least one* translation is filled it. It is never required to fill in all of them. This has the drawback that the HTML property ``required`` is set on none of the fields as this would lead to irritating behaviour. :param locales: An iterable of locale codes that the widget should render a field for. If omitted, fields will be rendered for all languages configured in ``settings.LANGUAGES``. :param require_all_fields: A boolean, if set to True field requires all translations to be given. """ def compress(self, data_list) -> LazyI18nString: locales = self.locales data = {} for i, value in enumerate(data_list): data[locales[i]] = value return LazyI18nString(data) def clean(self, value) -> LazyI18nString: if isinstance(value, LazyI18nString): # This happens e.g. if the field is disabled return value found = False found_all = True clean_data = [] errors = [] for i, field in enumerate(self.fields): try: field_value = value[i] except (IndexError, TypeError): field_value = None if field_value not in self.empty_values: found = True elif field.locale in self.widget.enabled_locales: found_all = False try: clean_data.append(field.clean(field_value)) except forms.ValidationError as e: # Collect all validation errors in a single list, which we'll # raise at the end of clean(), rather than raising a single # exception for the first error we encounter. Skip duplicates. errors.extend(m for m in e.error_list if m not in errors) if errors: raise forms.ValidationError(errors) if self.one_required and not found: raise forms.ValidationError(self.error_messages['required'], code='required') if self.require_all_fields and not found_all: raise forms.ValidationError(self.error_messages['incomplete'], code='incomplete') out = self.compress(clean_data) self.validate(out) self.run_validators(out) return out def __init__(self, *args, **kwargs): fields = [] defaults = { 'widget': self.widget, 'max_length': kwargs.pop('max_length', None), } self.locales = kwargs.pop('locales', [loc[0] for loc in settings.LANGUAGES]) self.one_required = kwargs.get('required', True) require_all_fields = kwargs.pop('require_all_fields', False) kwargs['required'] = False kwargs['widget'] = kwargs['widget']( locales=self.locales, field=self, **kwargs.pop('widget_kwargs', {}) ) defaults.update(**kwargs) for lngcode in self.locales: defaults['label'] = '%s (%s)' % (defaults.get('label'), lngcode) field = forms.CharField(**defaults) field.locale = lngcode fields.append(field) super().__init__( fields=fields, require_all_fields=False, *args, **kwargs ) self.require_all_fields = require_all_fields class I18nFormMixin: def __init__(self, *args, **kwargs): locales = kwargs.pop('locales', None) super().__init__(*args, **kwargs) if locales: for k, field in self.fields.items(): if isinstance(field, I18nFormField): field.widget.enabled_locales = locales class BaseI18nModelForm(I18nFormMixin, BaseModelForm): """ This is a helperclass to construct an I18nModelForm. """ pass class BaseI18nForm(I18nFormMixin, BaseForm): """ This is a helperclass to construct an I18nForm. """ pass class I18nForm(BaseI18nForm, metaclass=DeclarativeFieldsMetaclass): """ This is a modified version of Django's Form which differs from Form in only one way: The constructor takes one additional optional argument ``locales`` expecting a list of language codes. If given, this instance is used to select the visible languages in all I18nFormFields of the form. If not given, all languages from ``settings.LANGUAGES`` will be displayed. :param locales: A list of locales that should be displayed. """ pass class I18nModelForm(BaseI18nModelForm, metaclass=ModelFormMetaclass): """ This is a modified version of Django's ModelForm which differs from ModelForm in only one way: The constructor takes one additional optional argument ``locales`` expecting a list of language codes. If given, this instance is used to select the visible languages in all I18nFormFields of the form. If not given, all languages from ``settings.LANGUAGES`` will be displayed. :param locales: A list of locales that should be displayed. """ pass class I18nFormSetMixin: def __init__(self, *args, **kwargs): self.locales = kwargs.pop('locales', None) super().__init__(*args, **kwargs) def _construct_form(self, i, **kwargs): kwargs['locales'] = self.locales return super()._construct_form(i, **kwargs) @property def empty_form(self): form = self.form( auto_id=self.auto_id, prefix=self.add_prefix('__prefix__'), empty_permitted=True, use_required_attribute=False, locales=self.locales ) self.add_fields(form, None) return form class I18nModelFormSet(I18nFormSetMixin, BaseModelFormSet): """ This is equivalent to a normal BaseModelFormset, but cares for the special needs of I18nForms (see there for more information). :param locales: A list of locales that should be displayed. """ pass class I18nInlineFormSet(I18nFormSetMixin, BaseInlineFormSet): """ This is equivalent to a normal BaseInlineFormset, but cares for the special needs of I18nForms (see there for more information). :param locales: A list of locales that should be displayed. """ pass