diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index e75f4d2..2514224 100644 --- a/modeltranslation/admin.py +++ b/modeltranslation/admin.py @@ -47,10 +47,12 @@ class TranslationBaseModelAdmin(BaseModelAdmin): # For every localized field copy the widget from the original field # and add a css class to identify a modeltranslation widget. - if db_field.name in self.trans_opts.localized_fieldnames_rev: - orig_fieldname = self.trans_opts.localized_fieldnames_rev[db_field.name] - orig_formfield = self.formfield_for_dbfield( - self.model._meta.get_field(orig_fieldname), **kwargs) + try: + orig_field = db_field.translated_field + except AttributeError: + pass + else: + orig_formfield = self.formfield_for_dbfield(orig_field, **kwargs) field.widget = deepcopy(orig_formfield.widget) css_classes = field.widget.attrs.get('class', '').split(' ') css_classes.append('mt') @@ -62,7 +64,7 @@ class TranslationBaseModelAdmin(BaseModelAdmin): # widget. css_classes.append('mt-default') if (orig_formfield.required or self._orig_was_required.get( - '%s.%s' % (db_field.model._meta, orig_fieldname))): + '%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 @@ -76,8 +78,8 @@ class TranslationBaseModelAdmin(BaseModelAdmin): exclude = tuple() if exclude: exclude_new = tuple(exclude) - return exclude_new + tuple(self.trans_opts.fields) - return tuple(self.trans_opts.fields) + return exclude_new + tuple(self.trans_opts.fields.keys()) + return tuple(self.trans_opts.fields.keys()) def replace_orig_field(self, option): """ @@ -87,9 +89,9 @@ class TranslationBaseModelAdmin(BaseModelAdmin): Returns a new list with replaced fields. If `option` contains no registered fields, it is returned unmodified. - >>> print self.trans_opts.fields - ('title',) - >>> get_translation_fields(self.trans_opts.fields[0]) + >>> 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'] @@ -100,10 +102,6 @@ class TranslationBaseModelAdmin(BaseModelAdmin): 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) - >>> print self.trans_opts.fields - (('title', 'url'), 'email') - >>> get_translation_fields(self.trans_opts.fields[0]) - ['title_de', 'title_en', 'url_de', 'url_en', 'email_de', 'email_en'] >>> self.replace_orig_field((('title', 'url'), 'email', 'text')) ['title_de', 'title_en', 'url_de', 'url_en', 'email_de', 'email_en', 'text'] """ @@ -187,9 +185,9 @@ class TranslationBaseModelAdmin(BaseModelAdmin): if exclude_languages: excl_languages = exclude_languages exclude = [] - for orig_fieldname, translation_fields in self.trans_opts.localized_fieldnames.iteritems(): + for orig_fieldname, translation_fields in self.trans_opts.fields.iteritems(): for tfield in translation_fields: - language = tfield.split('_')[-1] + language = tfield.name.split('_')[-1] if language in excl_languages and tfield not in exclude: exclude.append(tfield) return tuple(exclude) diff --git a/modeltranslation/management/commands/sync_translation_fields.py b/modeltranslation/management/commands/sync_translation_fields.py index b993817..f1b0f13 100644 --- a/modeltranslation/management/commands/sync_translation_fields.py +++ b/modeltranslation/management/commands/sync_translation_fields.py @@ -10,12 +10,11 @@ You will need to execute this command in two cases: Credits: Heavily inspired by django-transmeta's sync_transmeta_db command. """ from django.conf import settings -from django.core.management.base import BaseCommand +from django.core.management.base import NoArgsCommand from django.core.management.color import no_style from django.db import connection, transaction -from django.db.models import get_models -from modeltranslation.translator import translator, NotRegistered +from modeltranslation.translator import translator from modeltranslation.utils import build_localized_fieldname @@ -41,46 +40,38 @@ def print_missing_langs(missing_langs, field_name, model_name): field_name, model_name, ", ".join(missing_langs)) -class Command(BaseCommand): - help = 'Detect new translatable fields or new available languages and sync database structure' +class Command(NoArgsCommand): + help = ('Detect new translatable fields or new available languages and' + ' sync database structure. Does not remove columns of removed' + ' languages or undeclared fields.') - def handle(self, *args, **options): + def handle_noargs(self, **options): """ Command execution. """ self.cursor = connection.cursor() self.introspection = connection.introspection - all_models = get_models() found_missing_fields = False - for model in all_models: - try: - options = translator.get_options_for_model(model) - # Options returns full-wide spectrum of localized fields but - # we only want to synchronize the local fields attached to the - # model. - local_field_names = [field.name for field in model._meta.local_fields] - translatable_fields = [field for field in options.localized_fieldnames - if field in local_field_names] - model_full_name = '%s.%s' % (model._meta.app_label, model._meta.module_name) - db_table = model._meta.db_table - for field_name in translatable_fields: - missing_langs = list( - self.get_missing_languages(field_name, db_table)) - if missing_langs: - found_missing_fields = True - print_missing_langs(missing_langs, field_name, model_full_name) - sql_sentences = self.get_sync_sql(field_name, missing_langs, model) - execute_sql = ask_for_confirmation(sql_sentences, model_full_name) - if execute_sql: - print 'Executing SQL...', - for sentence in sql_sentences: - self.cursor.execute(sentence) - print 'Done' - else: - print 'SQL not executed' - except NotRegistered: - pass + models = translator.get_registered_models(abstract=False) + for model in models: + db_table = model._meta.db_table + model_full_name = '%s.%s' % (model._meta.app_label, model._meta.module_name) + opts = translator.get_options_for_model(model) + for field_name in opts.local_fields.iterkeys(): + missing_langs = list(self.get_missing_languages(field_name, db_table)) + if missing_langs: + found_missing_fields = True + print_missing_langs(missing_langs, field_name, model_full_name) + sql_sentences = self.get_sync_sql(field_name, missing_langs, model) + execute_sql = ask_for_confirmation(sql_sentences, model_full_name) + if execute_sql: + print 'Executing SQL...', + for sentence in sql_sentences: + self.cursor.execute(sentence) + print 'Done' + else: + print 'SQL not executed' transaction.commit_unless_managed() diff --git a/modeltranslation/management/commands/update_translation_fields.py b/modeltranslation/management/commands/update_translation_fields.py index 55601c2..8911292 100644 --- a/modeltranslation/management/commands/update_translation_fields.py +++ b/modeltranslation/management/commands/update_translation_fields.py @@ -8,25 +8,26 @@ from modeltranslation.utils import build_localized_fieldname class Command(NoArgsCommand): - help = ('Updates the default translation fields of all or the specified ' - 'translated application using the value of the original field.') + help = ('Updates empty values of default translation fields using' + ' values from original fields (in all translated models).') - def handle(self, **options): + def handle_noargs(self, **options): verbosity = int(options['verbosity']) if verbosity > 0: self.stdout.write("Using default language: %s\n" % DEFAULT_LANGUAGE) - for model, trans_opts in translator._registry.items(): - if model._meta.abstract: - continue + models = translator.get_registered_models(abstract=False) + for model in models: if verbosity > 0: self.stdout.write("Updating data of model '%s'\n" % model) - for fieldname in trans_opts.fields: - def_lang_fieldname = build_localized_fieldname(fieldname, DEFAULT_LANGUAGE) + opts = translator.get_options_for_model(model) + for field_name in opts.fields.iterkeys(): + def_lang_fieldname = build_localized_fieldname(field_name, DEFAULT_LANGUAGE) # We'll only update fields which do not have an existing value q = Q(**{def_lang_fieldname: None}) - field = model._meta.get_field(fieldname) + field = model._meta.get_field(field_name) if field.empty_strings_allowed: q |= Q(**{def_lang_fieldname: ""}) - model.objects.filter(q).rewrite(False).update(**{def_lang_fieldname: F(fieldname)}) + model.objects.filter(q).rewrite(False).update( + **{def_lang_fieldname: F(field_name)}) diff --git a/modeltranslation/manager.py b/modeltranslation/manager.py index da69e12..871e8e8 100644 --- a/modeltranslation/manager.py +++ b/modeltranslation/manager.py @@ -14,18 +14,12 @@ from modeltranslation import settings from modeltranslation.utils import build_localized_fieldname, get_language -_registry = {} - - def get_translatable_fields_for_model(model): - from modeltranslation import translator - if model not in _registry: - try: - _registry[model] = dict( - translator.translator.get_options_for_model(model).localized_fieldnames) - except translator.NotRegistered: - _registry[model] = None - return _registry[model] + from modeltranslation.translator import NotRegistered, translator + try: + return translator.get_options_for_model(model).fields + except NotRegistered: + return None def rewrite_lookup_key(model, lookup_key): @@ -66,7 +60,7 @@ def rewrite_order_lookup_key(model, lookup_key): def get_fields_to_translatable_models(model): from modeltranslation.translator import translator results = [] - for field_name in translator.get_options_for_model(model).localized_fieldnames.keys(): + for field_name in translator.get_options_for_model(model).fields.keys(): field_object, modelclass, direct, m2m = model._meta.get_field_by_name(field_name) if direct and isinstance(field_object, RelatedField): if get_translatable_fields_for_model(field_object.related.parent_model) is not None: @@ -188,8 +182,8 @@ class MultilingualQuerySet(models.query.QuerySet): for key, val in kwargs.items(): if key in translatable_fields: # Try to add value in every language - for new_key in translatable_fields[key]: - kwargs.setdefault(new_key, val) + for translation_field in translatable_fields[key]: + kwargs.setdefault(translation_field.name, val) # If not use populate feature, then normal rewriting will occur at model's __init__ # That's why it is not performed here - no reason to rewrite twice. return super(MultilingualQuerySet, self).create(**kwargs) diff --git a/modeltranslation/models.py b/modeltranslation/models.py index 7e91c91..72c5e9c 100644 --- a/modeltranslation/models.py +++ b/modeltranslation/models.py @@ -39,14 +39,15 @@ def autodiscover(): import_module(module) # In debug mode, print a list of registered models and pid to stdout. - # Note: Differing model order is fine, _registry is just a dict and we - # don't rely on a particular order. + # Note: Differing model order is fine, we don't rely on a particular + # order, as far as base classes are registered before subclasses. if DEBUG: try: if sys.argv[1] in ('runserver', 'runserver_plus'): - translated_model_names = ', '.join(t.__name__ for t in translator._registry.keys()) - print('modeltranslation: Registered %d models for translation (%s) [pid:%d].' % ( - len(translator._registry), translated_model_names, os.getpid())) + models = translator.get_registered_models() + names = ', '.join(m.__name__ for m in models) + print('modeltranslation: Registered %d models for translation' + ' (%s) [pid: %d].' % (len(models), names, os.getpid())) except IndexError: pass diff --git a/modeltranslation/tests/__init__.py b/modeltranslation/tests/__init__.py index d31d334..b44cf6c 100644 --- a/modeltranslation/tests/__init__.py +++ b/modeltranslation/tests/__init__.py @@ -33,7 +33,7 @@ from modeltranslation.tests.translation import (FallbackModel2TranslationOptions FieldInheritanceCTranslationOptions, FieldInheritanceETranslationOptions) from modeltranslation.tests.test_settings import TEST_SETTINGS -from modeltranslation.utils import build_css_class +from modeltranslation.utils import build_css_class, build_localized_fieldname try: from django.test.utils import override_settings @@ -227,16 +227,24 @@ class ModeltranslationTest(ModeltranslationTestBase): self.failUnless(translator.translator) # Check that all models are registered for translation - self.failUnlessEqual(len(translator.translator._registry), 13) + self.assertEqual(len(translator.translator.get_registered_models()), 19) # Try to unregister a model that is not registered self.assertRaises(translator.NotRegistered, - translator.translator.unregister, User) + translator.translator.unregister, models.BasePage) # Try to get options for a model that is not registered self.assertRaises(translator.NotRegistered, translator.translator.get_options_for_model, User) + # Ensure that a base can't be registered after a subclass. + self.assertRaises(translator.DescendantRegistered, + translator.translator.register, models.BasePage) + + # Or unregistered before it. + self.assertRaises(translator.DescendantRegistered, + translator.translator.unregister, models.Slugged) + def test_fields(self): field_names = dir(models.TestModel()) self.failUnless('id' in field_names) @@ -400,11 +408,11 @@ class ModeltranslationTest(ModeltranslationTestBase): def _test_constructor(self, keywords): n = models.TestModel(**keywords) m = models.TestModel.objects.create(**keywords) - fields = translator.translator.get_options_for_model(models.TestModel).localized_fieldnames - for base_field, trans_fields in fields.iteritems(): + opts = translator.translator.get_options_for_model(models.TestModel) + for base_field, trans_fields in opts.fields.iteritems(): self._compare_instances(n, m, base_field) for lang_field in trans_fields: - self._compare_instances(n, m, lang_field) + self._compare_instances(n, m, lang_field.name) def test_constructor(self): """ @@ -1213,6 +1221,45 @@ class ModelInheritanceTest(ModeltranslationTestBase): self.failUnless('titleb_en' in field_names_d) self.failUnless('titled' in field_names_d) + def test_inheritance(self): + def assertLocalFields(model, local_fields): + # Proper fields are inherited. + opts = translator.translator.get_options_for_model(model) + self.assertEqual(set(opts.local_fields.keys()), set(local_fields)) + # Local translation fields are created on the model. + model_local_fields = [f.name for f in model._meta.local_fields] + for field in local_fields: + for lang in mt_settings.AVAILABLE_LANGUAGES: + translation_field = build_localized_fieldname(field, lang) + self.assertTrue(translation_field in model_local_fields) + + def assertFields(model, fields): + # The given fields are inherited. + opts = translator.translator.get_options_for_model(model) + self.assertEqual(set(opts.fields.keys()), set(fields)) + # Inherited translation fields are available on the model. + model_fields = model._meta.get_all_field_names() + for field in fields: + for lang in mt_settings.AVAILABLE_LANGUAGES: + translation_field = build_localized_fieldname(field, lang) + self.assertTrue(translation_field in model_fields) + + # Translation fields can be declared on abstract classes. + assertLocalFields(models.Slugged, ('slug',)) + assertLocalFields(models.MetaData, ('keywords',)) + assertLocalFields(models.RichText, ('content',)) + # Local fields are inherited from abstract superclasses. + assertLocalFields(models.Displayable, ('slug', 'keywords',)) + assertLocalFields(models.Page, ('slug', 'keywords', 'title',)) + # But not from concrete superclasses. + assertLocalFields(models.RichTextPage, ('content',)) + + # Fields inherited from concrete models are also available. + assertFields(models.Slugged, ('slug',)) + assertFields(models.Page, ('slug', 'keywords', 'title',)) + assertFields(models.RichTextPage, ('slug', 'keywords', 'title', + 'content',)) + class ModelInheritanceFieldAggregationTest(ModeltranslationTestBase): """ diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index 939bc9d..2db3e50 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -87,6 +87,47 @@ class AbstractModelB(AbstractModelA): titleb = models.CharField(ugettext_lazy('title b'), max_length=255) +########## Fields inheritance testing + +class Slugged(models.Model): + slug = models.CharField(max_length=255) + + class Meta: + abstract = True + + +class MetaData(models.Model): + keywords = models.CharField(max_length=255) + + class Meta: + abstract = True + + +class Displayable(Slugged, MetaData): + class Meta: + abstract = True + + +class BasePage(Displayable): + class Meta: + abstract = True + + +class Page(BasePage): + title = models.CharField(max_length=255) + + +class RichText(models.Model): + content = models.CharField(max_length=255) + + class Meta: + abstract = True + + +class RichTextPage(Page, RichText): + pass + + ########## Admin testing class DataModel(models.Model): diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index 583532c..e095479 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -5,6 +5,7 @@ from modeltranslation.translator import translator, TranslationOptions from modeltranslation.tests.models import ( TestModel, FallbackModel, FallbackModel2, FileFieldsModel, OtherFieldsModel, AbstractModelA, AbstractModelB, + Slugged, MetaData, Displayable, Page, RichText, RichTextPage, MultitableModelA, MultitableBModelA, MultitableModelC, ManagerTestModel, CustomManagerTestModel, CustomManager2TestModel) @@ -75,6 +76,33 @@ class AbstractModelBTranslationOptions(TranslationOptions): translator.register(AbstractModelB, AbstractModelBTranslationOptions) +########## Fields inheritance testing + +class SluggedTranslationOptions(TranslationOptions): + fields = ('slug',) + + +class MetaDataTranslationOptions(TranslationOptions): + fields = ('keywords',) + + +class RichTextTranslationOptions(TranslationOptions): + fields = ('content',) + + +class PageTranslationOptions(TranslationOptions): + fields = ('title',) + + +# BasePage left unregistered intentionally. +translator.register(Slugged, SluggedTranslationOptions) +translator.register(MetaData, MetaDataTranslationOptions) +translator.register(RichText, RichTextTranslationOptions) +translator.register(Displayable) +translator.register(Page, PageTranslationOptions) +translator.register(RichTextPage) + + ########## Manager testing class ManagerTestModelTranslationOptions(TranslationOptions): diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index 476dec6..4f4f809 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -5,7 +5,7 @@ from django.db.models.base import ModelBase from modeltranslation.fields import TranslationFieldDescriptor, create_translation_field from modeltranslation.manager import MultilingualManager, rewrite_lookup_key -from modeltranslation.utils import build_localized_fieldname, unique +from modeltranslation.utils import build_localized_fieldname class AlreadyRegistered(Exception): @@ -16,51 +16,79 @@ class NotRegistered(Exception): pass +class DescendantRegistered(Exception): + pass + + class FieldsAggregationMetaClass(type): """ - Metaclass to handle inheritance of fields between classes. + Metaclass to handle custom inheritance of fields between classes. """ def __new__(cls, name, bases, attrs): - parents = [b for b in bases if isinstance(b, FieldsAggregationMetaClass)] - if not parents: - return super(FieldsAggregationMetaClass, cls).__new__(cls, name, bases, attrs) - attrs['fields'] = tuple(attrs.get('fields', ())) - for base in parents: - attrs['fields'] += tuple(base.fields) - attrs['fields'] = tuple(unique(attrs['fields'])) + attrs['fields'] = set(attrs.get('fields', ())) + for base in bases: + if isinstance(base, FieldsAggregationMetaClass): + attrs['fields'].update(base.fields) + attrs['fields'] = tuple(attrs['fields']) return super(FieldsAggregationMetaClass, cls).__new__(cls, name, bases, attrs) class TranslationOptions(object): """ - The TranslationOptions object is used to specify the fields to translate. + Translatable fields are declared by registering a model using + ``TranslationOptions`` class with appropriate ``fields`` attribute. + Model-specific fallback values and languages can also be given as class + attributes. - The options are registered in combination with a model class at the - ``modeltranslation.translator.translator`` instance. - - It caches the content type of the translated model for faster lookup later - on. + Options instances hold info about translatable fields for a model and its + superclasses. The ``local_fields`` and ``fields`` attributes are mappings + from fields to sets of their translation fields; ``local_fields`` contains + only those fields that are handled in the model's database table (those + inherited from abstract superclasses, unless there is a concrete superclass + in between in the inheritance chain), while ``fields`` also includes fields + inherited from concrete supermodels (giving all translated fields available + on a model). """ __metaclass__ = FieldsAggregationMetaClass - fields = () - def __init__(self, *args, **kwargs): - self.localized_fieldnames = [] + def __init__(self, model): + """ + Create fields dicts without any translation fields. + """ + self.model = model + self.registered = False + self.local_fields = dict((f, set()) for f in self.fields) + self.fields = dict((f, set()) for f in self.fields) + + def update(self, other): + """ + Update with options from a superclass. + """ + if other.model._meta.abstract: + self.local_fields.update(other.local_fields) + self.fields.update(other.fields) + + def add_translation_field(self, field, translation_field): + """ + Add a new translation field to both fields dicts. + """ + self.local_fields[field].add(translation_field) + self.fields[field].add(translation_field) + + def __str__(self): + local = tuple(self.local_fields.keys()) + inherited = tuple(set(self.fields.keys()) - set(local)) + return '%s: %s + %s' % (self.__class__.__name__, local, inherited) -def add_localized_fields(model): +def add_translation_fields(model, opts): """ Monkey patches the original model class to provide additional fields for - every language. Only do that for fields which are defined in the - translation options of the model. + every language. - Returns a dict mapping the original fieldname to a list containing the - names of the localized fields created for the original field. + Adds newly created translation fields to the given translation options. """ - localized_fields = dict() - translation_opts = translator.get_options_for_model(model) - for field_name in translation_opts.fields: - localized_fields[field_name] = list() + for field_name in opts.local_fields.iterkeys(): for l in settings.LANGUAGES: # Create a dynamic translation field translation_field = create_translation_field( @@ -75,8 +103,7 @@ def add_localized_fields(model): # This approach implements the translation fields as full valid # django model fields and therefore adds them via add_to_class model.add_to_class(localized_field_name, translation_field) - localized_fields[field_name].append(localized_field_name) - return localized_fields + opts.add_translation_field(field_name, translation_field) def add_manager(model): @@ -127,7 +154,7 @@ def patch_constructor(model): #def translated_model_initializing(sender, args, kwargs, **signal_kwargs): #print "translated_model_initializing", sender, args, kwargs #trans_opts = translator.get_options_for_model(sender) - #for field_name in trans_opts.fields: + #for field_name in trans_opts.local_fields: #setattr(sender, field_name, TranslationFieldDescriptor(field_name)) @@ -153,51 +180,47 @@ class Translator(object): registered with the Translator using the register() method. """ def __init__(self): - # model_class class -> translation_opts instance + # All seen models (model class -> ``TranslationOptions`` instance). self._registry = {} - def register(self, model_or_iterable, translation_opts, **options): + def register(self, model_or_iterable, opts_class=None, **options): """ Registers the given model(s) with the given translation options. The model(s) should be Model classes, not instances. - If a model is already registered for translation, this will raise - AlreadyRegistered. + Fields declared for translation on a base class are inherited by + subclasses. If the model or one of its subclasses is already + registered for translation, this will raise an exception. """ if isinstance(model_or_iterable, ModelBase): model_or_iterable = [model_or_iterable] for model in model_or_iterable: + # Ensure that a base is not registered after a subclass (_registry + # is closed with respect to taking bases, so we can just check if + # we've seen the model). if model in self._registry: - raise AlreadyRegistered( - 'The model %s is already registered for translation' % model.__name__) + if self._registry[model].registered: + raise AlreadyRegistered( + 'Model "%s" is already registered for translation' % + model.__name__) + else: + descendants = [d.__name__ for d in self._registry.keys() + if issubclass(d, model) and d != model] + raise DescendantRegistered( + 'Model "%s" cannot be registered after its subclass' + ' "%s"' % (model.__name__, descendants[0])) - # If we got **options then dynamically construct a subclass of - # translation_opts with those **options. - if options: - # For reasons I don't quite understand, without a __module__ - # the created class appears to "live" in the wrong place, - # which causes issues later on. - options['__module__'] = __name__ - translation_opts = type( - "%sTranslationOptions" % model.__name__, (translation_opts,), options) + # Find inherited fields and create options instance for the model. + opts = self._get_options_for_model(model, opts_class, **options) - # Store the translation class associated to the model - self._registry[model] = translation_opts + # Mark the object explicitly as registered -- registry caches + # options of all models, registered or not. + opts.registered = True - # Add the localized fields to the model and store the names of - # these fields in the model's translation options for faster lookup - # later on. - translation_opts.localized_fieldnames = add_localized_fields(model) - - # Create a reverse dict mapping the localized_fieldnames to the - # original fieldname - rev_dict = dict() - for orig_name, loc_names in translation_opts.localized_fieldnames.items(): - for ln in loc_names: - rev_dict[ln] = orig_name - translation_opts.localized_fieldnames_rev = rev_dict + # Add translation fields to the model. + add_translation_fields(model, opts) # Delete all fields cache for related model (parent and children) for related_obj in model._meta.get_all_related_objects(): @@ -210,9 +233,9 @@ class Translator(object): patch_constructor(model) # Substitute original field with descriptor - model_fallback_values = getattr(translation_opts, 'fallback_values', None) - model_fallback_languages = getattr(translation_opts, 'fallback_languages', None) - for field_name in translation_opts.fields: + model_fallback_values = getattr(opts, 'fallback_values', None) + model_fallback_languages = getattr(opts, 'fallback_languages', None) + for field_name in opts.local_fields.iterkeys(): if model_fallback_values is None: field_fallback_value = None elif isinstance(model_fallback_values, dict): @@ -232,49 +255,72 @@ class Translator(object): """ Unregisters the given model(s). - If a model isn't already registered, this will raise NotRegistered. + If a model isn't registered, this will raise NotRegistered. If one of + its subclasses is registered, DescendantRegistered will be raised. """ if isinstance(model_or_iterable, ModelBase): model_or_iterable = [model_or_iterable] for model in model_or_iterable: - if model not in self._registry: - raise NotRegistered( - 'The model "%s" is not registered for translation' % model.__name__) - del self._registry[model] + # Check if the model is actually registered (``get_options_for_model`` + # throws an exception if it's not). + self.get_options_for_model(model) + # Invalidate all submodels options and forget about + # the model itself. + for desc, desc_opts in self._registry.items(): + if not issubclass(desc, model): + continue + if model != desc and desc_opts.registered: + # Allowing to unregister a base would necessitate + # repatching all submodels. + raise DescendantRegistered( + 'You need to unregister descendant "%s" before' + ' unregistering its base "%s"' % + (desc.__name__, model.__name__)) + del self._registry[desc] + + def get_registered_models(self, abstract=True): + """ + Returns a list of all registered models, or just concrete + registered models. + """ + return [model for (model, opts) in self._registry.items() + if opts.registered and (not model._meta.abstract or abstract)] + + def _get_options_for_model(self, model, opts_class=None, **options): + """ + Returns an instance of translation options with translated fields + defined for the ``model`` and inherited from superclasses. + """ + if model not in self._registry: + # Create a new type for backwards compatibility. + opts = type("%sTranslationOptions" % model.__name__, + (opts_class or TranslationOptions,), options)(model) + + # Fields for translation may be inherited from abstract + # superclasses, so we need to look at all parents. + for base in model.__bases__: + if not hasattr(base, '_meta'): + # Things without _meta aren't functional models, so they're + # uninteresting parents. + continue + opts.update(self._get_options_for_model(base)) + + # Cache options for all models -- we may want to compute options + # of registered subclasses of unregistered models. + self._registry[model] = opts + + return self._registry[model] def get_options_for_model(self, model): """ - Returns the translation options for the given ``model``. If the - ``model`` is not registered a ``NotRegistered`` exception is raised. + Thin wrapper around ``_get_options_for_model`` to preserve the + semantic of throwing exception for models not directly registered. """ - try: - return self._registry[model] - except KeyError: - # Try to find a localized parent model and build a dedicated - # translation options class with the parent info. - # Useful when a ModelB inherits from ModelA and only ModelA fields - # are localized. No need to register ModelB. - fields = set() - localized_fieldnames = {} - localized_fieldnames_rev = {} - for parent in model._meta.parents.keys(): - if parent in self._registry: - trans_opts = self._registry[parent] - fields.update(trans_opts.fields) - localized_fieldnames.update(trans_opts.localized_fieldnames) - localized_fieldnames_rev.update(trans_opts.localized_fieldnames_rev) - if fields and localized_fieldnames and localized_fieldnames_rev: - options = { - '__module__': __name__, - 'fields': tuple(fields), - 'localized_fieldnames': localized_fieldnames, - 'localized_fieldnames_rev': localized_fieldnames_rev - } - translation_opts = type( - "%sTranslation" % model.__name__, (TranslationOptions,), options) - # delete_cache_fields(model) - return translation_opts - raise NotRegistered('The model "%s" is not registered for translation' % model.__name__) + opts = self._get_options_for_model(model) + if not opts.registered: + raise NotRegistered('The model "%s" is not registered for ' + 'translation' % model.__name__) + return opts # This global object represents the singleton translator object