From 591e945c337b523b5f3ab3e2aeb26854da262462 Mon Sep 17 00:00:00 2001 From: Dirk Eschler Date: Mon, 18 Feb 2013 17:45:36 +0100 Subject: [PATCH] Add more control over required languages (close #143). --- docs/modeltranslation/registration.rst | 47 ++++++++++++++++++++++++-- modeltranslation/fields.py | 28 +++++++++++++-- modeltranslation/tests/models.py | 9 +++++ modeltranslation/tests/tests.py | 45 +++++++++++++++++++++++- modeltranslation/tests/translation.py | 14 +++++++- modeltranslation/translator.py | 27 +++++++++++++++ 6 files changed, 162 insertions(+), 8 deletions(-) diff --git a/docs/modeltranslation/registration.rst b/docs/modeltranslation/registration.rst index 3dc5f80..09a7d3b 100644 --- a/docs/modeltranslation/registration.rst +++ b/docs/modeltranslation/registration.rst @@ -154,15 +154,49 @@ involves copying migration files, using ``SOUTH_MIGRATION_MODULES`` setting, and passing ``--delete-ghost-migrations`` flag, so we don't recommend it. Invoking ``sync_translation_fields`` is plain easier. -Note that all added fields are +Note that all added fields are by default declared ``blank=True`` and ``null=True`` no matter if the original field is -required or not. In other words - all translations are optional. To populate -the default translation fields added by the modeltranslation application +required or not. In other words - all translations are optional, unless an explicit option +is provided - see below. + +To populate the default translation fields added by the modeltranslation application with values from existing database fields, you can use the ``update_translation_fields`` command below. See :ref:`commands-update_translation_fields` for more info on this. +.. _required_langs: + +Required fields +--------------- + +By default, all translation fields are optional (not required). It can be changed using special +attribute on ``TranslationOptions``, though:: + + class NewsTranslationOptions(TranslationOptions): + fields = ('title', 'text',) + required_languages = ('en', 'de') + +It quite self-explanatory: for German and English, all translation fields are required. For other +languages - optional. + +A more fine-grained control is available:: + + class NewsTranslationOptions(TranslationOptions): + fields = ('title', 'text',) + required_languages = {'de': ('title', 'text'), 'default': ('title',)} + +For German, all fields (both ``title`` and ``text``) are required; for all other languages - only +``title`` is required. The ``'default'`` is optional. + +.. note:: + Requirement is enforced by ``blank=False``. Please remember that it will trigger validation only + in modelforms and admin (as always in Django). Manual model validation can be performed via + ``full_clean()`` model method. + + The required fields are still ``null=True``, though. + + ``TranslationOptions`` attributes reference ------------------------------------------- @@ -205,6 +239,13 @@ Classes inheriting from ``TranslationOptions`` can have following attributes def empty_values = '' empty_values = {'title': '', 'slug': None, 'desc': 'both'} +.. attribute:: TranslationOptions.required_languages + + Control which translation fields are required. See :ref:`required_langs`. :: + + required_languages = ('en', 'de') + required_languages = {'de': ('title','text'), 'default': ('title',)} + .. _supported_field_matrix: diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index 3442fdf..8189b16 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -98,6 +98,8 @@ class TranslationField(object): that needs to be specified when the field is created. """ def __init__(self, translated_field, language, empty_value, *args, **kwargs): + from modeltranslation.translator import translator + # Update the dict of this field with the content of the original one # This might be a bit radical?! Seems to work though... self.__dict__.update(translated_field.__dict__) @@ -109,15 +111,35 @@ class TranslationField(object): if empty_value is NONE: self.empty_value = None if translated_field.null else '' - # Translation are always optional (for now - maybe add some parameters - # to the translation options for configuring this) - + # Default behaviour is that all translations are optional if not isinstance(self, fields.BooleanField): # TODO: Do we really want to enforce null *at all*? Shouldn't this # better honour the null setting of the translated field? self.null = True self.blank = True + # Take required_languages translation option into account + trans_opts = translator.get_options_for_model(self.model) + if trans_opts.required_languages: + required_languages = trans_opts.required_languages + if isinstance(trans_opts.required_languages, (tuple, list)): + # All fields + if self.language in required_languages: + # self.null = False + self.blank = False + else: + # Certain fields only + # Try current language - if not present, try 'default' key + try: + req_fields = required_languages[self.language] + except KeyError: + req_fields = required_languages.get('default', ()) + if self.name in req_fields: + # TODO: We might have to handle the whole thing through the + # FieldsAggregationMetaClass, as fields can be inherited. + # self.null = False + self.blank = False + # Adjust the name of this field to reflect the language self.attname = build_localized_fieldname(self.translated_field.name, self.language) self.name = self.attname diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index 56b96c7..0e9c18f 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -289,3 +289,12 @@ class CustomManager2(models.Manager): class CustomManager2TestModel(models.Model): title = models.CharField(ugettext_lazy('title'), max_length=255) objects = CustomManager2() + + +########## Required fields testing + +class RequiredModel(models.Model): + non_req = models.CharField(max_length=10, blank=True) + req = models.CharField(max_length=10) + req_reg = models.CharField(max_length=10) + req_en_reg = models.CharField(max_length=10) diff --git a/modeltranslation/tests/tests.py b/modeltranslation/tests/tests.py index 0cafcd5..52be7cf 100644 --- a/modeltranslation/tests/tests.py +++ b/modeltranslation/tests/tests.py @@ -38,7 +38,7 @@ from modeltranslation.utils import (build_css_class, build_localized_fieldname, request = None # How many models are registered for tests. -TEST_MODELS = 27 +TEST_MODELS = 28 class reload_override_settings(override_settings): @@ -2709,3 +2709,46 @@ class ProxyModelTest(ModeltranslationTestBase): self.assertEqual(n.title, m.title) self.assertEqual(n.title_de, m.title_de) self.assertEqual(n.title_en, m.title_en) + + +class TestRequired(ModeltranslationTestBase): + def assertRequired(self, field_name): + self.assertFalse(self.opts.get_field(field_name).blank) + + def assertNotRequired(self, field_name): + self.assertTrue(self.opts.get_field(field_name).blank) + + def test_required(self): + self.opts = models.RequiredModel._meta + + # All non required + self.assertNotRequired('non_req') + self.assertNotRequired('non_req_en') + self.assertNotRequired('non_req_de') + + # Original required, but translated fields not - default behaviour + self.assertRequired('req') + self.assertNotRequired('req_en') + self.assertNotRequired('req_de') + + # Set all translated field required + self.assertRequired('req_reg') + self.assertRequired('req_reg_en') + self.assertRequired('req_reg_de') + + # Set some translated field required + self.assertRequired('req_en_reg') + self.assertRequired('req_en_reg_en') + self.assertNotRequired('req_en_reg_de') + + # Test validation + inst = models.RequiredModel() + inst.req = 'abc' + inst.req_reg = 'def' + try: + inst.full_clean() + except ValidationError as e: + error_fields = set(e.message_dict.keys()) + self.assertEqual(set(('req_reg_en', 'req_en_reg', 'req_en_reg_en')), error_fields) + else: + self.fail('ValidationError not raised!') diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index c817a89..36fe3c4 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -7,7 +7,8 @@ from modeltranslation.tests.models import ( DescriptorModel, AbstractModelA, AbstractModelB, Slugged, MetaData, Displayable, Page, RichText, RichTextPage, MultitableModelA, MultitableModelB, MultitableModelC, ManagerTestModel, CustomManagerTestModel, CustomManager2TestModel, GroupFieldsetsModel, NameModel, - ThirdPartyRegisteredModel, ProxyTestModel, UniqueNullableModel, OneToOneFieldModel) + ThirdPartyRegisteredModel, ProxyTestModel, UniqueNullableModel, OneToOneFieldModel, + RequiredModel) class TestTranslationOptions(TranslationOptions): @@ -186,3 +187,14 @@ translator.register(GroupFieldsetsModel, GroupFieldsetsTranslationOptions) class NameTranslationOptions(TranslationOptions): fields = ('firstname', 'lastname', 'slug2') translator.register(NameModel, NameTranslationOptions) + + +########## Required fields testing + +class RequiredTranslationOptions(TranslationOptions): + fields = ('non_req', 'req', 'req_reg', 'req_en_reg') + required_languages = { + 'en': ('req_reg', 'req_en_reg',), + 'default': ('req_reg',), # for all other languages + } +translator.register(RequiredModel, RequiredTranslationOptions) diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index c96aeb5..0e70946 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from django.utils.six import with_metaclass +from django.core.exceptions import ImproperlyConfigured from django.db.models import Manager, ForeignKey, OneToOneField from django.db.models.base import ModelBase from django.db.models.signals import post_init @@ -57,6 +58,7 @@ class TranslationOptions(with_metaclass(FieldsAggregationMetaClass, object)): with translated model. This model may be not translated itself. ``related_fields`` contains names of reverse lookup fields. """ + required_languages = () def __init__(self, model): """ @@ -69,6 +71,28 @@ class TranslationOptions(with_metaclass(FieldsAggregationMetaClass, object)): self.fields = dict((f, set()) for f in self.fields) self.related_fields = [] + def validate(self): + """ + Perform options validation. + """ + # TODO: at the moment only required_languages is validated. + # Maybe check other options as well? + if self.required_languages: + if isinstance(self.required_languages, (tuple, list)): + self._check_languages(self.required_languages) + else: + self._check_languages(self.required_languages.iterkeys(), extra=('default',)) + for fieldnames in self.required_languages.itervalues(): + if any(f not in self.fields for f in fieldnames): + raise ImproperlyConfigured( + 'Fieldname in required_languages which is not in fields option.') + + def _check_languages(self, languages, extra=()): + correct = mt_settings.AVAILABLE_LANGUAGES + list(extra) + if any(l not in correct for l in languages): + raise ImproperlyConfigured( + 'Language in required_languages which is not in AVAILABLE_LANGUAGES.') + def update(self, other): """ Update with options from a superclass. @@ -342,6 +366,9 @@ class Translator(object): # Find inherited fields and create options instance for the model. opts = self._get_options_for_model(model, opts_class, **options) + # Now, when all fields are initialized and inherited, validate configuration. + opts.validate() + # Mark the object explicitly as registered -- registry caches # options of all models, registered or not. opts.registered = True