Skip to content
Browse files

Merged hvad-nani refactor with master

  • Loading branch information...
1 parent 7cfb07c commit 6d920089358cf8a2d28764e80c877fe333e81272 @KristianOellegaard KristianOellegaard committed May 4, 2012
Showing with 3,319 additions and 2,966 deletions.
  1. +8 −9 README.rst
  2. +2 −0 hvad/__init__.py
  3. +563 −0 hvad/admin.py
  4. 0 hvad/compat/__init__.py
  5. +71 −0 hvad/compat/date.py
  6. +58 −0 hvad/descriptors.py
  7. +3 −0 hvad/exceptions.py
  8. +109 −0 hvad/fieldtranslator.py
  9. +278 −0 hvad/forms.py
  10. +805 −0 hvad/manager.py
  11. +305 −0 hvad/models.py
  12. +43 −0 hvad/templates/admin/hvad/change_form.html
  13. +16 −0 hvad/templates/admin/hvad/deletion_not_allowed.html
  14. +83 −0 hvad/templates/admin/hvad/edit_inline/stacked.html
  15. +130 −0 hvad/templates/admin/hvad/edit_inline/tabular.html
  16. +11 −0 hvad/templates/admin/hvad/includes/translation_tabs.html
  17. 0 hvad/test_utils/__init__.py
  18. +141 −0 hvad/test_utils/context_managers.py
  19. +44 −0 hvad/test_utils/data.py
  20. +85 −0 hvad/test_utils/fixtures.py
  21. +173 −0 hvad/test_utils/request_factory.py
  22. +91 −0 hvad/test_utils/testcase.py
  23. +21 −0 hvad/tests/__init__.py
  24. +9 −9 {nani → hvad}/tests/admin.py
  25. +6 −6 {nani → hvad}/tests/basic.py
  26. +3 −3 {nani → hvad}/tests/dates.py
  27. 0 {nani → hvad}/tests/docs.py
  28. +3 −3 {nani → hvad}/tests/fallbacks.py
  29. +2 −2 {nani → hvad}/tests/fieldtranslator.py
  30. +3 −3 {nani → hvad}/tests/forms.py
  31. +6 −6 {nani → hvad}/tests/forms_inline.py
  32. +2 −2 {nani → hvad}/tests/limit_choices_to.py
  33. +2 −2 {nani → hvad}/tests/ordering.py
  34. +4 −4 {nani → hvad}/tests/query.py
  35. +6 −6 {nani → hvad}/tests/related.py
  36. +2 −2 {nani → hvad}/tests/serialization.py
  37. +4 −4 {nani → hvad}/tests/views.py
  38. +80 −0 hvad/utils.py
  39. +83 −0 hvad/views.py
  40. +3 −1 nani/__init__.py
  41. +3 −561 nani/admin.py
  42. +4 −0 nani/compat/__init__.py
  43. +3 −70 nani/compat/date.py
  44. +3 −57 nani/descriptors.py
  45. +3 −2 nani/exceptions.py
  46. +3 −108 nani/fieldtranslator.py
  47. +3 −277 nani/forms.py
  48. +3 −804 nani/manager.py
  49. +3 −304 nani/models.py
  50. +4 −0 nani/test_utils/__init__.py
  51. +3 −140 nani/test_utils/context_managers.py
  52. +3 −43 nani/test_utils/data.py
  53. +3 −84 nani/test_utils/fixtures.py
  54. +3 −172 nani/test_utils/request_factory.py
  55. +3 −90 nani/test_utils/testcase.py
  56. +0 −21 nani/tests/__init__.py
  57. +3 −79 nani/utils.py
  58. +3 −84 nani/views.py
  59. +3 −3 runtests.sh
  60. +1 −1 testproject/alternate_models_app/models/normal.py
  61. +1 −1 testproject/app/admin.py
  62. +1 −1 testproject/app/models.py
  63. +1 −1 testproject/app/views.py
  64. +1 −1 testproject/settings.py
