Permalink
Browse files

initial commit

  • Loading branch information...
0 parents commit a1b42651daccac3f9e494f179ab51771a2e82a10 Jonas Obrist committed Feb 14, 2011
8 .gitignore
@@ -0,0 +1,8 @@
+*.pyc
+*~
+.*
+dist
+*egg-info*
+*.xml
+htmlcov
+*.sqlite
24 LICENSE
@@ -0,0 +1,24 @@
+Copyright (c) 2011, Jonas Obrist
+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 Jonas Obrist 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 JONAS OBRIST 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.
27 README.rst
@@ -0,0 +1,27 @@
+============
+project-nani
+============
+
+.. warning:: DO NOT USE THIS PROJECT YET! IT HIS *HIGHLY* EXPERIMENTAL,
+ INCOMPLETE AND SUBJECT TO CHANGE WITHOUT PRIOR NOTICE!
+
+******************
+About this project
+******************
+
+This project is yet another attempt at making model translations suck less in
+Django.
+
+Planned Features
+----------------
+
+* Painless API for translated content in models
+* Painless admin/form integration
+* Predictable (in contrast to my previous attempt, django-multilingual-ng)
+* High level (no custom SQL Compiler or other scary things)
+* Few and simple queries
+
+Again, DO NOT USE THIS! I'm serious, DON'T! I will *NOT* support this in any way
+yet.
+
+You are however very welcome to contribute to the project.
0 nani/__init__.py
No changes.
67 nani/admin.py
@@ -0,0 +1,67 @@
+from django.contrib.admin.options import ModelAdmin
+from django.contrib.admin.util import flatten_fieldsets
+from django.utils.functional import curry
+from nani.forms import TranslateableModelForm
+
+
+def translateable_modelform_factory(model, form=TranslateableModelForm,
+ 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
+ }
+ return type(class_name, (form,), form_class_attrs)
+
+class TranslateableAdmin(ModelAdmin):
+
+ form = TranslateableModelForm
+
+ 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))
+ # if exclude is an empty list we pass None to be consistant with the
+ # default on modelform_factory
+ exclude = exclude or None
+ defaults = {
+ "form": self.form,
+ "fields": fields,
+ "exclude": exclude,
+ "formfield_callback": curry(self.formfield_for_dbfield, request=request),
+ }
+ defaults.update(kwargs)
+ return translateable_modelform_factory(self.model, **defaults)
78 nani/descriptors.py
@@ -0,0 +1,78 @@
+from nani.manager 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:
+ 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):
+ if not instance:
+ raise AttributeError()
+ setattr(self.translation(instance), self.name, value)
+
+ def __delete__(self, instance):
+ if not instance:
+ raise AttributeError()
+ 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):
+ if not instance:
+ raise AttributeError()
+ tmodel = instance._meta.translations_model
+ try:
+ other_lang = get_translation(instance, value)
+ except tmodel.DoesNotExist:
+ other_lang = tmodel()
+ for field in other_lang._meta.get_all_field_names():
+ val = getattr(instance, field, NULL)
+ if val is NULL:
+ continue
+ if field == 'pk':
+ continue
+ if field == tmodel._meta.pk.name:
+ continue
+ setattr(other_lang, field, getattr(instance, field, None))
+ other_lang.language_code = value
+ other_lang.master = instance
+ setattr(instance, instance._meta.translations_cache, other_lang)
+
+ def __delete__(self, instance):
+ if not instance:
+ raise AttributeError()
+ raise AttributeError("The 'language_code' attribute cannot be deleted!")
3 nani/fields.py
@@ -0,0 +1,3 @@
+from django.db.models.fields.related import ForeignKey
+
+class TranslatedForeignKey(ForeignKey): pass
111 nani/forms.py
@@ -0,0 +1,111 @@
+from django.core.exceptions import FieldError
+from django.forms.forms import get_declared_fields
+from django.forms.models import (ModelForm, ModelFormMetaclass, ModelFormOptions,
+ fields_for_model, model_to_dict, save_instance)
+from django.forms.util import ErrorList
+from django.forms.widgets import media_property
+from django.utils.translation import get_language
+from nani.manager import get_translation, get_cached_translation, combine
+from nani.models import TranslateableModel
+
+class TranslateableModelFormMetaclass(ModelFormMetaclass):
+ def __new__(cls, name, bases, attrs):
+ super_new = super(TranslateableModelFormMetaclass, 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)
+
+ 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:
+ if not issubclass(opts.model, TranslateableModel):
+ raise Exception(
+ "Only TranslateableModel subclasses may use TranslateableModelForm"
+ )
+ mopts = opts.model._meta
+
+ shared_fields = mopts.get_all_field_names()
+
+ 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]
+
+ # 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)
+ else:
+ fields = declared_fields
+ new_class.declared_fields = declared_fields
+ new_class.base_fields = fields
+ return new_class
+
+
+class TranslateableModelForm(ModelForm):
+ __metaclass__ = TranslateableModelFormMetaclass
+
+ class Meta:
+ exclude = ('language_code', 'translations', 'master',)
+
+ 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 = {}
+ if instance is not None:
+ trans = get_cached_translation(instance)
+ if not trans:
+ try:
+ trans = get_translation(instance)
+ 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(TranslateableModelForm, 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'
+ else:
+ fail_message = 'changed'
+ super(TranslateableModelForm, self).save(True)
+ trans_model = self.instance._meta.translations_model
+ language_code = self.cleaned_data.get('language_code', get_language())
+ trans = get_cached_translation(self.instance)
+ if not trans:
+ try:
+ trans = get_translation(self.instance, language_code)
+ except trans_model.DoesNotExist:
+ trans = trans_model()
+ trans = save_instance(self, trans, self._meta.fields, fail_message,
+ commit, construct=True)
+ trans.language_code = language_code
+ trans.master = self.instance
+ return combine(trans)
174 nani/manager.py
@@ -0,0 +1,174 @@
+from django.db import models
+from django.db.models.query import QuerySet
+from django.db.models.query_utils import Q
+from django.utils.translation import get_language
+
+
+def combine(trans):
+ """
+ 'Combine' the shared and translated instances by setting the translation
+ on the 'translations_cache' attribute of the shared instance and returning
+ the shared instance.
+ """
+ combined = trans.master
+ opts = combined._meta
+ setattr(combined, opts.translations_cache, trans)
+ return combined
+
+def get_cached_translation(instance):
+ return getattr(instance, instance._meta.translations_cache, None)
+
+def get_translation(instance, language_code=None):
+ opts = instance._meta
+ if not language_code:
+ language_code = get_language()
+ accessor = getattr(instance, opts.translations_accessor)
+ return accessor.get(language_code=language_code)
+
+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.model._meta.get_all_field_names())
+ self.translated_fields = tuple(self.manager.translations_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):
+ if key.startswith(self.shared_fields):
+ return 'master__%s' % key
+ else:
+ return key
+
+
+class TranslationManager(models.Manager):
+ """
+ Manager class for models with translated fields
+ """
+ def __init__(self):
+ self._local_field_names = None
+ self._field_translator = None
+ super(TranslationManager, self).__init__()
+
+ @property
+ def translations_manager(self):
+ """
+ Get manager of translations model
+ """
+ return self.translations_model.objects
+
+ @property
+ def translations_accessor(self):
+ """
+ Get the name of the reverse FK from the shared model
+ """
+ return self.model._meta.translations_accessor
+
+ @property
+ def translations_model(self):
+ """
+ Get the translations model class
+ """
+ return self.model._meta.translations_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 local_field_names(self):
+ if self._local_field_names is None:
+ self._local_field_names = self.model._meta.get_all_field_names()
+ return self._local_field_names
+
+ def create(self, **kwargs):
+ """
+ When we create an instance, what we actually need to do is create two
+ separate instances: One shared, and one translated.
+ For this, we split the 'kwargs' into translated and shared kwargs
+ and set the 'master' FK from in the translated kwargs to the shared
+ instance.
+ If 'language_code' is not given in kwargs, set it to the current
+ language.
+ """
+ tkwargs = {}
+ for key in kwargs.keys():
+ if not key in self.local_field_names:
+ tkwargs[key] = kwargs.pop(key)
+ # Enforce the language_code kwarg
+ if 'language_code' not in tkwargs:
+ tkwargs['language_code'] = get_language()
+ # Allow a pre-existing master to be passed, but only if no shared fields
+ # are given.
+ if 'master' in tkwargs:
+ if kwargs:
+ raise RuntimeError(
+ "Cannot explicitly use a master (shared) instance and shared fields in create"
+ )
+ else:
+ # create shared instance
+ shared = super(TranslationManager, self).create(**kwargs)
+ tkwargs['master'] = shared
+ # create translated instance
+ trans = self.translations_model.objects.create(**tkwargs)
+ # return combined instance
+ return combine(trans)
+
+ 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
+ if not 'language_code' in kwargs:
+ kwargs['language_code'] = get_language()
+ # 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))
+ # Enforce 'select related' onto 'mastser'
+ qs = self.translations_manager.select_related('master')
+ # Get the translated instance
+ trans = qs.get(*newargs, **newkwargs)
+ # Return a combined instance
+ return combine(trans)
+
+ def recurse_q(self, q):
+ """
+ Recursively translate fieldnames in a Q object.
+ """
+ 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 get_queryset(self):
+ qs = super(TranslationManager, self).get_queryset()
+ bases = [QuerySet, TranslationManager]
+ new_queryset_cls = type('TranslationManagerQueryset', tuple(bases), {})
+ qs.__class__ = new_queryset_cls
+ return qs
152 nani/models.py
@@ -0,0 +1,152 @@
+from django.core.exceptions import ImproperlyConfigured
+from django.db import models
+from django.db.models.base import ModelBase
+from django.db.models.signals import post_save
+from nani.descriptors import LanguageCodeAttribute, TranslatedAttribute
+from nani.manager import TranslationManager
+
+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)
+ name = '%sTranslations' % model.__name__
+ attrs = {}
+ attrs.update(fields)
+ attrs['Meta'] = Meta
+ attrs['__module__'] = model.__module__
+ attrs['language_code'] = models.CharField(max_length=15, db_index=True)
+ attrs['master'] = models.ForeignKey(model, related_name=related_name, editable=False)
+ # Create and return the new model
+ return ModelBase(name, (BaseTranslationModel,), attrs)
+
+
+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.
+ """
+ class Meta:
+ abstract = True
+
+
+class TranslateableModelBase(ModelBase):
+ """
+ Metaclass for models with translated fields (TranslatableModel)
+ """
+ def __new__(cls, name, bases, attrs):
+ super_new = super(TranslateableModelBase, cls).__new__
+ parents = [b for b in bases if isinstance(b, TranslateableModelBase)]
+ if not parents:
+ # If this isn't a subclass of TranslateableModel, 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("wrong manager")
+
+ found = False
+ local_field_names = [ff.name for ff in new_model._meta.fields]
+ field_names = new_model._meta.get_all_field_names()
+ for relation in [f for f in field_names if not f in local_field_names]:
+ obj = getattr(new_model, relation)
+ if not hasattr(obj, 'related'):
+ continue
+ if not hasattr(obj.related, 'model'):
+ continue
+ if issubclass(obj.related.model, BaseTranslationModel):
+ if found:
+ raise ImproperlyConfigured("more than one")
+ else:
+ new_model.contribute_translations(obj.related)
+ found = True
+
+ if not found:
+ raise ImproperlyConfigured("not found)")
+
+ post_save.connect(new_model.save_translations, sender=new_model, weak=False)
+
+ return new_model
+
+
+class TranslateableModel(models.Model):
+ """
+ Base model for all models supporting translated fields (via TranslatedFields).
+ """
+ __metaclass__ = TranslateableModelBase
+
+ # change the default manager to the translation manager
+ objects = TranslationManager()
+
+ class Meta:
+ abstract = True
+
+ @classmethod
+ def contribute_translations(cls, rel):
+ """
+ Contribute translations options to the inner Meta class and set the
+ descriptors.
+
+ This get's called from TranslateableModelBase.__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
+ for field in trans_opts.fields:
+ if field.name == 'pk':
+ continue
+ if field.name == 'master':
+ continue
+ if field.name == opts.translations_model._meta.pk.name:
+ 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)
+ trans.save()
0 nani/test_utils/__init__.py
No changes.
138 nani/test_utils/context_managers.py
@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+from django.conf import settings
+from django.utils.translation import get_language, activate
+from shutil import rmtree as _rmtree
+from tempfile import template, mkdtemp, _exists
+import StringIO
+import sys
+
+class NULL:
+ pass
+
+class SettingsOverride(object):
+ """
+ Overrides Django settings within a context and resets them to their inital
+ values on exit.
+
+ Example:
+
+ with SettingsOverride(DEBUG=True):
+ # do something
+ """
+
+ def __init__(self, **overrides):
+ self.overrides = overrides
+
+ def __enter__(self):
+ self.old = {}
+ for key, value in self.overrides.items():
+ self.old[key] = getattr(settings, key, NULL)
+ setattr(settings, key, value)
+
+ def __exit__(self, type, value, traceback):
+ for key, value in self.old.items():
+ if value is not NULL:
+ setattr(settings, key, value)
+ else:
+ delattr(settings,key) # do not pollute the context!
+
+
+class StdoutOverride(object):
+ """
+ This overrides Python's the standard output and redirrects it to a StringIO
+ object, so that on can test the output of the program.
+
+ example:
+ lines = None
+ with StdoutOverride() as buffer:
+ # print stuff
+ lines = buffer.getvalue()
+ """
+ def __enter__(self):
+ self.buffer = StringIO.StringIO()
+ sys.stdout = self.buffer
+ return self.buffer
+
+ def __exit__(self, type, value, traceback):
+ self.buffer.close()
+ # Revert the stdout to the real one
+ sys.stdout = sys.__stdout__
+
+
+
+class LanguageOverride(object):
+ def __init__(self, language):
+ self.newlang = language
+
+ def __enter__(self):
+ self.oldlang = get_language()
+ activate(self.newlang)
+
+ def __exit__(self, type, value, traceback):
+ activate(self.oldlang)
+
+
+class TemporaryDirectory:
+ """Create and return a temporary directory. This has the same
+ behavior as mkdtemp but can be used as a context manager. For
+ example:
+
+ with TemporaryDirectory() as tmpdir:
+ ...
+
+ Upon exiting the context, the directory and everthing contained
+ in it are removed.
+ """
+
+ def __init__(self, suffix="", prefix=template, dir=None):
+ self.name = mkdtemp(suffix, prefix, dir)
+
+ def __enter__(self):
+ return self.name
+
+ def cleanup(self):
+ if _exists(self.name):
+ _rmtree(self.name)
+
+ def __exit__(self, exc, value, tb):
+ self.cleanup()
+
+
+class UserLoginContext(object):
+ def __init__(self, testcase, user):
+ self.testcase = testcase
+ self.user = user
+
+ def __enter__(self):
+ self.testcase.login_user(self.user)
+
+ def __exit__(self, exc, value, tb):
+ self.testcase.user = None
+ self.testcase.client.logout()
+
+
+class ChangeModel(object):
+ """
+ Changes attributes on a model while within the context.
+
+ These changes *ARE* saved to the database for the context!
+ """
+ def __init__(self, instance, **overrides):
+ self.instance = instance
+ self.overrides = overrides
+
+ def __enter__(self):
+ self.old = {}
+ for key, value in self.overrides.items():
+ self.old[key] = getattr(self.instance, key, NULL)
+ setattr(self.instance, key, value)
+ self.instance.save()
+
+ def __exit__(self, exc, value, tb):
+ for key in self.overrides.keys():
+ old_value = self.old[key]
+ if old_value is NULL:
+ delattr(self.instance, key)
+ else:
+ setattr(self.instance, key, old_value)
+ self.instance.save()
79 nani/test_utils/testcase.py
@@ -0,0 +1,79 @@
+from django.conf import settings
+from django.core.signals import request_started
+from django.db import reset_queries, connections
+from django.db.utils import DEFAULT_DB_ALIAS
+from django.test import testcases
+from nani.models import TranslateableModel
+from testproject.app.models import Normal
+import sys
+
+class _AssertNumQueriesContext(object):
+ def __init__(self, test_case, num, connection):
+ self.test_case = test_case
+ self.num = num
+ self.connection = connection
+
+ def __enter__(self):
+ self.old_debug = settings.DEBUG
+ settings.DEBUG = True
+ self.starting_queries = len(self.connection.queries)
+ request_started.disconnect(reset_queries)
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ settings.DEBUG = self.old_debug
+ request_started.connect(reset_queries)
+ if exc_type is not None:
+ return
+
+ final_queries = len(self.connection.queries)
+ executed = final_queries - self.starting_queries
+
+ self.test_case.assertEqual(
+ executed, self.num, "%d queries executed, %d expected" % (
+ executed, self.num
+ )
+ )
+
+if hasattr(testcases.TestCase, 'assertNumQueries'):
+ TestCase = testcases.TestCase
+else:
+ class TestCase(testcases.TestCase):
+ def assertNumQueries(self, num, func=None, *args, **kwargs):
+ if hasattr(testcases.TestCase, 'assertNumQueries'):
+ return super(TestCase, self).assertNumQueries(num, func, *args, **kwargs)
+ return self._assertNumQueries(num, func, *args, **kwargs)
+
+ def _assertNumQueries(self, num, func=None, *args, **kwargs):
+ """
+ Backport from Django 1.3 for Django 1.2
+ """
+ using = kwargs.pop("using", DEFAULT_DB_ALIAS)
+ connection = connections[using]
+
+ context = _AssertNumQueriesContext(self, num, connection)
+ if func is None:
+ return context
+
+ # Basically emulate the `with` statement here.
+
+ context.__enter__()
+ try:
+ func(*args, **kwargs)
+ except:
+ context.__exit__(*sys.exc_info())
+ raise
+ else:
+ context.__exit__(*sys.exc_info())
+
+class NaniTestCase(TestCase):
+ def reload(self, obj):
+ model = obj.__class__
+ return model.objects.get(**{obj._meta.pk.name: obj.pk})
+
+
+class SingleNormalTestCase(NaniTestCase):
+ fixtures = ['single_normal.json']
+
+ def get_obj(self):
+ return Normal.objects.get(pk=1, language_code='en')
5 nani/tests/__init__.py
@@ -0,0 +1,5 @@
+from nani.tests.admin import NormalAdminTests
+from nani.tests.basic import (OptionsTest, BasicQueryTest, CreateTest, GetTest,
+ TranslatedTest, DeleteLanguageCodeTest)
+from nani.tests.related import (NormalToNormalFKTest, TransToNormalFKTest,
+ TransToTransFKTest, NormalToTransFKTest)
54 nani/tests/admin.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from django.core.urlresolvers import reverse
+from nani.test_utils.context_managers import LanguageOverride
+from nani.test_utils.testcase import NaniTestCase
+from testproject.app.models import Normal
+
+
+class NormalAdminTests(NaniTestCase):
+ fixtures = ['superuser.json']
+
+ def test_admin_simple(self):
+ SHARED = 'shared'
+ TRANS = 'trans'
+ self.client.login(username='admin', password='admin')
+ url = reverse('admin:app_normal_add')
+ data = {
+ 'shared_field': SHARED,
+ 'translated_field': TRANS,
+ }
+ response = self.client.post(url, data)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(Normal.objects.count(), 1)
+ obj = Normal.objects.all()[0]
+ self.assertEqual(obj.shared_field, SHARED)
+ self.assertEqual(obj.translated_field, TRANS)
+
+ def test_admin_dual(self):
+ SHARED = 'shared'
+ TRANS_EN = 'English'
+ TRANS_JA = u'日本語'
+ self.client.login(username='admin', password='admin')
+ url = reverse('admin:app_normal_add')
+ data_en = {
+ 'shared_field': SHARED,
+ 'translated_field': TRANS_EN,
+ }
+ data_ja = {
+ 'shared_field': SHARED,
+ 'translated_field': TRANS_JA,
+ }
+ with LanguageOverride('en'):
+ response = self.client.post(url, data_en)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(Normal.objects.count(), 1)
+ with LanguageOverride('ja'):
+ response = self.client.post(url, data_ja)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(Normal.objects.count(), 2)
+ en = Normal.objects.get(language_code='en')
+ self.assertEqual(en.shared_field, SHARED)
+ self.assertEqual(en.translated_field, TRANS_EN)
+ ja = Normal.objects.get(language_code='ja')
+ self.assertEqual(ja.shared_field, SHARED)
+ self.assertEqual(ja.translated_field, TRANS_JA)
85 nani/tests/basic.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+from __future__ import with_statement
+from nani.test_utils.context_managers import LanguageOverride
+from nani.test_utils.testcase import NaniTestCase, SingleNormalTestCase
+from testproject.app.models import Normal
+
+
+class OptionsTest(NaniTestCase):
+ def test_options(self):
+ opts = Normal._meta
+ self.assertTrue(hasattr(opts, 'translations_model'))
+ self.assertTrue(hasattr(opts, 'translations_accessor'))
+ relmodel = Normal._meta.get_field_by_name(opts.translations_accessor)[0].model
+ self.assertEqual(relmodel, opts.translations_model)
+
+
+class CreateTest(NaniTestCase):
+ def test_create(self):
+ with self.assertNumQueries(2):
+ en = Normal.objects.create(
+ shared_field="shared",
+ translated_field='English',
+ language_code='en'
+ )
+ self.assertEqual(en.shared_field, "shared")
+ self.assertEqual(en.translated_field, "English")
+ self.assertEqual(en.language_code, "en")
+
+
+class TranslatedTest(SingleNormalTestCase):
+ def test_translate(self):
+ SHARED_EN = 'shared'
+ TRANS_EN = 'English'
+ SHARED_JA = 'shared'
+ TRANS_JA = u'日本語'
+ en = self.get_obj()
+ self.assertEqual(Normal._meta.translations_model.objects.count(), 1)
+ self.assertEqual(en.shared_field, SHARED_EN)
+ self.assertEqual(en.translated_field, TRANS_EN)
+ ja = en
+ ja.language_code = 'ja'
+ ja.save()
+ self.assertEqual(Normal._meta.translations_model.objects.count(), 2)
+ self.assertEqual(ja.shared_field, SHARED_JA)
+ self.assertEqual(ja.translated_field, TRANS_EN)
+ ja.translated_field = TRANS_JA
+ ja.save()
+ self.assertEqual(Normal._meta.translations_model.objects.count(), 2)
+ self.assertEqual(ja.shared_field, SHARED_JA)
+ self.assertEqual(ja.translated_field, TRANS_JA)
+ with LanguageOverride('en'):
+ obj = self.reload(ja)
+ self.assertEqual(obj.shared_field, SHARED_EN)
+ self.assertEqual(obj.translated_field, TRANS_EN)
+ with LanguageOverride('ja'):
+ obj = self.reload(en)
+ self.assertEqual(obj.shared_field, SHARED_JA)
+ self.assertEqual(obj.translated_field, TRANS_JA)
+
+
+class GetTest(SingleNormalTestCase):
+ def test_get(self):
+ en = self.get_obj()
+ with self.assertNumQueries(1):
+ got = Normal.objects.get(pk=en.pk, language_code='en')
+ with self.assertNumQueries(0):
+ self.assertEqual(got.shared_field, "shared")
+ self.assertEqual(got.translated_field, "English")
+ self.assertEqual(got.language_code, "en")
+
+
+class BasicQueryTest(SingleNormalTestCase):
+ def test_basic(self):
+ en = self.get_obj()
+ with self.assertNumQueries(1):
+ queried = Normal.objects.get(pk=en.pk, language_code='en')
+ self.assertEqual(queried.shared_field, en.shared_field)
+ self.assertEqual(queried.translated_field, en.translated_field)
+ self.assertEqual(queried.language_code, en.language_code)
+
+
+class DeleteLanguageCodeTest(SingleNormalTestCase):
+ def test_delete_language_code(self):
+ en = self.get_obj()
+ self.assertRaises(AttributeError, delattr, en, 'language_code')
39 nani/tests/related.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+from nani.test_utils.context_managers import LanguageOverride
+from nani.test_utils.testcase import SingleNormalTestCase, NaniTestCase
+from testproject.app.models import Normal, Related
+
+
+class NormalToNormalFKTest(SingleNormalTestCase):
+ def test_relation(self):
+ normal = self.get_obj()
+ related = Related.objects.create(normal=normal)
+ self.assertEqual(related.normal.pk, normal.pk)
+ self.assertEqual(related.normal.shared_field, normal.shared_field)
+ self.assertEqual(related.normal.translated_field, normal.translated_field)
+ self.assertTrue(related in normal.rel1.all())
+
+
+class NormalToTransFKTest(SingleNormalTestCase):
+ def test_relation(self):
+ en = self.get_obj()
+ ja = en
+ ja.language_code = 'ja'
+ ja.translated_field = u''
+ ja.save()
+ self.assertEqual(Normal._meta.translations_model.objects.count(), 2)
+ related = Related.objects.create(normal_trans=ja)
+ with LanguageOverride('en'):
+ related = self.reload(related)
+ self.assertEqual(related.normal_trans.pk, ja.pk)
+ self.assertEqual(related.normal_trans.shared_field, ja.shared_field)
+ self.assertEqual(related.normal_trans.translated_field, ja.translated_field)
+ self.assertTrue(related in ja.rel2.all())
+
+
+class TransToNormalFKTest(NaniTestCase):
+ pass
+
+
+class TransToTransFKTest(NaniTestCase):
+ pass
13 setup.py
@@ -0,0 +1,13 @@
+from setuptools import setup, find_packages
+
+version = 'EXPERIMENTAL'
+
+setup(
+ name = 'project-nani',
+ version = version,
+ description = 'EXPERIMENTAL new multilingual database content app',
+ author = 'Jonas Obrist',
+ author_email = 'jonas.obrist@divio.ch',
+ packages = find_packages(),
+ zip_safe=False,
+)
0 testproject/__init__.py
No changes.
0 testproject/app/__init__.py
No changes.
6 testproject/app/admin.py
@@ -0,0 +1,6 @@
+from django.contrib import admin
+from nani.admin import TranslateableAdmin
+from models import Normal
+
+
+admin.site.register(Normal, TranslateableAdmin)
19 testproject/app/models.py
@@ -0,0 +1,19 @@
+from django.db import models
+from nani.fields import TranslatedForeignKey
+from nani.models import TranslateableModel, TranslatedFields
+
+class Normal(TranslateableModel):
+ shared_field = models.CharField(max_length=255)
+ translations = TranslatedFields(
+ translated_field = models.CharField(max_length=255)
+ )
+
+
+class Related(TranslateableModel):
+ normal = models.ForeignKey(Normal, related_name='rel1', null=True)
+ normal_trans = TranslatedForeignKey(Normal, related_name='rel2', null=True)
+
+ translated_fields = TranslatedFields(
+ translated = models.ForeignKey(Normal, related_name='rel3', null=True),
+ translated_to_translated = models.ForeignKey(Normal, related_name='rel4', null=True),
+ )
1 testproject/fixtures/single_normal.json
@@ -0,0 +1 @@
+[{"pk": 1, "model": "app.normaltranslations", "fields": {"language_code": "en", "master": 1, "translated_field": "English"}}, {"pk": 1, "model": "app.normal", "fields": {"shared_field": "shared"}}]
1 testproject/fixtures/superuser.json
@@ -0,0 +1 @@
+[{"pk": 13, "model": "auth.permission", "fields": {"codename": "add_logentry", "name": "Can add log entry", "content_type": 5}}, {"pk": 14, "model": "auth.permission", "fields": {"codename": "change_logentry", "name": "Can change log entry", "content_type": 5}}, {"pk": 15, "model": "auth.permission", "fields": {"codename": "delete_logentry", "name": "Can delete log entry", "content_type": 5}}, {"pk": 28, "model": "auth.permission", "fields": {"codename": "add_normal", "name": "Can add normal", "content_type": 10}}, {"pk": 29, "model": "auth.permission", "fields": {"codename": "change_normal", "name": "Can change normal", "content_type": 10}}, {"pk": 30, "model": "auth.permission", "fields": {"codename": "delete_normal", "name": "Can delete normal", "content_type": 10}}, {"pk": 25, "model": "auth.permission", "fields": {"codename": "add_normaltranslations", "name": "Can add normal translations", "content_type": 9}}, {"pk": 26, "model": "auth.permission", "fields": {"codename": "change_normaltranslations", "name": "Can change normal translations", "content_type": 9}}, {"pk": 27, "model": "auth.permission", "fields": {"codename": "delete_normaltranslations", "name": "Can delete normal translations", "content_type": 9}}, {"pk": 34, "model": "auth.permission", "fields": {"codename": "add_related", "name": "Can add related", "content_type": 12}}, {"pk": 35, "model": "auth.permission", "fields": {"codename": "change_related", "name": "Can change related", "content_type": 12}}, {"pk": 36, "model": "auth.permission", "fields": {"codename": "delete_related", "name": "Can delete related", "content_type": 12}}, {"pk": 31, "model": "auth.permission", "fields": {"codename": "add_relatedtranslations", "name": "Can add related translations", "content_type": 11}}, {"pk": 32, "model": "auth.permission", "fields": {"codename": "change_relatedtranslations", "name": "Can change related translations", "content_type": 11}}, {"pk": 33, "model": "auth.permission", "fields": {"codename": "delete_relatedtranslations", "name": "Can delete related translations", "content_type": 11}}, {"pk": 4, "model": "auth.permission", "fields": {"codename": "add_group", "name": "Can add group", "content_type": 2}}, {"pk": 5, "model": "auth.permission", "fields": {"codename": "change_group", "name": "Can change group", "content_type": 2}}, {"pk": 6, "model": "auth.permission", "fields": {"codename": "delete_group", "name": "Can delete group", "content_type": 2}}, {"pk": 10, "model": "auth.permission", "fields": {"codename": "add_message", "name": "Can add message", "content_type": 4}}, {"pk": 11, "model": "auth.permission", "fields": {"codename": "change_message", "name": "Can change message", "content_type": 4}}, {"pk": 12, "model": "auth.permission", "fields": {"codename": "delete_message", "name": "Can delete message", "content_type": 4}}, {"pk": 1, "model": "auth.permission", "fields": {"codename": "add_permission", "name": "Can add permission", "content_type": 1}}, {"pk": 2, "model": "auth.permission", "fields": {"codename": "change_permission", "name": "Can change permission", "content_type": 1}}, {"pk": 3, "model": "auth.permission", "fields": {"codename": "delete_permission", "name": "Can delete permission", "content_type": 1}}, {"pk": 7, "model": "auth.permission", "fields": {"codename": "add_user", "name": "Can add user", "content_type": 3}}, {"pk": 8, "model": "auth.permission", "fields": {"codename": "change_user", "name": "Can change user", "content_type": 3}}, {"pk": 9, "model": "auth.permission", "fields": {"codename": "delete_user", "name": "Can delete user", "content_type": 3}}, {"pk": 16, "model": "auth.permission", "fields": {"codename": "add_contenttype", "name": "Can add content type", "content_type": 6}}, {"pk": 17, "model": "auth.permission", "fields": {"codename": "change_contenttype", "name": "Can change content type", "content_type": 6}}, {"pk": 18, "model": "auth.permission", "fields": {"codename": "delete_contenttype", "name": "Can delete content type", "content_type": 6}}, {"pk": 19, "model": "auth.permission", "fields": {"codename": "add_session", "name": "Can add session", "content_type": 7}}, {"pk": 20, "model": "auth.permission", "fields": {"codename": "change_session", "name": "Can change session", "content_type": 7}}, {"pk": 21, "model": "auth.permission", "fields": {"codename": "delete_session", "name": "Can delete session", "content_type": 7}}, {"pk": 22, "model": "auth.permission", "fields": {"codename": "add_site", "name": "Can add site", "content_type": 8}}, {"pk": 23, "model": "auth.permission", "fields": {"codename": "change_site", "name": "Can change site", "content_type": 8}}, {"pk": 24, "model": "auth.permission", "fields": {"codename": "delete_site", "name": "Can delete site", "content_type": 8}}, {"pk": 1, "model": "auth.user", "fields": {"username": "admin", "first_name": "", "last_name": "", "is_active": true, "is_superuser": true, "is_staff": true, "last_login": "2011-02-14 10:45:40", "groups": [], "user_permissions": [], "password": "sha1$96f91$97f85aefe0f8cb46b0ca666faea481606d430850", "email": "admin@admin.com", "date_joined": "2011-02-14 10:28:15"}}]
16 testproject/manage.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+import sys
+import os
+
+try:
+ import settings # Assumed to be in the same directory.
+except ImportError:
+ sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+ sys.exit(1)
+
+BASEDIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+sys.path.insert(0, BASEDIR)
+
+if __name__ == "__main__":
+ execute_manager(settings)
41 testproject/settings.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+import os
+
+PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
+
+DATABASE_ENGINE = 'sqlite3'
+DATABASE_NAME = 'nani.sqlite'
+
+TEST_DATABASE_CHARSET = "utf8"
+TEST_DATABASE_COLLATION = "utf8_general_ci"
+
+DATABASE_SUPPORTS_TRANSACTIONS = True
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.admin',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.sites',
+
+ 'testproject.app',
+ 'nani',
+)
+
+LANGUAGE_CODE = "en"
+
+LANGUAGES = (
+ ('en', 'English'),
+ ('ja', u'日本語'),
+)
+
+SOUTH_TESTS_MIGRATE = False
+
+FIXTURE_DIRS = (
+ os.path.join(PROJECT_PATH, 'fixtures'),
+)
+
+ROOT_URLCONF = 'testproject.urls'
+
+DEBUG = True
+TEMPLATE_DEBUG = True
8 testproject/urls.py
@@ -0,0 +1,8 @@
+from django.conf.urls.defaults import *
+
+from django.contrib import admin
+admin.autodiscover()
+
+urlpatterns = patterns('',
+ (r'^admin/', include(admin.site.urls)),
+)

0 comments on commit a1b4265

Please sign in to comment.