diff --git a/AUTHORS.txt b/AUTHORS.txt new file mode 100644 index 0000000..92d3312 --- /dev/null +++ b/AUTHORS.txt @@ -0,0 +1,2 @@ +The author of django-modeltranslation is Peter Eschler +http://pyjax.net diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..e69de29 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..44311f2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,10 @@ +Copyright (c) 2009, Peter Eschler +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + * Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/docs/modeltranslation/modeltranslation.txt b/docs/modeltranslation/modeltranslation.txt new file mode 100644 index 0000000..94fd9ef --- /dev/null +++ b/docs/modeltranslation/modeltranslation.txt @@ -0,0 +1,436 @@ +.. _ref-topics-modeltranslation: + +=================== + Model translation +=================== + +.. admonition:: About this document + + This document provides an introduction to the modeltranslation application. + +.. currentmodule:: modeltranslation.models +.. moduleauthor:: P. Eschler + +The modeltranslation application can be used to translate existing models to +an arbitrary number of languages without having to change the original model +classes. It uses a registration approach (comparable to Django's +admin app) to be able to add translations to existing or new projects and is +fully integrated into the Django admin backend. + +.. contents:: + +Features +======== + +- Unlimited number of target languages +- Add translations without changing existing models +- Django admin support +- ?Supports inherited models + +Installation +============ + +To install the application please follow these steps. Each step is described +in detail in the following sections:: + +1. Add the ``modeltranslation`` app to the ``INSTALLED_APPS`` variable of your + project's ``settings.py``. + +2. Configure your languages in the ``settings.py``. + +3. Create a ``translation.py`` in your project directory and register + ``TranslationOptions`` for every model you want to translate. + +4. Sync the database using ``manage.py syncdb`` (note that this only applies + if the models registered in the ``translations.py`` did not have been + synced to the database before. If they did - read further down what to do + in that case. + +Configure the project's ``settings.py`` +--------------------------------------- +The following variables have to be added to or edited in the project's +``settings.py``: + +**settings.INSTALLED_APPS** + +Make sure that the ``modeltranslation`` app is listed in your +``INSTALLED_APPS`` variable:: + + INSTALLED_APPS = ( + ... + 'modeltranslation', + .... + ) + +Also make sure that the app can be found on a path contained in your +``PYTHONPATH`` environment variable. + +**settings.LANGUAGES** + +The LANGUAGES variable must contain all languages used for translation. The +first language is treated as the *default language*. + +The modeltranslation application uses the list of languages to add localized +fields to the models registered for translation. To use the languages ``de`` and +``en`` in your project, set the settings.LANGUAGES variable like this (where +``de`` is the default language):: + + gettext = lambda s: s + LANGUAGES = ( + ('de', gettext('German')), + ('en', gettext('English')), + ) + +Note that the ``gettext`` lambda function is not a feature of the +modeltranslation app, but rather required for Django to be able to +(statically) translate the verbose names of the languages using the standard +``i18n`` solution. + + +Registering models and their fields for translation +--------------------------------------------------- +Registering models and their fields for translation requires the following +steps: + +1. Create a ``translation.py`` in your project directory. +2. Create a translation option class for every model to translate. +3. Register the model and the translation option class at the + ``modeltranslation.translator.translator`` + +The ``modeltranslation`` application reads the ``translation.py`` file in your +project directory thereby triggering the registration of the translation +options found in the file. + +A translation option is a class that declares which fields of a model to +translate. The class must derive from ``modeltranslation.ModelTranslation`` +and it must provide a ``fields`` attribute storing the list of fieldnames. The +option class must be registered with the +``modeltranslation.translator.translator`` instance. + +.. note: In contrast to the Django admin application which looks for + ``admin.py`` files in the project **and** application directories, + the modeltranslation app looks only for one ``translation.py`` file in + the project directory. + +To illustrate this let's have a look at a simple example using a ``News`` model. +The news in this example only contains a ``title`` and a ``text`` field. Instead +of a news, this could be any Django model class:: + + class News(models.Model): + title = models.CharField(max_length=255) + text = models.TextField() + +In order to tell the ``modeltranslation`` app to translate the ``title`` and +``text`` field, create a ``translation.py`` file in your project directory and +add the following:: + + from modeltranslation.translator import translator, TranslationOptions + from some.news.models import News + + class NewsTranslationOptions(TranslationOptions): + fields = ('title', 'text',) + + translator.register(News, NewsTranslationOptions) + +Note that this does not require to change the ``News`` model in any way, it's +only imported. The ``NewsTranslationOptions`` derives from +``TranslationOptions`` and provides the ``fields`` attribute. Finally the model +and it's translation options are registered at the ``translator`` object. + +Changes automatically applied to the model class +------------------------------------------------ +At this point you are mostly done and the model classes registered for +translation will have been added some auto-magical fields. An SQL dump of the +News app example will look like this:: + + $ ./manage.py sqlall news + BEGIN; + CREATE TABLE `news_news` ( + `id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, + `title` varchar(255) NOT NULL, + `title_de` varchar(255) NULL, + `title_en` varchar(255) NULL, + `text` longtext NULL, + `text_de` longtext NULL, + `text_en` longtext NULL, + ) + ; + ALTER TABLE `news_news` ADD CONSTRAINT page_id_refs_id_3edd1f0d FOREIGN KEY (`page_id`) REFERENCES `page_page` (`id`); + CREATE INDEX `news_news_page_id` ON `news_news` (`page_id`); + COMMIT; + +Note the ``title_de``, ``title_en``, ``text_de`` and ``text_en`` fields which +are not declared in the original News model class but rather have been added by +the modeltranslation app. These are called *translation fields*. There will be +one for every language in your project's ``settings.py``. + +The name of these additional fields is build using the original name of the +translated field and appending one of the language identifiers found in the +``settings.LANGUAGES``. + +As these fields are added to the registered model class as fully valid Django +model fields, they will appear in the db schema for the model although it has +not been specified on the model explicitly. In case you are translating an +existing project and your models have already been synced to the database you +will need to alter the tables in your database and add these additional +translation fields. + +.. _set_language: http://docs.djangoproject.com/en/dev/topics/i18n/#the-set-language-redirect-view + +If you are starting a fresh project and have considered your translation needs +in the beginning then simply sync your database and you are ready to use +the translated models. + +If you have added the translations to an existing project you have to edit your +database schema. Note that all added fields are declared ``null=True`` not +matter if the original field is required. In other words - all translations are +optional. To populate the default translation fields added by the +``modeltranslation`` application you can use the ``update_translation_fields`` +command below. See the `The update_translation_fields command` section for more +infos on this. + + +The ``update_translation_fields`` command +----------------------------------------- +In case the modeltranslation app was installed on an existing project and you +have specified to translate fields of models which are already synced to the +database, you have to update your database schema manually. + +Unfortunately the translation fields on the model will be empty then, and +your templates will show the translated value of the fields (see Rule 1 below) +which will be empty in this case. To correctly initialize the default +translation field you can use the ``update_translation_fields`` command:: + + manage.py update_translation_fields + +Taken the News example from above this command will copy the value from the +news object's ``title`` field to the default translation field ``title_de``. +It only does so if the default translation field is empty otherwise nothing +is copied. + +.. note: The command will examine your ``settings.LANGUAGES`` variable at the + first language declared there will be used as the default language. + +By default all translated models (as specified in the project's +``translation.py`` will be populated with initial data. If you only want to +populate a specific model you can use the command with an additional app +name (the same as on e.g. the ``sqlall`` command):: + + manage.py update_translation_fields news + + +Accessing translated and translation fields +=========================================== +The ``modeltranslation`` app changes the behaviour of the translated fields. To +explain this consider the News example again. The original ``News`` model +looked like this:: + + class News(models.Model): + title = models.CharField(max_length=255) + text = models.TextField() + +Now that it is registered with the ``modeltranslation`` app the model looks +like this - note the additional fields automatically added by the app:: + + class News(models.Model): + title = models.CharField(max_length=255) # original/translated field + title_de = models.CharField(null=True, blank=True, max_length=255) # default translation field + title_en = models.CharField(null=True, blank=True, max_length=255) # translation field + text = models.TextField() # original/translated field + text_de = models.TextField(null=True, blank=True) # default translation field + text_en = models.TextField(null=True, blank=True) # translation field + +The example above assumes that the default language is ``de``, therefore the +``title_de`` and ``text_de`` fields are marked as the *default translation +fields*. If the default language is ``en``, the ``title_en`` and ``text_en`` +fields would be the *default translation fields*. + +Rules for translated field access +--------------------------------- +So now when it comes to setting and getting the value of the original and the +translation fields the following rules apply: + +**Rule 1** + +Reading the value from the original field returns the value translated to the +*current language*. + +**Rule 2** + +Assigning a value to the original field also updates the value in the +associated default translation field. + +**Rule 3** + +Assigning a value to the default translation field also updates the original +field - note that the value of the original field will not be updated until the +model instance is saved. + +**Rule 4** + +If both fields - the original and the default translation field - are updated +at the same time, the default translation field wins. + + +Examples for translated field access +------------------------------------ +Because the whole point of using the ``modeltranslation`` app is translating +dynamic content, the fields marked for translation are somehow special when it +comes to accessing them. The value returned by a translated field is depending +on the current language setting. "Language setting" is referring to the Django +`set_language`_ view and the corresponding ``get_lang`` function. + +Assuming the current language is ``de`` in the News example from above, the +translated ``title`` field will return the value from the ``title_de`` field:: + + # Assuming the current language is "de" + n = News.objects.all()[0] + t = n.title # returns german translation + + # Assuming the current language is "en" + t = n.title # returns english translation + +This feature is implemented using Python descriptors making it happen without +the need to touch the original model classes in any way. The descriptor uses +the ``django.utils.i18n.get_language`` function to determine the current +language. + + +Django admin backend integration +================================ +In order to be able to edit the translations via the admin backend you need to +register a special admin class for the translated models. The admin class must +derive from ``modeltranslation.admin.TranslationAdmin`` which does some funky +patching on all your models registered for translation:: + + from django.contrib import admin + from modeltranslation.admin import TranslationAdmin + + class NewsAdmin(TranslationAdmin): + list_display = ('title',) + + admin.site.register(News, NewsAdmin) + +Tweaks applied to the admin +--------------------------- + +The ``TranslationAdmin`` class does only implement one special method which is +``def formfield_for_dbfield(self, db_field, **kwargs)``. This method does the +following: + +1. Removes the original field from every admin form by setting it + ``editable=False``. +2. Copies the widget of the original field to each of it's translation fields. +3. Checks if the - now removed - original field was required and if so makes the + default translation field required instead. + +TranslationAdmin in combination with other admin classes +-------------------------------------------------------- +If there already exists a custom admin class for a translated model and you +don't want or can't edit that class directly there is another solution. + +Taken the News example let's say there is a ``NewsAdmin`` class defined by the +News app itself. This app is not yours or you don't want to touch it at all, but +it has this nice admin class:: + + class NewsAdmin(model.Admin): + def formfield_for_dbfield(self, db_field, **kwargs): + # does some funky stuff with the formfield here + +So a first attempt might be to create your own admin class which subclasses +``NewsAdmin`` and ``TranslationAdmin`` to combine stuff like so:: + + class MyTranslatedNewsAdmin(NewsAdmin, TranslationAdmin): + pass + +Unfortunately this won't work because Python can only execute one of the +``formfield_for_dbfield`` methods. Since both admin class implement this method +Python must make a decision and it chooses the first class ``NewsAdmin``. The +functionality from ``TranslationAdmin`` will not be executed and translation in +the admin will not work for this class. + +But don't panic, here's a solution:: + + class MyTranslatedNewsAdmin(NewsAdmin, TranslationAdmin): + def formfield_for_dbfield(self, db_field, **kwargs): + field = super(MyTranslatedNewsAdmin, self).formfield_for_dbfield(db_field, **kwargs) + self.patch_translation_field(db_field, field, **kwargs) + return field + +This implements the ``formfield_for_dbfield`` such that both functionalities +will be executed. The first line calls the superclass method which in this case +will be the one of ``NewsAdmin`` because it is the first class inherited from. +The ``TranslationAdmin`` capsulates all it's functionality in the +``patch_translation_field(db_field, field, **kwargs)`` method and the +``formfield_for_dbfield`` implementation of the ``TranslationAdmin`` class +simply calls it. You can copy this behaviour by calling it from a +custom admin class and that's done in the example above. After that the +``field`` is fully patched for translation and finally returned. + +Caveats +======= +Consider the following example (assuming the default lanuage is ``de``):: + + >>> n = News.objects.create(title="foo") + >>> n.title + 'foo' + >>> n.title_de + >>> + +Because the original field ``title`` was specified in the constructor it is +directly passed into the instance's ``__dict__`` and the descriptor which +normally updates the associated default translation field (``title_de``) is not +called. Therefor the call to ``n.title_de`` returns an empty value. + +Now assign the title, which triggers the descriptor and the default translation +field is updated:: + + >>> n.title = 'foo' + >>> n.title_de + 'foo' + >>> + +Related projects +================ + +`django-multilingual`_ +---------------------- + + A library providing support for multilingual content in Django models. + +It is not possible to reuse existing models without modifying them. + + +`django-multilingual-model`_ +---------------------------- +A much simpler version of the above `django-multilingual`. +It works very similiar to the `django-multilingual` approach. + +`transdb`_ +---------- + + Django's field that stores labels in more than one language in database. + +This approach uses a specialized ``Field`` class, which means one has to change +existing models. + +`i18ndynamic`_ +-------------- +This approach is not developed any more. + + +`django-pluggable-model-i18n`_ +------------------------------ + + This app utilizes a new approach to multilingual models based on the same + concept the new admin interface uses. A translation for an existing model can + be added by registering a translation class for that model. + +This is more or less what ``modeltranslation`` does, unfortunately it is far +from being finished. + +.. _django-multilingual: http://code.google.com/p/django-multilingual/ +.. _django-multilingual-model: http://code.google.com/p/django-multilingual-model/ +.. _django-transdb: http://code.google.com/p/transdb/ +.. _i18ndynamic: http://code.google.com/p/i18ndynamic/ +.. _django-pluggable-model-i18n: http://code.google.com/p/django-pluggable-model-i18n/ diff --git a/modeltranslation/__init__.py b/modeltranslation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py new file mode 100644 index 0000000..996d313 --- /dev/null +++ b/modeltranslation/admin.py @@ -0,0 +1,110 @@ +from copy import deepcopy + +from django.conf import settings +from django.contrib import admin +from django.contrib.contenttypes.models import ContentType +from django.forms import widgets +from django import forms, template +from django.forms.fields import MultiValueField +from django.shortcuts import get_object_or_404, render_to_response +from django.utils.safestring import mark_safe + +from modeltranslation.translator import translator + +class TranslationAdmin(admin.ModelAdmin): + + def patch_translation_field(self, db_field, field, **kwargs): + trans_opts = translator.get_options_for_model(self.model) + + # Hide the original field by making it non-editable. + if db_field.name in trans_opts.fields: + db_field.editable = False + # field.widget.attrs['readonly'] = "true" + + # For every localized field copy the widget from the original field + if db_field.name in trans_opts.localized_fieldnames_rev: + orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name] + orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs) + + # In case the original form field was required, make the default + # translation field required instead. + if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required: + orig_formfield.required = False + field.required = True + + field.widget = deepcopy(orig_formfield.widget) + + + def formfield_for_dbfield(self, db_field, **kwargs): + print "TranslationAdmin.formfield_for_dbfield" + # Call the baseclass function to get the formfield + field = super(TranslationAdmin, self).formfield_for_dbfield(db_field, **kwargs) + + self.patch_translation_field(db_field, field, **kwargs) + + #trans_opts = translator.get_options_for_model(self.model) + + ## Hide the original field by making it non-editable. + #if db_field.name in trans_opts.fields: + #db_field.editable = False + ## field.widget.attrs['readonly'] = "true" + + ## For every localized field copy the widget from the original field + #if db_field.name in trans_opts.localized_fieldnames_rev: + #orig_fieldname = trans_opts.localized_fieldnames_rev[db_field.name] + #orig_formfield = self.formfield_for_dbfield(self.model._meta.get_field(orig_fieldname), **kwargs) + + ## In case the original form field was required, make the default + ## translation field required instead. + #if db_field.language == settings.LANGUAGES[0][0] and orig_formfield.required: + #orig_formfield.required = False + #field.required = True + + #field.widget = deepcopy(orig_formfield.widget) + + return field + + #def save_form(self, request, form, change): + #""" + #Given a ModelForm return an unsaved instance. ``change`` is True if + #the object is being changed, and False if it's being added. + #""" + #return form.save(commit=False) + + #def save_model(self, request, obj, form, change): + #""" + #Given a model instance save it to the database. + + #Because each translated field is wrapped with a descriptor to return + #the translated fields value (determined by the current language) we + #cannot set the field directly. + #To bypass the descriptor the assignment is done using the __dict__ + #of the object. + #""" + #trans_opts = translator.get_options_for_model(self.model) + #for field_name in trans_opts.fields: + ## Bypass the descriptor applied to the original field by setting + ## it's value via the __dict__ (which doesn't call the descriptor). + #obj.__dict__[field_name] = form.cleaned_data[field_name] + + ## Call the baseclass method + #super(TranslationAdmin, self).save_model(request, obj, form, change) + + + #def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + ## Get the form from the context + #form = context['adminform'].form + + ## Get the translation options for the model of this admin view + #trans_opts = translator.get_options_for_model(self.model) + #if add: + #pass + #else: + #for fname in trans_opts.fields: + #field = form.fields[fname] + ## print "here:", form.fields[fname] + #form.initial[fname] = obj.__dict__[fname] + + #return super(TranslationAdmin, self).render_change_form(request, context, add, change, form_url, obj) + + \ No newline at end of file diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py new file mode 100644 index 0000000..9862137 --- /dev/null +++ b/modeltranslation/fields.py @@ -0,0 +1,98 @@ + +from django.conf import settings +from django.db.models.fields import Field, CharField +from django.utils.translation import get_language + +from modeltranslation.utils import build_localized_fieldname + +class TranslationField(Field): + """ + The translation field functions as a proxy to the original field which is + wrapped. + + For every field defined in the model's ``TranslationOptions`` localized + versions of that field are added to the model depending on the languages + given in ``settings.LANGUAGES``. + + If for example there is a model ``News`` with a field ``title`` which is + registered for translation and the ``settings.LANGUAGES`` contains the + ``de`` and ``en`` languages, the fields ``title_de`` and ``title_en`` will + be added to the model class. These fields are realized using this + descriptor. + + The translation field needs to know which language it contains therefore + that needs to be specified when the field is created. + """ + def __init__(self, translated_field, language, *args, **kwargs): + # Store the originally wrapped field for later + self.translated_field = translated_field + self.language = language + + # 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__) + + # Translation are always optional (for now - maybe add some parameters + # to the translation options for configuring this) + self.null = True + self.blank = True + + # Adjust the name of this field to reflect the language + self.attname = build_localized_fieldname(translated_field.name, language) + self.name = self.attname + + # Copy the verbose name and append a language suffix (will e.g. in the + # admin). This might be a proxy function so we have to check that here. + if hasattr(translated_field.verbose_name, '_proxy____unicode_cast'): + verbose_name = translated_field.verbose_name._proxy____unicode_cast() + else: + verbose_name = translated_field.verbose_name + self.verbose_name = '%s [%s]' % (verbose_name, language) + + def pre_save(self, model_instance, add): + val = super(TranslationField, self).pre_save(model_instance, add) + if get_language() == self.language and not add: + # Rule is: 3. Assigning a value to a translation field of the default language + # also updates the original field + model_instance.__dict__[self.translated_field.name] = val + #setattr(model_instance, self.attname, orig_val) + # Also return the original value + #return orig_val + return val + + #def get_attname(self): + #return self.attname + + def get_internal_type(self): + return self.translated_field.get_internal_type() + + def contribute_to_class(self, cls, name): + + super(TranslationField, self).contribute_to_class(cls, name) + + #setattr(cls, 'get_%s_display' % self.name, curry(cls._get_FIELD_display, field=self)) + +#class CurrentLanguageField(CharField): + #def __init__(self, **kwargs): + #super(CurrentLanguageField, self).__init__(null=True, max_length=5, **kwargs) + + #def contribute_to_class(self, cls, name): + #super(CurrentLanguageField, self).contribute_to_class(cls, name) + #registry = CurrentLanguageFieldRegistry() + #registry.add_field(cls, self) + + +#class CurrentLanguageFieldRegistry(object): + #_registry = {} + + #def add_field(self, model, field): + #reg = self.__class__._registry.setdefault(model, []) + #reg.append(field) + + #def get_fields(self, model): + #return self.__class__._registry.get(model, []) + + #def __contains__(self, model): + #return model in self.__class__._registry + + \ No newline at end of file diff --git a/modeltranslation/management/__init__.py b/modeltranslation/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modeltranslation/middleware.py b/modeltranslation/middleware.py new file mode 100644 index 0000000..1697d36 --- /dev/null +++ b/modeltranslation/middleware.py @@ -0,0 +1,24 @@ +# from django.db.models import signals +# from django.utils.functional import curry + +#class TranslationMiddleware(object): + #def process_request(self, request): + #if hasattr(request, 'LANGUAGE_CODE'): + #print "TranslationMiddleware: preferred lang=", request.LANGUAGE_CODE + #update_lang = curry(self.update_lang, request.LANGUAGE_CODE) + #signals.pre_save.connect(update_lang, dispatch_uid=request, weak=False) + #else: + #print "TranslationMiddleware: no lang" + #pass + + + #def update_lang(self, lang, sender, instance, **kwargs): + #registry = registration.FieldRegistry() + #if sender in registry: + #for field in registry.get_fields(sender): + #setattr(instance, field.name, lang) + + #def process_response(self, request, response): + #print "response:", dir(response) + #signals.pre_save.disconnect(dispatch_uid=request) + #return response \ No newline at end of file diff --git a/modeltranslation/models.py b/modeltranslation/models.py new file mode 100644 index 0000000..42c44ee --- /dev/null +++ b/modeltranslation/models.py @@ -0,0 +1,23 @@ + +from django.db import models + +from modeltranslation.translator import translator + +# Every model registered with the modeltranslation.translator.translator +# is patched to contain additional localized versions for every +# field specified in the model's translation options. + +# Import the project's global "translation.py" which registers model +# classes and their translation options with the translator object. +# And because it must import the model classes for the registration +# process, the models.py modules of these apps are fully imported +try: + translation_mod = __import__('translation', {}, {}, ['']) +except ImportError, exc: + print "No translation.py found in the project directory." + +# After importing all translation modules, all translation classes are +# registered with the translator. +translated_app_names = ', '.join(t.__name__ for t in translator._registry.keys()) +print "modeltranslation: registered %d applications for translation (%s)." % (len(translator._registry), + translated_app_names) diff --git a/modeltranslation/tests.py b/modeltranslation/tests.py new file mode 100644 index 0000000..e77c545 --- /dev/null +++ b/modeltranslation/tests.py @@ -0,0 +1,253 @@ + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.test import TestCase +from django.contrib.auth.models import User +from django.utils.translation import get_language +from django.utils.translation import trans_real +from django.utils.thread_support import currentThread + +from modeltranslation import translator + +class TestModel(models.Model): + title = models.CharField(max_length=255) + text = models.TextField(null=True) + +class TestTranslationOptions(translator.TranslationOptions): + fields = ('title', 'text',) + +translator.translator.register(TestModel, TestTranslationOptions) + +class ModelTranslationTest(TestCase): + "Basic tests for the modeltranslation application." + + urls = 'modeltranslation.testurls' + + def setUp(self): + trans_real.activate("de") + + def tearDown(self): + trans_real.deactivate() + + + def test_registration(self): + self.client.post('/set_language/', data={'language': 'de'}) + #self.client.session['django_language'] = 'de-de' + #self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = 'de-de' + + langs = tuple(l[0] for l in settings.LANGUAGES) + self.failUnlessEqual(2, len(langs)) + self.failUnless('de' in langs) + self.failUnless('en' in langs) + self.failUnless(translator.translator) + + # Check that only one model is registered for translation + self.failUnlessEqual(len(translator.translator._registry), 1) + + # Try to unregister a model that is not registered + self.assertRaises(translator.NotRegistered, translator.translator.unregister, User) + + # Try to get options for a model that is not registered + self.assertRaises(translator.NotRegistered, translator.translator.get_options_for_model, User) + + + def test_translated_models(self): + # First create an instance of the test model to play with + inst = TestModel.objects.create(title="Testtitle", text="Testtext") + field_names = dir(inst) + self.failUnless('id' in field_names) + self.failUnless('title' in field_names) + self.failUnless('text' in field_names) + self.failUnless('title_de' in field_names) + self.failUnless('title_en' in field_names) + self.failUnless('text_de' in field_names) + self.failUnless('text_en' in field_names) + + inst.delete() + + def test_set_translation(self): + self.failUnlessEqual(get_language(), "de") + # First create an instance of the test model to play with + title1_de = "title de" + title1_en = "title en" + title2_de = "title2 de" + inst1 = TestModel(title_en=title1_en, text="Testtext") + inst1.title = title1_de + inst2 = TestModel(title=title2_de, text="Testtext") + inst1.save() + inst2.save() + + self.failUnlessEqual(inst1.title, title1_de) + self.failUnlessEqual(inst1.title_en, title1_en) + + self.failUnlessEqual(inst2.title, title2_de) + self.failUnlessEqual(inst2.title_en, None) + + del inst1 + del inst2 + + # Check that the translation fields are correctly saved and provide the + # correct value when retrieving them again. + n = TestModel.objects.get(title=title1_de) + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + def test_titleonly(self): + title1_de = "title de" + n = TestModel.objects.create(title=title1_de) + self.failUnlessEqual(n.title, title1_de) + # Because the original field "title" was specified in the constructor + # it is directly passed into the instance's __dict__ and the descriptor + # which updates the associated default translation field is not called + # and the default translation will be None. + self.failUnlessEqual(n.title_de, None) + self.failUnlessEqual(n.title_en, None) + + # Now assign the title, that triggers the descriptor and the default + # translation field is updated + n.title = title1_de + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, None) + + def test_rule1(self): + """ + Rule 1: Reading the value from the original field returns the value in + translated to the current language. + """ + title1_de = "title de" + title1_en = "title en" + text_de = "Dies ist ein deutscher Satz" + text_en = "This is an english sentence" + + # Test 1. + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en, + text_de=text_de, text_en=text_en) + n.save() + + # language is set to "de" at this point + self.failUnlessEqual(get_language(), "de") + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + self.failUnlessEqual(n.text, text_de) + self.failUnlessEqual(n.text_de, text_de) + self.failUnlessEqual(n.text_en, text_en) + # Now switch to "en" + trans_real.activate("en") + self.failUnlessEqual(get_language(), "en") + # Title should now be return the english one (just by switching the + # language) + self.failUnlessEqual(n.title, title1_en) + self.failUnlessEqual(n.text, text_en) + + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en, + text_de=text_de, text_en=text_en) + n.save() + # language is set to "en" at this point + self.failUnlessEqual(n.title, title1_en) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + self.failUnlessEqual(n.text, text_en) + self.failUnlessEqual(n.text_de, text_de) + self.failUnlessEqual(n.text_en, text_en) + trans_real.activate("de") + self.failUnlessEqual(get_language(), "de") + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.text, text_de) + trans_real.deactivate() + + + def test_rule2(self): + """ + Rule 2: Assigning a value to the original field also updates the value + in the associated translation field of the default language + """ + self.failUnlessEqual(get_language(), "de") + title1_de = "title de" + title1_en = "title en" + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en) + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + title2 = "Neuer Titel" + n.title = title2 + n.save() + self.failUnlessEqual(n.title, title2) + self.failUnlessEqual(n.title, n.title_de) + + trans_real.activate("en") + self.failUnlessEqual(get_language(), "en") + title3 = "new title" + + n.title = title3 + n.title_de = title1_de + n.save() + self.failUnlessEqual(n.title, title3) + self.failUnlessEqual(n.title, n.title_en) + self.failUnlessEqual(title1_de, n.title_de) + + trans_real.deactivate() + + + def test_rule3(self): + """ + Rule 3: Assigning a value to a translation field of the default language + also updates the original field - note that the value of the original + field will not be updated until the model instance is saved. + """ + title1_de = "title de" + title1_en = "title en" + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en) + self.failUnlessEqual(get_language(), "de") + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + n.title_de = "Neuer Titel" + n.save() + self.failUnlessEqual(n.title, n.title_de) + + # Now switch to "en" + trans_real.activate("en") + self.failUnlessEqual(get_language(), "en") + n.title_en = "New title" + # the n.title field is not updated before the instance is saved + n.save() + self.failUnlessEqual(n.title, n.title_en) + trans_real.deactivate() + + + def test_rule4(self): + """ + Rule 4: If both fields - the original and the translation field of the + default language - are updated at the same time, the translation field + wins. + """ + self.failUnlessEqual(get_language(), "de") + title1_de = "title de" + title1_en = "title en" + n = TestModel.objects.create(title_de=title1_de, title_en=title1_en) + self.failUnlessEqual(n.title, title1_de) + self.failUnlessEqual(n.title_de, title1_de) + self.failUnlessEqual(n.title_en, title1_en) + + title2_de = "neu de" + title2_en = "new en" + title_foo = "foo" + n.title = title_foo + n.title_de = title2_de + n.title_en = title2_en + n.save() + self.failUnlessEqual(n.title, title2_de) + self.failUnlessEqual(n.title_de, title2_de) + self.failUnlessEqual(n.title_en, title2_en) + + n.title = title_foo + n.save() + self.failUnlessEqual(n.title, title_foo) + self.failUnlessEqual(n.title_de, title_foo) + self.failUnlessEqual(n.title_en, title2_en) + \ No newline at end of file diff --git a/modeltranslation/testurls.py b/modeltranslation/testurls.py new file mode 100644 index 0000000..c4f1759 --- /dev/null +++ b/modeltranslation/testurls.py @@ -0,0 +1,13 @@ + +from django.conf.urls.defaults import * +from django.contrib import admin +from django.views.generic.simple import direct_to_template + +urlpatterns = patterns('', + + url(r'^set_language/$', + 'django.views.i18n.set_language', + {}, + name='set_language'), + +) \ No newline at end of file diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py new file mode 100644 index 0000000..249de29 --- /dev/null +++ b/modeltranslation/translator.py @@ -0,0 +1,177 @@ +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import signals +from django.db.models.base import ModelBase +from django.utils.functional import curry + +from modeltranslation.fields import TranslationField +from modeltranslation.utils import TranslationFieldDescriptor, build_localized_fieldname + +class AlreadyRegistered(Exception): + pass + +class NotRegistered(Exception): + pass + +class TranslationOptions(object): + """ + The TranslationOptions object is used to specify the fields to translate. + + 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. + """ + def __init__(self, *args, **kwargs): + # self.translation_model = None + self.model_ct = None + self.localized_fieldnames = list() + +#def get_localized_fieldnames(model): + +def add_localized_fields(model): + """ + Monkey patchs 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. + + Returns a dict mapping the original fieldname to a list containing the names + of the localized fields created for the original field. + """ + localized_fields = dict() + translation_opts = translator.get_options_for_model(model) + for field_name in translation_opts.fields: + localized_fields[field_name] = list() + for l in settings.LANGUAGES: + # Construct the name for the localized field + localized_field_name = build_localized_fieldname(field_name, l[0]) + # Check if the model already has a field by that name + if hasattr(model, localized_field_name): + raise ValueError("Error adding translation field. The model "\ + "'%s' already contains a field named '%s'. "\ + % (instance.__class__.__name__, localized_field_name)) + + # This approach implements the translation fields as full valid + # django model fields and therefore adds them via add_to_class + localized_field = model.add_to_class(localized_field_name, + TranslationField(model._meta.get_field(field_name), + l[0])) + localized_fields[field_name].append(localized_field_name) + + + return localized_fields + # model.add_to_class('current_language', CurrentLanguageField()) + +#def translated_model_initialized(field_names, instance, **kwargs): + #print "translated_model_initialized instance:", instance, ", field:", field_names + #for field_name in field_names: + #initial_val = getattr(instance, field_name) + #print " field: %s, initialval: %s" % (field_name, initial_val) + #setattr(instance.__class__, field_name, TranslationFieldDescriptor(field_name, + #initial_val)) +#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: + #setattr(sender, field_name, TranslationFieldDescriptor(field_name)) + + +class Translator(object): + """ + A Translator object encapsulates an instance of a translator. Models are + registered with the Translator using the register() method. + """ + def __init__(self): + self._registry = {} # model_class class -> translation_opts instance + + def register(self, model_or_iterable, translation_opts, **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. + """ + # Don't import the humongous validation code unless required + if translation_opts and settings.DEBUG: + from django.contrib.admin.validation import validate + else: + validate = lambda model, adminclass: None + + #if not translation_opts: + #translation_opts = TranslationOptions + if isinstance(model_or_iterable, ModelBase): + model_or_iterable = [model_or_iterable] + + for model in model_or_iterable: + if model in self._registry: + raise AlreadyRegistered('The model %s is already registered for translation' % model.__name__) + + # 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("%sAdmin" % model.__name__, (translation_opts,), options) + + # Validate (which might be a no-op) + #validate(translation_opts, model) + + # Store the translation class associated to the model + self._registry[model] = translation_opts + + # Get the content type of the original model and store it on the + # translation options for faster lookup later on. + translation_opts.model_ct = ContentType.objects.get_for_model(model) + + # 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 + + print "Applying descriptor field for model %s" % model + for field_name in translation_opts.fields: + setattr(model, field_name, TranslationFieldDescriptor(field_name)) + + #signals.pre_init.connect(translated_model_initializing, sender=model, weak=False) + + def unregister(self, model_or_iterable): + """ + Unregisters the given model(s). + + If a model isn't already registered, this will raise NotRegistered. + """ + 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] + + 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. + """ + try: + return self._registry[model] + except KeyError: + raise NotRegistered('The model "%s" is not registered for translation' % model.__name__) + + +# This global object represents the singleton translator object +translator = Translator() diff --git a/modeltranslation/utils.py b/modeltranslation/utils.py new file mode 100644 index 0000000..58ad914 --- /dev/null +++ b/modeltranslation/utils.py @@ -0,0 +1,103 @@ +from django.db import models +from django.conf import settings +from django.core.exceptions import ValidationError +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import get_language + +class TranslationFieldDescriptor(object): + """ + A descriptor used for the original translated field. + """ + def __init__(self, name, initial_val=""): + """ + The ``name`` is the name of the field (which is not available in the + descriptor by default - this is Python behaviour). + """ + self.name = name + self.val = initial_val + + def __set__(self, instance, value): + # print "Descriptor.__set__%s %s %s.%s: %s" % (id(instance), id(self), type(instance), self.name, value) + lang = get_language() + loc_field_name = build_localized_fieldname(self.name, lang) + + # also update the translation field of the current language + setattr(instance, loc_field_name, value) + + # update the original field via the __dict__ to prevent calling the + # descriptor + instance.__dict__[self.name] = value + + + def __get__(self, instance, owner): + # print "Descriptor.__get__%s %s %s.%s: %s" % (id(instance), id(self), type(instance), self.name, self.val) + if not instance: + raise ValueError(u"Translation field '%s' can only be "\ + "accessed via an instance not via "\ + "a class." % self.name) + + lang = get_language() + loc_field_name = build_localized_fieldname(self.name, lang) + if hasattr(instance, loc_field_name): + return getattr(instance, loc_field_name) or instance.__dict__[self.name] + return instance.__dict__[self.name] + + +#def create_model(name, fields=None, app_label='', module='', options=None, admin_opts=None): + #""" + #Create specified model. + #This is taken from http://code.djangoproject.com/wiki/DynamicModels + #""" + #class Meta: + ## Using type('Meta', ...) gives a dictproxy error during model creation + #pass + + #if app_label: + ## app_label must be set using the Meta inner class + #setattr(Meta, 'app_label', app_label) + + ## Update Meta with any options that were provided + #if options is not None: + #for key, value in options.iteritems(): + #setattr(Meta, key, value) + + ## Set up a dictionary to simulate declarations within a class + #attrs = {'__module__': module, 'Meta': Meta} + + ## Add in any fields that were provided + #if fields: + #attrs.update(fields) + + ## Create the class, which automatically triggers ModelBase processing + #model = type(name, (models.Model,), attrs) + + ## Create an Admin class if admin options were provided + #if admin_opts is not None: + #class Admin(admin.ModelAdmin): + #pass + #for key, value in admin_opts: + #setattr(Admin, key, value) + #admin.site.register(model, Admin) + + #return model + + +def copy_field(field): + """Instantiate a new field, with all of the values from the old one, except the + to and to_field in the case of related fields. + + This taken from http://www.djangosnippets.org/snippets/442/ + """ + base_kw = dict([(n, getattr(field,n, '_null')) for n in models.fields.Field.__init__.im_func.func_code.co_varnames]) + if isinstance(field, models.fields.related.RelatedField): + rel = base_kw.get('rel') + rel_kw = dict([(n, getattr(rel,n, '_null')) for n in rel.__init__.im_func.func_code.co_varnames]) + if isinstance(field, models.fields.related.ForeignKey): + base_kw['to_field'] = rel_kw.pop('field_name') + base_kw.update(rel_kw) + base_kw.pop('self') + return field.__class__(**base_kw) + + +def build_localized_fieldname(field_name, lang): + return '%s_%s' % (field_name, lang) diff --git a/modeltranslation/views.py b/modeltranslation/views.py new file mode 100644 index 0000000..e69de29