View
17 README.rst
@@ -7,7 +7,9 @@ Django.
Documentation for django-hvad can be found at http://django-hvad.readthedocs.org/.
-This project provides the same functionality as django-nani, but it as opposed to django-nani, this project does not affect the default queries, which means that everything will continue to work as it was before.
+This project provides the same functionality as django-nani, but it as opposed
+to django-nani, this project does not affect the default queries, which means
+that everything will continue to work as it was before.
You have to activate the translated fields, by calling a specific method on the manager.
@@ -24,11 +26,13 @@ Example
Normal.objects.all()
-Returns all objects, but without any translated fields attached - this query is just the default django queryset and can therefore be used as usual.
+Returns all objects, but without any translated fields attached - this query is
+just the default django queryset and can therefore be used as usual.
Normal.objects.language().all()
-Returns all objects as translated instances, but only the ones that are translated into the currect language. You can also specify which language to get, using e.g.
+Returns all objects as translated instances, but only the ones that are translated
+into the currect language. You can also specify which language to get, using e.g.
Normal.objects.language("en").all()
@@ -43,12 +47,7 @@ Features
* High level (no custom SQL Compiler or other scary things)
-Important
----------
-
-We keep the nani name internally in the code, as we want to be able to adapt and contribute to/from django-nani
-
Thanks to
---------
-Jonas Obrist (https://github.com/ojii) for making django-nani and for helping me with this project.
+Jonas Obrist (https://github.com/ojii) for making django-nani and for helping me with this project.
View
2 hvad/__init__.py
@@ -0,0 +1,2 @@
+__version__ = '0.1.5'
+
View
563 hvad/admin.py
@@ -0,0 +1,563 @@
+from distutils.version import LooseVersion
+from django.conf import settings
+from django.contrib.admin.options import ModelAdmin, csrf_protect_m, InlineModelAdmin
+from django.contrib.admin.util import (flatten_fieldsets, unquote,
+ get_deleted_objects)
+from django.core.exceptions import ValidationError, PermissionDenied
+from django.core.urlresolvers import reverse
+from django.db import router, transaction
+from django.forms.formsets import formset_factory
+from django.forms.models import ModelForm, BaseModelFormSet, BaseInlineFormSet, model_to_dict
+from django.forms.util import ErrorList
+from django.http import Http404, HttpResponseRedirect
+from django.shortcuts import render_to_response
+from django.template import TemplateDoesNotExist
+from django.template.context import RequestContext
+from django.template.loader import find_template
+from django.utils.encoding import iri_to_uri, force_unicode
+from django.utils.functional import curry
+from django.utils.translation import ugettext_lazy as _, get_language
+from functools import update_wrapper
+from hvad.forms import TranslatableModelForm, translatable_inlineformset_factory, translatable_modelform_factory
+import django
+import urllib
+from hvad.utils import get_cached_translation, get_translation
+from hvad.manager import FALLBACK_LANGUAGES
+
+
+NEW_GET_DELETE_OBJECTS = LooseVersion(django.get_version()) >= LooseVersion('1.3')
+
+
+def get_language_name(language_code):
+ return dict(settings.LANGUAGES).get(language_code, language_code)
+
+class InlineModelForm(TranslatableModelForm):
+ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
+ initial=None, error_class=ErrorList, label_suffix=':',
+ empty_permitted=False, instance=None):
+ """
+
+ """
+ opts = self._meta
+ model_opts = opts.model._meta
+ object_data = {}
+ language = getattr(self, 'language', get_language())
+ if instance is not None:
+ trans = get_cached_translation(instance)
+ if not trans or trans.language_code != language:
+ try:
+ trans = get_translation(instance, language)
+ except model_opts.translations_model.DoesNotExist:
+ trans = None
+ if trans:
+ object_data = model_to_dict(trans, opts.fields, opts.exclude)
+ # Dirty hack that swaps the id from the translation id, to the master id
+ # This is necessary, because we in this case get the untranslated instance,
+ # and thereafter get the correct translation on save.
+ if object_data.has_key("id"):
+ object_data["id"] = trans.master.id
+ if initial is not None:
+ object_data.update(initial)
+ initial = object_data
+ super(TranslatableModelForm, self).__init__(data, files, auto_id,
+ prefix, initial,
+ error_class, label_suffix,
+ empty_permitted, instance)
+
+
+class TranslatableModelAdminMixin(object):
+ query_language_key = 'language'
+
+ def all_translations(self, obj):
+ """
+ use this to display all languages the object has been translated to
+ in the changelist view:
+
+ class MyAdmin(admin.ModelAdmin):
+ list_display = ('__str__', 'all_translations',)
+
+ """
+ if obj and obj.pk:
+ languages = []
+ current_language = get_language()
+ for language in obj.get_available_languages():
+ if language == current_language:
+ languages.append(u'<strong>%s</strong>' % language)
+ else:
+ languages.append(language)
+ return u' '.join(languages)
+ else:
+ return ''
+ all_translations.allow_tags = True
+ all_translations.short_description = _('all translations')
+
+ def get_available_languages(self, obj):
+ if obj:
+ return obj.get_available_languages()
+ else:
+ return []
+
+ def get_language_tabs(self, request, available_languages):
+ tabs = []
+ get = dict(request.GET)
+ language = self._language(request)
+ for key, name in settings.LANGUAGES:
+ get.update({'language': key})
+ url = '%s://%s%s?%s' % (request.is_secure() and 'https' or 'http',
+ request.get_host(), request.path,
+ urllib.urlencode(get))
+ if language == key:
+ status = 'current'
+ elif key in available_languages:
+ status = 'available'
+ else:
+ status = 'empty'
+ tabs.append((url, name, key, status))
+ return tabs
+
+ def _language(self, request):
+ return request.GET.get(self.query_language_key, get_language())
+
+
+class TranslatableAdmin(ModelAdmin, TranslatableModelAdminMixin):
+ form = TranslatableModelForm
+
+ change_form_template = 'admin/hvad/change_form.html'
+
+ deletion_not_allowed_template = 'admin/hvad/deletion_not_allowed.html'
+
+ def get_urls(self):
+ from django.conf.urls.defaults import patterns, url
+
+ urlpatterns = super(TranslatableAdmin, self).get_urls()
+
+ def wrap(view):
+ def wrapper(*args, **kwargs):
+ return self.admin_site.admin_view(view)(*args, **kwargs)
+ return update_wrapper(wrapper, view)
+
+ info = self.model._meta.app_label, self.model._meta.module_name
+
+ urlpatterns = patterns('',
+ url(r'^(.+)/delete-translation/(.+)/$',
+ wrap(self.delete_translation),
+ name='%s_%s_delete_translation' % info),
+ ) + urlpatterns
+ return urlpatterns
+
+ def get_form(self, request, obj=None, **kwargs):
+ """
+ Returns a Form class for use in the admin add view. This is used by
+ add_view and change_view.
+ """
+
+ if self.declared_fieldsets:
+ fields = flatten_fieldsets(self.declared_fieldsets)
+ else:
+ fields = None
+ if self.exclude is None:
+ exclude = []
+ else:
+ exclude = list(self.exclude)
+ exclude.extend(kwargs.get("exclude", []))
+ exclude.extend(self.get_readonly_fields(request, obj))
+ # Exclude language_code, adding it again to the instance is done by
+ # the LanguageAwareCleanMixin (see translatable_modelform_factory)
+ exclude.append('language_code')
+ old_formfield_callback = curry(self.formfield_for_dbfield,
+ request=request)
+ defaults = {
+ "form": self.form,
+ "fields": fields,
+ "exclude": exclude,
+ "formfield_callback": old_formfield_callback,
+ }
+ defaults.update(kwargs)
+ language = self._language(request)
+ return translatable_modelform_factory(language, self.model, **defaults)
+
+
+
+ def render_change_form(self, request, context, add=False, change=False,
+ form_url='', obj=None):
+ lang_code = self._language(request)
+ lang = get_language_name(lang_code)
+ available_languages = self.get_available_languages(obj)
+ context['title'] = '%s (%s)' % (context['title'], lang)
+ context['current_is_translated'] = lang_code in available_languages
+ context['allow_deletion'] = len(available_languages) > 1
+ context['language_tabs'] = self.get_language_tabs(request, available_languages)
+ context['base_template'] = self.get_change_form_base_template()
+ return super(TranslatableAdmin, self).render_change_form(request,
+ context,
+ add, change,
+ form_url, obj)
+
+ def response_change(self, request, obj):
+ redirect = super(TranslatableAdmin, self).response_change(request, obj)
+ uri = iri_to_uri(request.path)
+ app_label, model_name = self.model._meta.app_label, self.model._meta.module_name
+ if redirect['Location'] in (uri, "../add/", reverse('admin:%s_%s_add' % (app_label, model_name))):
+ if self.query_language_key in request.GET:
+ redirect['Location'] = '%s?%s=%s' % (redirect['Location'],
+ self.query_language_key, request.GET[self.query_language_key])
+ return redirect
+
+ @csrf_protect_m
+ @transaction.commit_on_success
+ def delete_translation(self, request, object_id, language_code):
+ "The 'delete translation' admin view for this model."
+ opts = self.model._meta
+ app_label = opts.app_label
+ translations_model = opts.translations_model
+
+ try:
+ obj = translations_model.objects.select_related('maser').get(
+ master__pk=unquote(object_id),
+ language_code=language_code)
+ except translations_model.DoesNotExist:
+ raise Http404
+
+ if not self.has_delete_permission(request, obj):
+ raise PermissionDenied
+
+ if len(self.get_available_languages(obj.master)) <= 1:
+ return self.deletion_not_allowed(request, obj, language_code)
+
+ using = router.db_for_write(translations_model)
+
+ # Populate deleted_objects, a data structure of all related objects that
+ # will also be deleted.
+
+ protected = False
+ if NEW_GET_DELETE_OBJECTS:
+ (deleted_objects, perms_needed, protected) = get_deleted_objects(
+ [obj], translations_model._meta, request.user, self.admin_site, using)
+ else: # pragma: no cover
+ (deleted_objects, perms_needed) = get_deleted_objects(
+ [obj], translations_model._meta, request.user, self.admin_site)
+
+
+ lang = get_language_name(language_code)
+
+
+ if request.POST: # The user has already confirmed the deletion.
+ if perms_needed:
+ raise PermissionDenied
+ obj_display = '%s translation of %s' % (lang, force_unicode(obj.master))
+ self.log_deletion(request, obj, obj_display)
+ self.delete_model_translation(request, obj)
+
+ self.message_user(request,
+ _('The %(name)s "%(obj)s" was deleted successfully.') % {
+ 'name': force_unicode(opts.verbose_name),
+ 'obj': force_unicode(obj_display)
+ }
+ )
+
+ if not self.has_change_permission(request, None):
+ return HttpResponseRedirect(reverse('admin:index'))
+ return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (opts.app_label, opts.module_name)))
+
+ object_name = '%s Translation' % force_unicode(opts.verbose_name)
+
+ if perms_needed or protected:
+ title = _("Cannot delete %(name)s") % {"name": object_name}
+ else:
+ title = _("Are you sure?")
+
+ context = {
+ "title": title,
+ "object_name": object_name,
+ "object": obj,
+ "deleted_objects": deleted_objects,
+ "perms_lacking": perms_needed,
+ "protected": protected,
+ "opts": opts,
+ "app_label": app_label,
+ }
+
+ # in django > 1.4 root_path is removed
+ if hasattr(self.admin_site, 'root_path'):
+ context.update({"root_path": self.admin_site.root_path})
+
+ return render_to_response(self.delete_confirmation_template or [
+ "admin/%s/%s/delete_confirmation.html" % (app_label, opts.object_name.lower()),
+ "admin/%s/delete_confirmation.html" % app_label,
+ "admin/delete_confirmation.html"
+ ], context, RequestContext(request))
+
+ def deletion_not_allowed(self, request, obj, language_code):
+ opts = self.model._meta
+ app_label = opts.app_label
+ object_name = force_unicode(opts.verbose_name)
+
+ context = RequestContext(request)
+ context['object'] = obj.master
+ context['language_code'] = language_code
+ context['opts'] = opts
+ context['app_label'] = app_label
+ context['language_name'] = get_language_name(language_code)
+ context['object_name'] = object_name
+ return render_to_response(self.deletion_not_allowed_template, context)
+
+ def delete_model_translation(self, request, obj):
+ obj.delete()
+
+ def get_object(self, request, object_id):
+ obj = super(TranslatableAdmin, self).get_object(request, object_id)
+ if obj:
+ return obj
+ queryset = self.model.objects.untranslated()
+ model = self.model
+ try:
+ object_id = model._meta.pk.to_python(object_id)
+ obj = queryset.get(pk=object_id)
+ except (model.DoesNotExist, ValidationError):
+ return None
+ new_translation = model._meta.translations_model()
+ new_translation.language_code = self._language(request)
+ new_translation.master = obj
+ setattr(obj, model._meta.translations_cache, new_translation)
+ return obj
+
+ def queryset(self, request):
+ language = self._language(request)
+ languages = [language,]
+ for lang in FALLBACK_LANGUAGES:
+ if not lang in languages:
+ languages.append(lang)
+ qs = self.model._default_manager.untranslated().use_fallbacks(*languages)
+ # TODO: this should be handled by some parameter to the ChangeList.
+ ordering = getattr(self, 'ordering', None) or () # otherwise we might try to *None, which is bad ;)
+ if ordering:
+ qs = qs.order_by(*ordering)
+ return qs
+
+ def get_change_form_base_template(self):
+ opts = self.model._meta
+ app_label = opts.app_label
+ search_templates = [
+ "admin/%s/%s/change_form.html" % (app_label, opts.object_name.lower()),
+ "admin/%s/change_form.html" % app_label,
+ "admin/change_form.html"
+ ]
+ for template in search_templates:
+ try:
+ find_template(template)
+ return template
+ except TemplateDoesNotExist:
+ pass
+ else: # pragma: no cover
+ pass
+
+class TranslatableInlineModelAdmin(InlineModelAdmin, TranslatableModelAdminMixin):
+ form = InlineModelForm
+
+ change_form_template = 'admin/hvad/change_form.html'
+
+ deletion_not_allowed_template = 'admin/hvad/deletion_not_allowed.html'
+
+ def get_formset(self, request, obj=None, **kwargs):
+ """Returns a BaseInlineFormSet class for use in admin add/change views."""
+ if self.declared_fieldsets:
+ fields = flatten_fieldsets(self.declared_fieldsets)
+ else:
+ fields = None
+ if self.exclude is None:
+ exclude = []
+ else:
+ exclude = list(self.exclude)
+ exclude.extend(kwargs.get("exclude", []))
+ exclude.extend(self.get_readonly_fields(request, obj))
+ # if exclude is an empty list we use None, since that's the actual
+ # default
+ exclude = exclude or None
+ defaults = {
+ "form": self.get_form(request, obj),
+ #"formset": self.formset,
+ "fk_name": self.fk_name,
+ "fields": fields,
+ "exclude": exclude,
+ "formfield_callback": curry(self.formfield_for_dbfield, request=request),
+ "extra": self.extra,
+ "max_num": self.max_num,
+ "can_delete": self.can_delete,
+ }
+ defaults.update(kwargs)
+ language = self._language(request)
+ return translatable_inlineformset_factory(language, self.parent_model, self.model, **defaults)
+
+ def get_urls(self):
+ from django.conf.urls.defaults import patterns, url
+
+ urlpatterns = super(InlineModelAdmin, self).get_urls()
+
+ def wrap(view):
+ def wrapper(*args, **kwargs):
+ return self.admin_site.admin_view(view)(*args, **kwargs)
+ return update_wrapper(wrapper, view)
+
+ info = self.model._meta.app_label, self.model._meta.module_name
+
+ urlpatterns = patterns('',
+ url(r'^(.+)/delete-translation/(.+)/$',
+ wrap(self.delete_translation),
+ name='%s_%s_delete_translation' % info),
+ ) + urlpatterns
+ return urlpatterns
+
+ def get_form(self, request, obj=None, **kwargs):
+ """
+ Returns a Form class for use in the admin add view. This is used by
+ add_view and change_view.
+ """
+ if self.declared_fieldsets:
+ fields = flatten_fieldsets(self.declared_fieldsets)
+ else:
+ fields = None
+ if self.exclude is None:
+ exclude = []
+ else:
+ exclude = list(self.exclude)
+ exclude.extend(kwargs.get("exclude", []))
+ exclude.extend(self.get_readonly_fields(request, obj))
+ # Exclude language_code, adding it again to the instance is done by
+ # the LanguageAwareCleanMixin (see translatable_modelform_factory)
+ exclude.append('language_code')
+ old_formfield_callback = curry(self.formfield_for_dbfield,
+ request=request)
+ defaults = {
+ "form": self.form,
+ "fields": fields,
+ "exclude": exclude,
+ "formfield_callback": old_formfield_callback,
+ }
+ defaults.update(kwargs)
+ language = self._language(request)
+ return translatable_modelform_factory(language, self.model, **defaults)
+
+ def response_change(self, request, obj):
+ redirect = super(TranslatableAdmin, self).response_change(request, obj)
+ uri = iri_to_uri(request.path)
+ if redirect['Location'] in (uri, "../add/"):
+ if self.query_language_key in request.GET:
+ redirect['Location'] = '%s?%s=%s' % (redirect['Location'],
+ self.query_language_key, request.GET[self.query_language_key])
+ return redirect
+
+ """
+# Should be added
+ @csrf_protect_m
+ @transaction.commit_on_success
+ def delete_translation(self, request, object_id, language_code):
+ "The 'delete translation' admin view for this model."
+ opts = self.model._meta
+ app_label = opts.app_label
+ translations_model = opts.translations_model
+
+ try:
+ obj = translations_model.objects.select_related('maser').get(
+ master__pk=unquote(object_id),
+ language_code=language_code)
+ except translations_model.DoesNotExist:
+ raise Http404
+
+ if not self.has_delete_permission(request, obj):
+ raise PermissionDenied
+
+ if len(self.get_available_languages(obj.master)) <= 1:
+ return self.deletion_not_allowed(request, obj, language_code)
+
+ using = router.db_for_write(translations_model)
+
+ # Populate deleted_objects, a data structure of all related objects that
+ # will also be deleted.
+
+ protected = False
+ if NEW_GET_DELETE_OBJECTS:
+ (deleted_objects, perms_needed, protected) = get_deleted_objects(
+ [obj], translations_model._meta, request.user, self.admin_site, using)
+ else: # pragma: no cover
+ (deleted_objects, perms_needed) = get_deleted_objects(
+ [obj], translations_model._meta, request.user, self.admin_site)
+
+
+ lang = get_language_name(language_code)
+
+
+ if request.POST: # The user has already confirmed the deletion.
+ if perms_needed:
+ raise PermissionDenied
+ obj_display = '%s translation of %s' % (lang, force_unicode(obj.master))
+ self.log_deletion(request, obj, obj_display)
+ self.delete_model_translation(request, obj)
+
+ self.message_user(request,
+ _('The %(name)s "%(obj)s" was deleted successfully.') % {
+ 'name': force_unicode(opts.verbose_name),
+ 'obj': force_unicode(obj_display)
+ }
+ )
+
+ if not self.has_change_permission(request, None):
+ return HttpResponseRedirect(reverse('admin:index'))
+ return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (opts.app_label, opts.module_name)))
+
+ object_name = '%s Translation' % force_unicode(opts.verbose_name)
+
+ if perms_needed or protected:
+ title = _("Cannot delete %(name)s") % {"name": object_name}
+ else:
+ title = _("Are you sure?")
+
+ context = {
+ "title": title,
+ "object_name": object_name,
+ "object": obj,
+ "deleted_objects": deleted_objects,
+ "perms_lacking": perms_needed,
+ "protected": protected,
+ "opts": opts,
+ "root_path": self.admin_site.root_path,
+ "app_label": app_label,
+ }
+
+ return render_to_response(self.delete_confirmation_template or [
+ "admin/%s/%s/delete_confirmation.html" % (app_label, opts.object_name.lower()),
+ "admin/%s/delete_confirmation.html" % app_label,
+ "admin/delete_confirmation.html"
+ ], context, RequestContext(request))
+
+ def deletion_not_allowed(self, request, obj, language_code):
+ opts = self.model._meta
+ app_label = opts.app_label
+ object_name = force_unicode(opts.verbose_name)
+
+ context = RequestContext(request)
+ context['object'] = obj.master
+ context['language_code'] = language_code
+ context['opts'] = opts
+ context['app_label'] = app_label
+ context['language_name'] = get_language_name(language_code)
+ context['object_name'] = object_name
+ return render_to_response(self.deletion_not_allowed_template, context)
+
+ def delete_model_translation(self, request, obj):
+ obj.delete()
+ """
+ def queryset(self, request):
+ language = self._language(request)
+ qs = self.model._default_manager.all()#.language(language)
+ # TODO: this should be handled by some parameter to the ChangeList.
+ ordering = getattr(self, 'ordering', None) or () # otherwise we might try to *None, which is bad ;)
+ if ordering:
+ qs = qs.order_by(*ordering)
+ return qs
+
+
+class TranslatableStackedInline(TranslatableInlineModelAdmin):
+ template = 'admin/hvad/edit_inline/stacked.html'
+
+class TranslatableTabularInline(TranslatableInlineModelAdmin):
+ template = 'admin/hvad/edit_inline/tabular.html'
View
0 hvad/compat/__init__.py
No changes.
View
71 hvad/compat/date.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+from django.core.exceptions import FieldError
+from django.db.models.fields import FieldDoesNotExist, DateField
+from django.db.models.sql.constants import LOOKUP_SEP
+from django.db.models.sql.datastructures import Date
+from django.db.models.sql.query import Query
+from hvad.manager import TranslationQueryset
+
+class DateQuerySet(TranslationQueryset):
+ def iterator(self):
+ return self.query.get_compiler(self.db).results_iter()
+
+ def _setup_query(self):
+ """
+ Sets up any special features of the query attribute.
+
+ Called by the _clone() method after initializing the rest of the
+ instance.
+ """
+ self.query.clear_deferred_loading()
+ self.query = self.query.clone(klass=DateQuery, setup=True)
+ self.query.select = []
+ self.query.add_date_select(self._field_name, self._kind, self._order)
+
+ def _clone(self, klass=None, setup=False, **kwargs):
+ c = super(DateQuerySet, self)._clone(klass, False, **kwargs)
+ c._field_name = self._field_name
+ c._kind = self._kind
+ if setup and hasattr(c, '_setup_query'):
+ c._setup_query()
+ return c
+
+
+class DateQuery(Query):
+ """
+ A DateQuery is a normal query, except that it specifically selects a single
+ date field. This requires some special handling when converting the results
+ back to Python objects, so we put it in a separate class.
+ """
+
+ compiler = 'SQLDateCompiler'
+
+ def add_date_select(self, field_name, lookup_type, order='ASC'):
+ """
+ Converts the query into a date extraction query.
+ """
+ try:
+ result = self.setup_joins(
+ field_name.split(LOOKUP_SEP),
+ self.get_meta(),
+ self.get_initial_alias(),
+ False
+ )
+ except FieldError:
+ raise FieldDoesNotExist("%s has no field named '%s'" % (
+ self.model._meta.object_name, field_name
+ ))
+ field = result[0]
+ assert isinstance(field, DateField), "%r isn't a DateField." \
+ % field.name
+ alias = result[3][-1]
+ select = Date((alias, field.column), lookup_type)
+ self.select = [select]
+ self.select_fields = [None]
+ self.select_related = False # See #7097.
+ self.set_extra_mask([])
+ self.distinct = True
+ self.order_by = order == 'ASC' and [1] or [-1]
+
+ if field.null:
+ self.add_filter(("%s__isnull" % field_name, False))
View
58 hvad/descriptors.py
@@ -0,0 +1,58 @@
+from hvad.utils import get_translation
+
+class NULL:pass
+
+class BaseDescriptor(object):
+ """
+ Base descriptor class with a helper to get the translations instance.
+ """
+ def __init__(self, opts):
+ self.opts = opts
+
+ def translation(self, instance):
+ cached = getattr(instance, self.opts.translations_cache, None)
+ if not cached:
+ cached = get_translation(instance)
+ setattr(instance, self.opts.translations_cache, cached)
+ return cached
+
+
+class TranslatedAttribute(BaseDescriptor):
+ """
+ Basic translated attribute descriptor.
+
+ Proxies attributes from the shared instance to the translated instance.
+ """
+ def __init__(self, opts, name):
+ self.name = name
+ super(TranslatedAttribute, self).__init__(opts)
+
+ def __get__(self, instance, instance_type=None):
+ if not instance:
+ # Don't raise an attribute error so we can use it in admin.
+ return self.opts.translations_model._meta.get_field_by_name(
+ self.name)[0].default
+ return getattr(self.translation(instance), self.name)
+
+ def __set__(self, instance, value):
+ setattr(self.translation(instance), self.name, value)
+
+ def __delete__(self, instance):
+ delattr(self.translation(instance), self.name)
+
+
+class LanguageCodeAttribute(TranslatedAttribute):
+ """
+ The language_code attribute is different from other attribtues as it cannot
+ be deleted. Trying to do so will always cause an attribute error.
+
+ """
+ def __init__(self, opts):
+ super(LanguageCodeAttribute, self).__init__(opts, 'language_code')
+
+ def __set__(self, instance, value):
+ raise AttributeError("The 'language_code' attribute cannot be " +\
+ "changed directly! Use the translate() method instead.")
+
+ def __delete__(self, instance):
+ raise AttributeError("The 'language_code' attribute cannot be deleted!")
View
3 hvad/exceptions.py
@@ -0,0 +1,3 @@
+from django.db.models.fields import FieldDoesNotExist
+
+class WrongManager(Exception): pass
View
109 hvad/fieldtranslator.py
@@ -0,0 +1,109 @@
+from django.db.models.sql.constants import QUERY_TERMS
+
+TRANSLATIONS = 1
+TRANSLATED = 2
+NORMAL = 3
+
+
+MODEL_INFO = {}
+
+
+def _build_model_info(model):
+ """
+ Builds the model information dictinary for get_model_info
+ """
+ from nani.models import BaseTranslationModel, TranslatableModel
+ info = {}
+ if issubclass(model, BaseTranslationModel):
+ info['type'] = TRANSLATIONS
+ info['shared'] = model._meta.shared_model._meta.get_all_field_names() + ['pk']
+ info['translated'] = model._meta.get_all_field_names()
+ elif issubclass(model, TranslatableModel):
+ info['type'] = TRANSLATED
+ info['shared'] = model._meta.get_all_field_names() + ['pk']
+ info['translated'] = model._meta.translations_model._meta.get_all_field_names()
+ else:
+ info['type'] = NORMAL
+ info['shared'] = model._meta.get_all_field_names() + ['pk']
+ info['translated'] = []
+ if 'id' in info['translated']:
+ info['translated'].remove('id')
+ return info
+
+def get_model_info(model):
+ """
+ Returns a dictionary with 'translated' and 'shared' as keys, and a list of
+ respective field names as values. Also has a key 'type' which is either
+ TRANSLATIONS, TRANSLATED or NORMAL
+ """
+ if model not in MODEL_INFO:
+ MODEL_INFO[model] = _build_model_info(model)
+ return MODEL_INFO[model]
+
+def _get_model_from_field(starting_model, fieldname):
+ # TODO: m2m handling
+ field, model, direct, _ = starting_model._meta.get_field_by_name(fieldname)
+ if model:
+ return model
+ elif direct:
+ return field.rel.to
+ else:
+ return field.model
+
+def translate(querykey, starting_model):
+ """
+ Translates a querykey starting from a given model to be 'translation aware'.
+ """
+ bits = querykey.split('__')
+ translated_bits = []
+ model = starting_model
+ language_joins = []
+ max_index = len(bits) - 1
+ # iterate over the bits
+ for index, bit in enumerate(bits):
+ model_info = get_model_info(model)
+ # if the bit is a QUERY_TERM, just append it to the translated_bits
+ if bit in QUERY_TERMS:
+ translated_bits.append(bit)
+ # same goes for 'normal model' bits
+ elif model_info['type'] == NORMAL:
+ translated_bits.append(bit)
+ # if the bit is on a translated model, check if it's in translated
+ # translated or untranslated fields. If it's in translated, inject a
+ # lookup via the translations accessor. Also add a language join on this
+ # table.
+ elif model_info['type'] == TRANSLATED:
+ if bit in model_info['translated']:
+ translated_bits.append(model._meta.translations_accessor)
+ path = '__'.join(translated_bits)
+ # ignore the first model, since it should already enforce a
+ # language
+ if index != 0:
+ language_joins.append('%s__language_code' % path)
+ translated_bits.append(bit)
+ else:
+ path = '__'.join(translated_bits + [model._meta.translations_accessor])
+ # ignore the first model, since it should already enforce a
+ # language
+ if index != 0:
+ language_joins.append('%s__language_code' % path)
+ translated_bits.append(bit)
+ # else (if it's a translations table), inject a 'master' if the field is
+ # untranslated and add language joins.
+ else:
+ if bit in model_info['translated']:
+ translated_bits.append(bit)
+ else:
+ path = '__'.join(translated_bits)
+ # ignore the first model, since it should already enforce a
+ # language
+ if index != 0:
+ language_joins.append('%s__language_code' % path)
+ translated_bits.append('master')
+ translated_bits.append(bit)
+ # do we really want to get the next model? Is there a next model?
+ if index < max_index:
+ next = bits[index + 1]
+ if next not in QUERY_TERMS:
+ model = _get_model_from_field(model, bit)
+ return '__'.join(translated_bits), language_joins
View
278 hvad/forms.py
@@ -0,0 +1,278 @@
+from django.core.exceptions import FieldError
+from django.forms.forms import get_declared_fields
+from django.forms.formsets import formset_factory
+from django.forms.models import (ModelForm, ModelFormMetaclass, ModelFormOptions,
+ fields_for_model, model_to_dict, save_instance, BaseInlineFormSet, BaseModelFormSet)
+from django.forms.util import ErrorList
+from django.forms.widgets import media_property
+from django.utils.translation import get_language
+from hvad.models import TranslatableModel
+from hvad.utils import get_cached_translation, get_translation, combine
+
+
+class TranslatableModelFormMetaclass(ModelFormMetaclass):
+ def __new__(cls, name, bases, attrs):
+
+ """
+ Django 1.3 fix, that removes all Meta.fields and Meta.exclude
+ fieldnames that are in the translatable model. This ensures
+ that the superclass' init method doesnt throw a validation
+ error
+ """
+ fields = []
+ exclude = []
+ fieldsets = []
+ if "Meta" in attrs:
+ meta = attrs["Meta"]
+ if getattr(meta, "fieldsets", False):
+ fieldsets = meta.fieldsets
+ meta.fieldsets = []
+ if getattr(meta, "fields", False):
+ fields = meta.fields
+ meta.fields = []
+ if getattr(meta, "exclude", False):
+ exclude = meta.exclude
+ meta.exclude = []
+ # End 1.3 fix
+
+ super_new = super(TranslatableModelFormMetaclass, cls).__new__
+
+ formfield_callback = attrs.pop('formfield_callback', None)
+ declared_fields = get_declared_fields(bases, attrs, False)
+ new_class = super_new(cls, name, bases, attrs)
+
+ # Start 1.3 fix
+ if fields:
+ new_class.Meta.fields = fields
+ if exclude:
+ new_class.Meta.exclude = exclude
+ if fieldsets:
+ new_class.Meta.fieldsets = fieldsets
+ # End 1.3 fix
+
+ if not getattr(new_class, "Meta", None):
+ class Meta:
+ exclude = ['language_code']
+ new_class.Meta = Meta
+ elif not getattr(new_class.Meta, 'exclude', None):
+ new_class.Meta.exclude = ['language_code']
+ elif getattr(new_class.Meta, 'exclude', False):
+ if 'language_code' not in new_class.Meta.exclude:
+ new_class.Meta.exclude.append("language_code")
+
+ if 'Media' not in attrs:
+ new_class.media = media_property(new_class)
+ opts = new_class._meta = ModelFormOptions(getattr(new_class, 'Meta', attrs.get('Meta', None)))
+ if opts.model:
+ # bail out if a wrong model uses this form class
+ if not issubclass(opts.model, TranslatableModel):
+ raise TypeError(
+ "Only TranslatableModel subclasses may use TranslatableModelForm"
+ )
+ mopts = opts.model._meta
+
+ shared_fields = mopts.get_all_field_names()
+
+ # split exclude and include fieldnames into shared and translated
+ sfieldnames = [field for field in opts.fields or [] if field in shared_fields]
+ tfieldnames = [field for field in opts.fields or [] if field not in shared_fields]
+ sexclude = [field for field in opts.exclude or [] if field in shared_fields]
+ texclude = [field for field in opts.exclude or [] if field not in shared_fields]
+
+ # required by fields_for_model
+ if not sfieldnames :
+ sfieldnames = None if not fields else []
+ if not tfieldnames:
+ tfieldnames = None if not fields else []
+
+ # If a model is defined, extract form fields from it.
+ sfields = fields_for_model(opts.model, sfieldnames, sexclude,
+ opts.widgets, formfield_callback)
+ tfields = fields_for_model(mopts.translations_model, tfieldnames,
+ texclude, opts.widgets, formfield_callback)
+
+ fields = sfields
+ fields.update(tfields)
+
+ # make sure opts.fields doesn't specify an invalid field
+ none_model_fields = [k for k, v in fields.iteritems() if not v]
+ missing_fields = set(none_model_fields) - \
+ set(declared_fields.keys())
+ if missing_fields:
+ message = 'Unknown field(s) (%s) specified for %s'
+ message = message % (', '.join(missing_fields),
+ opts.model.__name__)
+ raise FieldError(message)
+ # Override default model fields with any custom declared ones
+ # (plus, include all the other declared fields).
+ fields.update(declared_fields)
+
+ if new_class._meta.exclude:
+ new_class._meta.exclude = list(new_class._meta.exclude)
+ else:
+ new_class._meta.exclude = []
+
+ for field in (mopts.translations_accessor, 'master'):
+ if not field in new_class._meta.exclude:
+ new_class._meta.exclude.append(field)
+ else:
+ fields = declared_fields
+ new_class.declared_fields = declared_fields
+ new_class.base_fields = fields
+ # always exclude the FKs
+ return new_class
+
+
+class TranslatableModelForm(ModelForm):
+ __metaclass__ = TranslatableModelFormMetaclass
+
+ def __init__(self, data=None, files=None, auto_id='id_%s', prefix=None,
+ initial=None, error_class=ErrorList, label_suffix=':',
+ empty_permitted=False, instance=None):
+ opts = self._meta
+ model_opts = opts.model._meta
+ object_data = {}
+ language = getattr(self, 'language', get_language())
+ if instance is not None:
+ trans = get_cached_translation(instance)
+ if not trans:
+ try:
+ trans = get_translation(instance, language)
+ except model_opts.translations_model.DoesNotExist:
+ trans = None
+ if trans:
+ object_data = model_to_dict(trans, opts.fields, opts.exclude)
+ if initial is not None:
+ object_data.update(initial)
+ initial = object_data
+ super(TranslatableModelForm, self).__init__(data, files, auto_id,
+ prefix, object_data,
+ error_class, label_suffix,
+ empty_permitted, instance)
+
+ def save(self, commit=True):
+ if self.instance.pk is None:
+ fail_message = 'created'
+ new = True
+ else:
+ fail_message = 'changed'
+ new = False
+ super(TranslatableModelForm, self).save(True)
+ trans_model = self.instance._meta.translations_model
+ language_code = self.cleaned_data.get('language_code', get_language())
+ if not new:
+ trans = get_cached_translation(self.instance)
+ if not trans or trans.language_code != language_code:
+ try:
+ trans = get_translation(self.instance, language_code)
+ except trans_model.DoesNotExist:
+ trans = trans_model()
+ else:
+ trans = trans_model()
+
+ trans.language_code = language_code
+ trans.master = self.instance
+ trans = save_instance(self, trans, self._meta.fields, fail_message,
+ commit, construct=True)
+ return combine(trans)
+
+ def _post_clean(self):
+ if self.instance.pk:
+ try:
+ trans = trans = get_translation(self.instance, self.instance.language_code)
+ trans.master = self.instance
+ self.instance = combine(trans)
+ except self.instance._meta.translations_model.DoesNotExist:
+ language_code = self.cleaned_data.get('language_code', get_language())
+ self.instance = self.instance.translate(language_code)
+ return super(TranslatableModelForm, self)._post_clean()
+
+
+
+class CleanMixin(object):
+ def clean(self):
+ data = super(CleanMixin, self).clean()
+ data['language_code'] = self.language
+ return data
+
+
+def LanguageAwareCleanMixin(language):
+ return type('BoundCleanMixin', (CleanMixin,), {'language': language})
+
+
+def translatable_modelform_factory(language, model, form=TranslatableModelForm,
+ fields=None, exclude=None,
+ formfield_callback=None):
+ # Create the inner Meta class. FIXME: ideally, we should be able to
+ # construct a ModelForm without creating and passing in a temporary
+ # inner class.
+
+ # Build up a list of attributes that the Meta object will have.
+ attrs = {'model': model}
+ if fields is not None:
+ attrs['fields'] = fields
+ if exclude is not None:
+ attrs['exclude'] = exclude
+
+ # If parent form class already has an inner Meta, the Meta we're
+ # creating needs to inherit from the parent's inner meta.
+ parent = (object,)
+ if hasattr(form, 'Meta'):
+ parent = (form.Meta, object)
+ Meta = type('Meta', parent, attrs)
+
+ # Give this new form class a reasonable name.
+ class_name = model.__name__ + 'Form'
+
+ # Class attributes for the new form class.
+ form_class_attrs = {
+ 'Meta': Meta,
+ 'formfield_callback': formfield_callback
+ }
+ clean_mixin = LanguageAwareCleanMixin(language)
+ return type(class_name, (clean_mixin, form,), form_class_attrs)
+
+def translatable_modelformset_factory(language, model, form=TranslatableModelForm, formfield_callback=None,
+ formset=BaseModelFormSet,
+ extra=1, can_delete=False, can_order=False,
+ max_num=None, fields=None, exclude=None):
+ """
+ Returns a FormSet class for the given Django model class.
+ """
+ form = translatable_modelform_factory(language, model, form=form, fields=fields, exclude=exclude,
+ formfield_callback=formfield_callback)
+ FormSet = formset_factory(form, formset, extra=extra, max_num=max_num,
+ can_order=can_order, can_delete=can_delete)
+ FormSet.model = model
+ return FormSet
+
+def translatable_inlineformset_factory(language, parent_model, model, form=TranslatableModelForm,
+ formset=BaseInlineFormSet, fk_name=None,
+ fields=None, exclude=None,
+ extra=3, can_order=False, can_delete=True, max_num=None,
+ formfield_callback=None):
+ """
+ Returns an ``InlineFormSet`` for the given kwargs.
+
+ You must provide ``fk_name`` if ``model`` has more than one ``ForeignKey``
+ to ``parent_model``.
+ """
+ from django.forms.models import _get_foreign_key
+ fk = _get_foreign_key(parent_model, model, fk_name=fk_name)
+ # enforce a max_num=1 when the foreign key to the parent model is unique.
+ if fk.unique:
+ max_num = 1
+ kwargs = {
+ 'form': form,
+ 'formfield_callback': formfield_callback,
+ 'formset': formset,
+ 'extra': extra,
+ 'can_delete': can_delete,
+ 'can_order': can_order,
+ 'fields': fields,
+ 'exclude': exclude,
+ 'max_num': max_num,
+ }
+ FormSet = translatable_modelformset_factory(language, model, **kwargs)
+ FormSet.fk = fk
+ return FormSet
View
805 hvad/manager.py
@@ -0,0 +1,805 @@
+from collections import defaultdict
+from django.conf import settings
+from django.db import models, transaction, IntegrityError
+from django.db.models.query import (QuerySet, ValuesQuerySet, DateQuerySet,
+ CHUNK_SIZE)
+from django.db.models.query_utils import Q
+from django.utils.translation import get_language
+from hvad.fieldtranslator import translate
+from hvad.utils import combine
+import django
+import logging
+import sys
+
+logger = logging.getLogger(__name__)
+
+# maybe there should be an extra settings for this
+FALLBACK_LANGUAGES = [ code for code, name in settings.LANGUAGES ]
+
+class FieldTranslator(dict):
+ """
+ Translates *shared* field names from '<shared_field>' to
+ 'master__<shared_field>' and caches those names.
+ """
+ def __init__(self, manager):
+ self.manager = manager
+ self.shared_fields = tuple(self.manager.shared_model._meta.get_all_field_names()) + ('pk',)
+ self.translated_fields = tuple(self.manager.model._meta.get_all_field_names())
+ super(FieldTranslator, self).__init__()
+
+ def get(self, key):
+ if not key in self:
+ self[key] = self.build(key)
+ return self[key]
+
+ def build(self, key):
+ """
+ Checks if the selected field is a shared field
+ and in that case, prefixes it with master___
+ It also handles - and ? in case its called by
+ order_by()
+ """
+ if key == "?":
+ return key
+ if key.startswith("-"):
+ prefix = "-"
+ key = key[1:]
+ else:
+ prefix = ""
+ if key.startswith(self.shared_fields):
+ return '%smaster__%s' % (prefix, key)
+ else:
+ return '%s%s' % (prefix, key)
+
+
+class ValuesMixin(object):
+ def _strip_master(self, key):
+ if key.startswith('master__'):
+ return key[8:]
+ return key
+
+ def iterator(self):
+ for row in super(ValuesMixin, self).iterator():
+ if isinstance(row, dict):
+ yield dict([(self._strip_master(k), v) for k,v in row.items()])
+ else:
+ yield row
+
+
+class DatesMixin(object):
+ pass
+
+
+#===============================================================================
+# Default
+#===============================================================================
+
+
+class TranslationQueryset(QuerySet):
+ """
+ This is where things happen.
+
+ To fully understand this project, you have to understand this class.
+
+ Go through each method individually, maybe start with 'get', 'create' and
+ 'iterator'.
+
+ IMPORTANT: the `model` attribute on this class is the *translated* Model,
+ despite this being used as the queryset for the *shared* Model!
+ """
+ override_classes = {
+ ValuesQuerySet: ValuesMixin,
+ DateQuerySet: DatesMixin,
+ }
+
+ def __init__(self, model=None, query=None, using=None, real=None):
+ self._local_field_names = None
+ self._field_translator = None
+ self._real_manager = real
+ self._fallback_manager = None
+ self._language_code = None
+ super(TranslationQueryset, self).__init__(model=model, query=query, using=using)
+
+ #===========================================================================
+ # Helpers and properties (INTERNAL!)
+ #===========================================================================
+
+ @property
+ def shared_model(self):
+ """
+ Get the shared model class
+ """
+ return self._real_manager.model
+
+ @property
+ def field_translator(self):
+ """
+ Field translator for this manager
+ """
+ if self._field_translator is None:
+ self._field_translator = FieldTranslator(self)
+ return self._field_translator
+
+ @property
+ def shared_local_field_names(self):
+ if self._local_field_names is None:
+ self._local_field_names = self.shared_model._meta.get_all_field_names()
+ return self._local_field_names
+
+ def _translate_args_kwargs(self, *args, **kwargs):
+ # Translated kwargs from '<shared_field>' to 'master__<shared_field>'
+ # where necessary.
+ newkwargs = {}
+ for key, value in kwargs.items():
+ newkwargs[self.field_translator.get(key)] = value
+ # Translate args (Q objects) from '<shared_field>' to
+ # 'master__<shared_field>' where necessary.
+ newargs = []
+ for q in args:
+ newargs.append(self._recurse_q(q))
+ return newargs, newkwargs
+
+ def _translate_fieldnames(self, fieldnames):
+ newnames = []
+ for name in fieldnames:
+ newnames.append(self.field_translator.get(name))
+ return newnames
+
+ def _reverse_translate_fieldnames_dict(self, fieldname_dict):
+ """
+ Helper function to make sure the user doesnt get "bothered"
+ with the construction of shared/translated model
+
+ Translates e.g.
+ {'master__number_avg': 10} to {'number__avg': 10}
+
+ """
+ newdict = {}
+ for key, value in fieldname_dict.items():
+ if key.startswith("master__"):
+ key = key.replace("master__", "")
+ newdict[key] = value
+ return newdict
+
+ def _recurse_q(self, q):
+ """
+ Recursively translate fieldnames in a Q object.
+
+ TODO: What happens if we span multiple relations?
+ """
+ newchildren = []
+ for child in q.children:
+ if isinstance(child, Q):
+ newq = self._recurse_q(child)
+ newchildren.append(self._recurse_q(newq))
+ else:
+ key, value = child
+ newchildren.append((self.field_translator.get(key), value))
+ q.children = newchildren
+ return q
+
+ def _find_language_code(self, q):
+ """
+ Checks if it finds a language code in a Q object (and it's children).
+ """
+ language_code = None
+ for child in q.children:
+ if isinstance(child, Q):
+ language_code = self._find_language_code(child)
+ elif isinstance(child, tuple):
+ key, value = child
+ if key == 'language_code':
+ language_code = value
+ if language_code:
+ break
+ return language_code
+
+ def _split_kwargs(self, **kwargs):
+ """
+ Split kwargs into shared and translated fields
+ """
+ shared = {}
+ translated = {}
+ for key, value in kwargs.items():
+ if key in self.shared_local_field_names:
+ shared[key] = value
+ else:
+ translated[key] = value
+ return shared, translated
+
+ def _get_class(self, klass):
+ for key, value in self.override_classes.items():
+ if issubclass(klass, key):
+ return type(value.__name__, (value, klass, TranslationQueryset,), {})
+ return klass
+
+ def _get_shared_query_set(self):
+ qs = super(TranslationQueryset, self)._clone()
+ qs.__class__ = QuerySet
+ # un-select-related the 'master' relation
+ del qs.query.select_related['master']
+ accessor = self.shared_model._meta.translations_accessor
+ # update using the real manager
+ return self._real_manager.filter(**{'%s__in' % accessor:qs})
+
+ #===========================================================================
+ # Queryset/Manager API
+ #===========================================================================
+
+ def language(self, language_code=None):
+ if not language_code:
+ language_code = get_language()
+ self._language_code = language_code
+ return self.filter(language_code=language_code)
+
+ def __getitem__(self, k):
+ """
+ Handle getitem special since self.iterator is called *after* the
+ slicing happens, when it's no longer possible to filter a queryest.
+ Therefore the check for _language_code must be done here.
+ """
+ if not self._language_code:
+ return self.language().__getitem__(k)
+ return super(TranslationQueryset, self).__getitem__(k)
+
+ def create(self, **kwargs):
+ if 'language_code' not in kwargs:
+ if self._language_code:
+ kwargs['language_code'] = self._language_code
+ else:
+ kwargs['language_code'] = get_language()
+ obj = self.shared_model(**kwargs)
+ self._for_write = True
+ obj.save(force_insert=True, using=self.db)
+ return obj
+
+ def get(self, *args, **kwargs):
+ """
+ Get an object by querying the translations model and returning a
+ combined instance.
+ """
+ # Enforce a language_code to be used
+ newargs, newkwargs = self._translate_args_kwargs(*args, **kwargs)
+ # Enforce 'select related' onto 'master'
+ # Get the translated instance
+ found = False
+ qs = self
+
+
+ if 'language_code' in newkwargs:
+ language_code = newkwargs.pop('language_code')
+ qs = self.language(language_code)
+ found = True
+ elif args:
+ language_code = None
+ for arg in args:
+ if not isinstance(arg, Q):
+ continue
+ language_code = self._find_language_code(arg)
+ if language_code:
+ break
+ if language_code:
+ qs = self.language(language_code)
+ found = True
+ else:
+ for where in qs.query.where.children:
+ if where.children:
+ for child in where.children:
+ if child[0].field.name == 'language_code':
+ found = True
+ break
+ if found:
+ break
+ if not found:
+ qs = self.language()
+ # self.iterator already combines! Isn't that nice?
+ return QuerySet.get(qs, *newargs, **newkwargs)
+
+ def get_or_create(self, **kwargs):
+ """
+ Looks up an object with the given kwargs, creating one if necessary.
+ Returns a tuple of (object, created), where created is a boolean
+ specifying whether an object was created.
+ """
+ assert kwargs, \
+ 'get_or_create() must be passed at least one keyword argument'
+ defaults = kwargs.pop('defaults', {})
+ lookup = kwargs.copy()
+ for f in self.model._meta.fields:
+ if f.attname in lookup:
+ lookup[f.name] = lookup.pop(f.attname)
+ try:
+ self._for_write = True
+ return self.get(**lookup), False
+ except self.model.DoesNotExist:
+ try:
+ params = dict([(k, v) for k, v in kwargs.items() if '__' not in k])
+ params.update(defaults)
+ # START PATCH
+ if 'language_code' not in params:
+ if self._language_code:
+ params['language_code'] = self._language_code
+ else:
+ params['language_code'] = get_language()
+ obj = self.shared_model(**params)
+ # END PATCH
+ sid = transaction.savepoint(using=self.db)
+ obj.save(force_insert=True, using=self.db)
+ transaction.savepoint_commit(sid, using=self.db)
+ return obj, True
+ except IntegrityError, e:
+ transaction.savepoint_rollback(sid, using=self.db)
+ exc_info = sys.exc_info()
+ try:
+ return self.get(**lookup), False
+ except self.model.DoesNotExist:
+ # Re-raise the IntegrityError with its original traceback.
+ raise exc_info[1], None, exc_info[2]
+
+ def filter(self, *args, **kwargs):
+ newargs, newkwargs = self._translate_args_kwargs(*args, **kwargs)
+ return super(TranslationQueryset, self).filter(*newargs, **newkwargs)
+
+ def aggregate(self, *args, **kwargs):
+ """
+ Loops over all the passed aggregates and translates the fieldnames
+ """
+ newargs, newkwargs = [], {}
+ for arg in args:
+ arg.lookup = self._translate_fieldnames([arg.lookup])[0]
+ newargs.append(arg)
+ for key in kwargs:
+ value = kwargs[key]
+ value.lookup = self._translate_fieldnames([value.lookup])[0]
+ newkwargs[key] = value
+ response = super(TranslationQueryset, self).aggregate(*newargs, **newkwargs)
+ return self._reverse_translate_fieldnames_dict(response)
+
+ def latest(self, field_name=None):
+ if field_name:
+ field_name = self.field_translator.get(field_name)
+ return super(TranslationQueryset, self).latest(field_name)
+
+ def in_bulk(self, id_list):
+ raise NotImplementedError()
+
+ def delete(self):
+ qs = self._get_shared_query_set()
+ qs.delete()
+ delete.alters_data = True
+
+ def delete_translations(self):
+ self.update(master=None)
+ super(TranslationQueryset, self).delete()
+ delete_translations.alters_data = True
+
+ def update(self, **kwargs):
+ shared, translated = self._split_kwargs(**kwargs)
+ count = 0
+ if translated:
+ count += super(TranslationQueryset, self).update(**translated)
+ if shared:
+ shared_qs = self._get_shared_query_set()
+ count += shared_qs.update(**shared)
+ return count
+ update.alters_data = True
+
+ def values(self, *fields):
+ fields = self._translate_fieldnames(fields)
+ return super(TranslationQueryset, self).values(*fields)
+
+ def values_list(self, *fields, **kwargs):
+ fields = self._translate_fieldnames(fields)
+ return super(TranslationQueryset, self).values_list(*fields, **kwargs)
+
+ def dates(self, field_name, kind=None, order='ASC'):
+ field_name = self.field_translator.get(field_name)
+ if int(django.get_version().split('.')[1][0]) <= 2:
+ from nani.compat.date import DateQuerySet
+ return self._clone(klass=DateQuerySet, setup=True,
+ _field_name=field_name, _kind=kind, _order=order)
+ return super(TranslationQueryset, self).dates(field_name, kind=kind, order=order)
+
+ def exclude(self, *args, **kwargs):
+ newargs, newkwargs = self._translate_args_kwargs(*args, **kwargs)
+ return super(TranslationQueryset, self).exclude(*newargs, **newkwargs)
+
+ def complex_filter(self, filter_obj):
+ # Don't know how to handle Q object yet, but it is probably doable...
+ # An unknown type object that supports 'add_to_query' is a different story :)
+ if isinstance(filter_obj, models.Q) or hasattr(filter_obj, 'add_to_query'):
+ raise NotImplementedError()
+
+ newargs, newkwargs = self._translate_args_kwargs(**filter_obj)
+ return super(TranslationQueryset, self)._filter_or_exclude(None, *newargs, **newkwargs)
+
+ def annotate(self, *args, **kwargs):
+ raise NotImplementedError()
+
+ def order_by(self, *field_names):
+ """
+ Returns a new QuerySet instance with the ordering changed.
+ """
+ fieldnames = self._translate_fieldnames(field_names)
+ return super(TranslationQueryset, self).order_by(*fieldnames)
+
+ def reverse(self):
+ return super(TranslationQueryset, self).reverse()
+
+ def defer(self, *fields):
+ raise NotImplementedError()
+
+ def only(self, *fields):
+ raise NotImplementedError()
+
+ def _clone(self, klass=None, setup=False, **kwargs):
+ kwargs.update({
+ '_local_field_names': self._local_field_names,
+ '_field_translator': self._field_translator,
+ '_language_code': self._language_code,
+ '_real_manager': self._real_manager,
+ '_fallback_manager': self._fallback_manager,
+ })
+ if klass:
+ klass = self._get_class(klass)
+ else:
+ klass = self.__class__
+ return super(TranslationQueryset, self)._clone(klass, setup, **kwargs)
+
+ def iterator(self):
+ """
+ If this queryset is not filtered by a language code yet, it should be
+ filtered first by calling self.language.
+
+ If someone doesn't want a queryset filtered by language, they should use
+ Model.objects.untranslated()
+ """
+ if not self._language_code:
+ for obj in self.language().iterator():
+ yield obj
+ else:
+ for obj in super(TranslationQueryset, self).iterator():
+ # non-cascade-deletion hack:
+ if not obj.master:
+ yield obj
+ else:
+ yield combine(obj)
+
+
+class TranslationManager(models.Manager):
+ """
+ Manager class for models with translated fields
+ """
+ #===========================================================================
+ # API
+ #===========================================================================
+
+ def using_translations(self):
+ if not hasattr(self, '_real_manager'):
+ self.contribute_real_manager()
+ qs = TranslationQueryset(self.translations_model, using=self.db, real=self._real_manager)
+ if hasattr(self, 'core_filters'):
+ qs = qs._next_is_sticky().filter(**(self.core_filters))
+ return qs.select_related('master')
+
+ def language(self, language_code=None):
+ return self.using_translations().language(language_code)
+
+ def untranslated(self):
+ return self._fallback_manager.get_query_set()
+
+ #===========================================================================
+ # Internals
+ #===========================================================================
+
+ @property
+ def translations_model(self):
+ """
+ Get the translations model class
+ """
+ return self.model._meta.translations_model
+
+ #def get_query_set(self):
+ # """
+ # Make sure that querysets inherit the methods on this manager (chaining)
+ # """
+ # return self.untranslated()
+
+ def contribute_to_class(self, model, name):
+ super(TranslationManager, self).contribute_to_class(model, name)
+ self.name = name
+ self.contribute_real_manager()
+ self.contribute_fallback_manager()
+
+ def contribute_real_manager(self):
+ self._real_manager = models.Manager()
+ self._real_manager.contribute_to_class(self.model, '_%s' % getattr(self, 'name', 'objects'))
+
+ def contribute_fallback_manager(self):
+ self._fallback_manager = TranslationFallbackManager()
+ self._fallback_manager.contribute_to_class(self.model, '_%s_fallback' % getattr(self, 'name', 'objects'))
+
+
+#===============================================================================
+# Fallbacks
+#===============================================================================
+
+class FallbackQueryset(QuerySet):
+ '''
+ Queryset that tries to load a translated version using fallbacks on a per
+ instance basis.
+ BEWARE: creates a lot of queries!
+ '''
+ def __init__(self, *args, **kwargs):
+ self._translation_fallbacks = None
+ super(FallbackQueryset, self).__init__(*args, **kwargs)
+
+ def _get_real_instances(self, base_results):
+ """
+ The logic for this method was taken from django-polymorphic by Bert
+ Constantin (https://github.com/bconstantin/django_polymorphic) and was
+ slightly altered to fit the needs of django-nani.
+ """
+ # get the primary keys of the shared model results
+ base_ids = [obj.pk for obj in base_results]
+ fallbacks = list(self._translation_fallbacks)
+ # get all translations for the fallbacks chosen for those shared models,
+ # note that this query is *BIG* and might return a lot of data, but it's
+ # arguably faster than running one query for each result or even worse
+ # one query per result per language until we find something
+ translations_manager = self.model._meta.translations_model.objects
+ baseqs = translations_manager.select_related('master')
+ translations = baseqs.filter(language_code__in=fallbacks,
+ master__pk__in=base_ids)
+ fallback_objects = defaultdict(dict)
+ # turn the results into a dict of dicts with shared model primary key as
+ # keys for the first dict and language codes for the second dict
+ for obj in translations:
+ fallback_objects[obj.master.pk][obj.language_code] = obj
+ # iterate over the share dmodel results
+ for instance in base_results:
+ translation = None
+ # find the translation
+ for fallback in fallbacks:
+ translation = fallback_objects[instance.pk].get(fallback, None)
+ if translation is not None:
+ break
+ # if we found a translation, yield the combined result
+ if translation:
+ yield combine(translation)
+ else:
+ # otherwise yield the shared instance only
+ logger.error("no translation for %s, type %s" % (instance, type(instance)))
+
+ def iterator(self):
+ """
+ The logic for this method was taken from django-polymorphic by Bert
+ Constantin (https://github.com/bconstantin/django_polymorphic) and was
+ slightly altered to fit the needs of django-nani.
+ """
+ base_iter = super(FallbackQueryset, self).iterator()
+
+ # only do special stuff when we actually want fallbacks
+ if self._translation_fallbacks:
+ while True:
+ base_result_objects = []
+ reached_end = False
+
+ # get the next "chunk" of results
+ for i in range(CHUNK_SIZE):
+ try:
+ instance = base_iter.next()
+ base_result_objects.append(instance)
+ except StopIteration:
+ reached_end = True
+ break
+
+ # "combine" the results with their fallbacks
+ real_results = self._get_real_instances(base_result_objects)
+
+ # yield em!
+ for instance in real_results:
+ yield instance
+
+ # get out of the while loop if we're at the end, since this is
+ # an iterator, we need to raise StopIteration, not "return".
+ if reached_end:
+ raise StopIteration
+ else:
+ # just iterate over it
+ for instance in base_iter:
+ yield instance
+
+ def use_fallbacks(self, *fallbacks):
+ if fallbacks:
+ self._translation_fallbacks = fallbacks
+ else:
+ self._translation_fallbacks = FALLBACK_LANGUAGES
+ return self
+
+ def _clone(self, klass=None, setup=False, **kwargs):
+ kwargs.update({
+ '_translation_fallbacks': self._translation_fallbacks,
+ })
+ return super(FallbackQueryset, self)._clone(klass, setup, **kwargs)
+
+
+class TranslationFallbackManager(models.Manager):
+ """
+ Manager class for the shared model, without specific translations. Allows
+ using `use_fallbacks()` to enable per object language fallback.
+ """
+ def use_fallbacks(self, *fallbacks):
+ return self.get_query_set().use_fallbacks(*fallbacks)
+
+ def get_query_set(self):
+ qs = FallbackQueryset(self.model, using=self.db)
+ return qs
+
+
+#===============================================================================
+# TranslationAware
+#===============================================================================
+
+
+class TranslationAwareQueryset(QuerySet):
+ def __init__(self, *args, **kwargs):
+ super(TranslationAwareQueryset, self).__init__(*args, **kwargs)
+ self._language_code = None
+
+ def _translate_args_kwargs(self, *args, **kwargs):
+ self.language(self._language_code)
+ language_joins = []
+ newkwargs = {}
+ extra_filters = Q()
+ for key, value in kwargs.items():
+ newkey, langjoins = translate(key, self.model)
+ for langjoin in langjoins:
+ if langjoin not in language_joins:
+ language_joins.append(langjoin)
+ newkwargs[newkey] = value
+ newargs = []
+ for q in args:
+ new_q, langjoins = self._recurse_q(q)
+ newargs.append(new_q)
+ for langjoin in langjoins:
+ if langjoin not in language_joins:
+ language_joins.append(langjoin)
+ for langjoin in language_joins:
+ extra_filters &= Q(**{langjoin: self._language_code})
+ return newargs, newkwargs, extra_filters
+
+ def _recurse_q(self, q):
+ newchildren = []
+ language_joins = []
+ for child in q.children:
+ if isinstance(child, Q):
+ newq = self._recurse_q(child)
+ newchildren.append(self._recurse_q(newq))
+ else:
+ key, value = child
+ newkey, langjoins =translate(key, self.model)
+ newchildren.append((newkey, value))
+ for langjoin in langjoins:
+ if langjoin not in language_joins:
+ language_joins.append(langjoin)
+ q.children = newchildren
+ return q, language_joins
+
+ def _translate_fieldnames(self, fields):
+ self.language(self._language_code)
+ newfields = []
+ extra_filters = Q()
+ language_joins = []
+ for field in fields:
+ newfield, langjoins = translate(field, self.model)
+ newfields.append(newfield)
+ for langjoin in langjoins:
+ if langjoin not in language_joins:
+ language_joins.append(langjoin)
+ for langjoin in language_joins:
+ extra_filters &= Q(**{langjoin: self._language_code})
+ return newfields, extra_filters
+
+ #===========================================================================
+ # Queryset/Manager API
+ #===========================================================================
+
+ def language(self, language_code=None):
+ if not language_code:
+ language_code = get_language()
+ self._language_code = language_code
+ return self
+
+ def get(self, *args, **kwargs):
+ newargs, newkwargs, extra_filters = self._translate_args_kwargs(*args, **kwargs)
+ return self._filter_extra(extra_filters).get(*newargs, **newkwargs)
+
+ def filter(self, *args, **kwargs):
+ newargs, newkwargs, extra_filters = self._translate_args_kwargs(*args, **kwargs)
+ return self._filter_extra(extra_filters).filter(*newargs, **newkwargs)
+
+ def aggregate(self, *args, **kwargs):
+ raise NotImplementedError()
+
+ def latest(self, field_name=None):
+ extra_filters = Q()
+ if field_name:
+ field_name, extra_filters = translate(self, field_name)
+ return self._filter_extra(extra_filters).latest(field_name)
+
+ def in_bulk(self, id_list):
+ raise NotImplementedError()
+
+ def values(self, *fields):
+ fields, extra_filters = self._translate_fieldnames(fields)
+ return self._filter_extra(extra_filters).values(*fields)
+
+ def values_list(self, *fields, **kwargs):
+ fields, extra_filters = self._translate_fieldnames(fields)
+ return self._filter_extra(extra_filters).values_list(*fields, **kwargs)
+
+ def dates(self, field_name, kind, order='ASC'):
+ raise NotImplementedError()
+
+ def exclude(self, *args, **kwargs):
+ newargs, newkwargs, extra_filters = self._translate_args_kwargs(*args, **kwargs)
+ return self._exclude_extra(extra_filters).exclude(*newargs, **newkwargs)
+
+ def complex_filter(self, filter_obj):
+ # admin calls this with an empy filter_obj sometimes
+ if filter_obj == {}:
+ return self
+ raise NotImplementedError()
+
+ def annotate(self, *args, **kwargs):
+ raise NotImplementedError()
+
+ def order_by(self, *field_names):
+ """
+ Returns a new QuerySet instance with the ordering changed.
+ """
+ fieldnames, extra_filters = self._translate_fieldnames(field_names)
+ return self._filter_extra(extra_filters).order_by(*fieldnames)
+
+ def reverse(self):
+ raise NotImplementedError()
+
+ def defer(self, *fields):
+ raise NotImplementedError()
+
+ def only(self, *fields):
+ raise NotImplementedError()
+
+ def _clone(self, klass=None, setup=False, **kwargs):
+ kwargs.update({
+ '_language_code': self._language_code,
+ })
+ return super(TranslationAwareQueryset, self)._clone(klass, setup, **kwargs)
+
+ def _filter_extra(self, extra_filters):
+ qs = super(TranslationAwareQueryset, self).filter(extra_filters)
+ return super(TranslationAwareQueryset, qs)
+
+ def _exclude_extra(self, extra_filters):
+ qs = super(TranslationAwareQueryset, self).exclude(extra_filters)
+ return super(TranslationAwareQueryset, qs)
+
+
+class TranslationAwareManager(models.Manager):
+ def language(self, language_code=None):
+ return self.get_query_set().language(language_code)
+
+ def get_query_set(self):
+ qs = TranslationAwareQueryset(self.model, using=self.db)
+ return qs
+
+
+#===============================================================================
+# Translations Model Manager
+#===============================================================================
+
+
+class TranslationsModelManager(models.Manager):
+ def get_language(self, language):
+ return self.get(language_code=language)
View
305 hvad/models.py
@@ -0,0 +1,305 @@
+from django.core.exceptions import ImproperlyConfigured
+from django.conf import settings
+from django.db import models
+from django.db.models.base import ModelBase
+from django.db.models.signals import post_save
+from django.utils.translation import get_language
+from hvad.descriptors import LanguageCodeAttribute, TranslatedAttribute
+from hvad.manager import TranslationManager, TranslationsModelManager
+from hvad.utils import SmartGetFieldByName
+from types import MethodType
+import sys
+
+
+def create_translations_model(model, related_name, meta, **fields):
+ """
+ Create the translations model for the shared model 'model'.
+ 'related_name' is the related name for the reverse FK from the translations
+ model.
+ 'meta' is a (optional) dictionary of attributes for the translations model's
+ inner Meta class.
+ 'fields' is a dictionary of fields to put on the translations model.
+
+ Two fields are enforced on the translations model:
+
+ language_code: A 15 char, db indexed field.
+ master: A ForeignKey back to the shared model.
+
+ Those two fields are unique together, this get's enforced in the inner Meta
+ class of the translations table
+ """
+ if not meta:
+ meta = {}
+ unique = [('language_code', 'master')]
+ meta['unique_together'] = list(meta.get('unique_together', [])) + unique
+ # Create inner Meta class
+ Meta = type('Meta', (object,), meta)
+ if not hasattr(Meta, 'db_table'):
+ Meta.db_table = model._meta.db_table + '%stranslation' % getattr(settings, 'NANI_TABLE_NAME_SEPARATOR', '_')
+ Meta.app_label = model._meta.app_label
+ name = '%sTranslation' % model.__name__
+ attrs = {}
+ attrs.update(fields)
+ attrs['Meta'] = Meta
+ attrs['__module__'] = model.__module__
+ attrs['objects'] = TranslationsModelManager()
+ attrs['language_code'] = models.CharField(max_length=15, db_index=True)
+ # null=True is so we can prevent cascade deletion
+ attrs['master'] = models.ForeignKey(model, related_name=related_name,
+ editable=False, null=True)
+ # Create and return the new model
+ translations_model = ModelBase(name, (BaseTranslationModel,), attrs)
+ bases = (model.DoesNotExist, translations_model.DoesNotExist,)
+ DNE = type('DoesNotExist', bases, {})
+ translations_model.DoesNotExist = DNE
+ opts = translations_model._meta
+ opts.shared_model = model
+
+ # Register it as a global in the shared model's module.
+ # This is needed so that Translation model instances, and objects which
+ # refer to them, can be properly pickled and unpickled. The Django session
+ # and caching frameworks, in particular, depend on this behaviour.
+ mod = sys.modules[model.__module__]
+ setattr(mod, name, translations_model)
+
+ return translations_model
+
+
+class TranslatedFields(object):
+ """
+ Wrapper class to define translated fields on a model.
+ """
+ def __init__(self, meta=None, **fields):
+ self.fields = fields
+ self.meta = meta
+
+ def contribute_to_class(self, cls, name):
+ """
+ Called from django.db.models.base.ModelBase.__new__
+ """
+ create_translations_model(cls, name, self.meta, **self.fields)
+
+
+class BaseTranslationModel(models.Model):
+ """
+ Needed for detection of translation models. Due to the way dynamic classes
+ are created, we cannot put the 'language_code' field on here.
+ """
+ def __init__(self, *args, **kwargs):
+ super(BaseTranslationModel, self).__init__(*args, **kwargs)
+
+ class Meta:
+ abstract = True
+
+
+class TranslatableModelBase(ModelBase):
+ """
+ Metaclass for models with translated fields (TranslatableModel)
+ """
+ def __new__(cls, name, bases, attrs):
+ super_new = super(TranslatableModelBase, cls).__new__
+ parents = [b for b in bases if isinstance(b, TranslatableModelBase)]
+ if not parents:
+ # If this isn't a subclass of TranslatableModel, don't do anything special.
+ return super_new(cls, name, bases, attrs)
+ new_model = super_new(cls, name, bases, attrs)
+ if not isinstance(new_model.objects, TranslationManager):
+ raise ImproperlyConfigured(
+ "The default manager on a TranslatableModel must be a "
+ "TranslationManager instance or an instance of a subclass of "
+ "TranslationManager, the default manager of %r is not." %
+ new_model)
+
+ opts = new_model._meta
+ if opts.abstract:
+ return new_model
+
+ found = False
+ for relation in new_model.__dict__.keys():
+ try:
+ obj = getattr(new_model, relation)
+ except AttributeError:
+ continue
+ if not hasattr(obj, 'related'):
+ continue
+ if not hasattr(obj.related, 'model'):
+ continue
+ if getattr(obj.related.model._meta, 'shared_model', None) is new_model:
+ if found:
+ raise ImproperlyConfigured(
+ "A TranslatableModel can only define one set of "
+ "TranslatedFields, %r defines more than one: %r to %r "
+ "and %r to %r and possibly more" % (new_model, obj,
+ obj.related.model, found, found.related.model))
+ else:
+ new_model.contribute_translations(obj.related)
+ found = obj
+
+ if not found:
+ raise ImproperlyConfigured(
+ "No TranslatedFields found on %r, subclasses of "
+ "TranslatableModel must define TranslatedFields." % new_model
+ )
+
+ post_save.connect(new_model.save_translations, sender=new_model, weak=False)
+
+ if not isinstance(opts.get_field_by_name, SmartGetFieldByName):
+ smart_get_field_by_name = SmartGetFieldByName(opts.get_field_by_name)
+ opts.get_field_by_name = MethodType(smart_get_field_by_name , opts,
+ opts.__class__)
+
+ return new_model
+
+
+class NoTranslation(object):
+ pass
+
+class TranslatableModel(models.Model):
+ """
+ Base model for all models supporting translated fields (via TranslatedFields).
+ """
+ __metaclass__ = TranslatableModelBase
+
+ # change the default manager to the translation manager
+ objects = TranslationManager()
+
+ class Meta:
+ abstract = True
+
+ def __init__(self, *args, **kwargs):
+ tkwargs = {} # translated fields
+ skwargs = {} # shared fields
+
+ if 'master' in kwargs.keys():
+ raise RuntimeError(
+ "Cannot init %s class with a 'master' argument" % \
+ self.__class__.__name__
+ )
+
+ # filter out all the translated fields (including 'master' and 'language_code')
+ primary_key_names = ('pk', self._meta.pk.name)
+ for key in kwargs.keys():
+ if key in self._translated_field_names:
+ if not key in primary_key_names:
+ # we exclude the pk of the shared model
+ tkwargs[key] = kwargs.pop(key)
+ if not tkwargs.keys():
+ # if there where no translated options, then we assume this is a
+ # regular init and don't want to do any funky stuff
+ super(TranslatableModel, self).__init__(*args, **kwargs)
+ return
+
+ # there was at least one of the translated fields (or a language_code)
+ # in kwargs. We need to do magic.
+ # extract all the shared fields (including the pk)
+ for key in kwargs.keys():
+ if key in self._shared_field_names:
+ skwargs[key] = kwargs.pop(key)
+ # do the regular init minus the translated fields
+ super(TranslatableModel, self).__init__(*args, **skwargs)
+ # prepopulate the translations model cache with an translation model
+ tkwargs['language_code'] = tkwargs.get('language_code', get_language())
+ tkwargs['master'] = self
+ translated = self._meta.translations_model(*args, **tkwargs)
+ setattr(self, self._meta.translations_cache, translated)
+
+ @classmethod
+ def contribute_translations(cls, rel):
+ """
+ Contribute translations options to the inner Meta class and set the
+ descriptors.
+
+ This get's called from TranslatableModelBase.__new__
+ """
+ opts = cls._meta
+ opts.translations_accessor = rel.get_accessor_name()
+ opts.translations_model = rel.model
+ opts.translations_cache = '%s_cache' % rel.get_accessor_name()
+ trans_opts = opts.translations_model._meta
+
+ # Set descriptors
+ ignore_fields = [
+ 'pk',
+ 'master',
+ opts.translations_model._meta.pk.name,
+ ]
+ for field in trans_opts.fields:
+ if field.name in ignore_fields:
+ continue
+ if field.name == 'language_code':
+ attr = LanguageCodeAttribute(opts)
+ else:
+ attr = TranslatedAttribute(opts, field.name)
+ setattr(cls, field.name, attr)
+
+ @classmethod
+ def save_translations(cls, instance, **kwargs):
+ """
+ When this instance is saved, also save the (cached) translation
+ """
+ opts = cls._meta
+ if hasattr(instance, opts.translations_cache):
+ trans = getattr(instance, opts.translations_cache)
+ if not trans.master_id:
+ trans.master = instance
+ trans.save()
+
+ def translate(self, language_code):
+ """
+ Returns an Model instance in the specified language.
+ Does NOT check if the translation already exists!
+ Does NOT interact with the database.
+
+ This will refresh the translations cache attribute on the instance.
+ """
+ tkwargs = {
+ 'language_code': language_code,
+ 'master': self,
+ }
+ translated = self._meta.translations_model(**tkwargs)
+ setattr(self, self._meta.translations_cache, translated)
+ return self
+
+ def safe_translation_getter(self, name, default=None):
+ cache = getattr(self, self._meta.translations_cache, None)
+ if not cache:
+ return default
+ return getattr(cache, name, default)
+
+ def lazy_translation_getter(self, name, default=None):
+ """
+ Lazy translation getter that fetches translations from DB in case the instance is currently untranslated and
+ saves the translation instance in the translation cache
+