This repository has been archived by the owner on Mar 15, 2018. It is now read-only.
/
fields.py
276 lines (215 loc) · 10 KB
/
fields.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
from django import forms
from django.conf import settings
from django.db import models
from django.db.models.fields import related
from django.utils import translation as translation_utils
from django.utils.translation.trans_real import to_language
from .models import Translation, PurifiedTranslation, LinkifiedTranslation
from .widgets import TransInput, TransTextarea
class TranslatedField(models.ForeignKey):
"""
A foreign key to the translations table.
If require_locale=False, the fallback join will not use a locale. Instead,
we will look for 1) a translation in the current locale and 2) fallback
with any translation matching the foreign key.
"""
to = Translation
def __init__(self, **kwargs):
# to_field: The field on the related object that the relation is to.
# Django wants to default to translations.autoid, but we need id.
options = dict(null=True, to_field='id', unique=True, blank=True)
kwargs.update(options)
self.short = kwargs.pop('short', True)
self.require_locale = kwargs.pop('require_locale', True)
super(TranslatedField, self).__init__(self.to, **kwargs)
@property
def db_column(self):
# Django wants to call the db_column ('%s_id' % self.name), but our
# translations foreign keys aren't set up that way.
return self._db_column if hasattr(self, '_db_column') else self.name
@db_column.setter
def db_column(self, value):
# Django sets db_column=None to initialize it. I don't think anyone
# would set the db_column otherwise.
if value is not None:
self._db_column = value
def contribute_to_class(self, cls, name):
"""Add this Translation to ``cls._meta.translated_fields``."""
super(TranslatedField, self).contribute_to_class(cls, name)
# Add self to the list of translated fields.
if hasattr(cls._meta, 'translated_fields'):
cls._meta.translated_fields.append(self)
else:
cls._meta.translated_fields = [self]
# Set up a unique related name. The + means it's hidden.
self.rel.related_name = '%s_%s_set+' % (cls.__name__, name)
# Replace the normal descriptor with our custom descriptor.
setattr(cls, self.name, TranslationDescriptor(self))
def formfield(self, **kw):
widget = TransInput if self.short else TransTextarea
defaults = {'form_class': TransField, 'widget': widget}
defaults.update(kw)
return super(TranslatedField, self).formfield(**defaults)
def validate(self, value, model_instance):
# Skip ForeignKey.validate since that expects only one Translation when
# doing .get(id=id)
return models.Field.validate(self, value, model_instance)
class PurifiedField(TranslatedField):
to = PurifiedTranslation
class LinkifiedField(TranslatedField):
to = LinkifiedTranslation
def switch(obj, new_model):
"""Switch between Translation and Purified/Linkified Translations."""
fields = [(f.name, getattr(obj, f.name)) for f in new_model._meta.fields]
return new_model(**dict(fields))
def save_on_signal(obj, trans):
"""Connect signals so the translation gets saved during obj.save()."""
signal = models.signals.pre_save
def cb(sender, instance, **kw):
if instance is obj:
is_new = trans.autoid is None
trans.save(force_insert=is_new, force_update=not is_new)
signal.disconnect(cb)
signal.connect(cb, sender=obj.__class__, weak=False)
class TranslationDescriptor(related.ReverseSingleRelatedObjectDescriptor):
"""
Descriptor that handles creating and updating Translations given strings.
"""
def __init__(self, field):
super(TranslationDescriptor, self).__init__(field)
self.model = field.rel.to
def __get__(self, instance, instance_type=None):
if instance is None:
return self
# If Django doesn't find find the value in the cache (which would only
# happen if the field was set or accessed already), it does a db query
# to follow the foreign key. We expect translations to be set by
# queryset transforms, so doing a query is the wrong thing here.
try:
return getattr(instance, self.field.get_cache_name())
except AttributeError:
return None
def __set__(self, instance, value):
lang = translation_utils.get_language()
if isinstance(value, basestring):
value = self.translation_from_string(instance, lang, value)
elif hasattr(value, 'items'):
value = self.translation_from_dict(instance, lang, value)
# Don't let this be set to None, because Django will then blank out the
# foreign key for this object. That's incorrect for translations.
if value is not None:
# We always get these back from the database as Translations, but
# we may want them to be a more specific Purified/Linkified child
# class.
if not isinstance(value, self.model):
value = switch(value, self.model)
super(TranslationDescriptor, self).__set__(instance, value)
elif getattr(instance, self.field.attname, None) is None:
super(TranslationDescriptor, self).__set__(instance, None)
def translation_from_string(self, instance, lang, string):
"""Create, save, and return a Translation from a string."""
try:
trans = getattr(instance, self.field.name)
trans_id = getattr(instance, self.field.attname)
if trans is None and trans_id is not None:
# This locale doesn't have a translation set, but there are
# translations in another locale, so we have an id already.
translation = self.model.new(string, lang, id=trans_id)
elif to_language(trans.locale) == lang.lower():
# Replace the translation in the current language.
trans.localized_string = string
translation = trans
else:
# We already have a translation in a different language.
translation = self.model.new(string, lang, id=trans.id)
except AttributeError:
# Create a brand new translation.
translation = self.model.new(string, lang)
save_on_signal(instance, translation)
return translation
def translation_from_dict(self, instance, lang, dict_):
"""
Create Translations from a {'locale': 'string'} mapping.
If one of the locales matches lang, that Translation will be returned.
"""
rv = None
for locale, string in dict_.items():
if locale.lower() not in settings.LANGUAGES:
continue
# The Translation is created and saved in here.
trans = self.translation_from_string(instance, locale, string)
# Set the Translation on the object because translation_from_string
# doesn't expect Translations to be created but not attached.
self.__set__(instance, trans)
# If we're setting the current locale, set it to the object so
# callers see the expected effect.
if to_language(locale) == lang:
rv = trans
return rv
class _TransField(object):
def __init__(self, *args, **kwargs):
self.default_locale = settings.LANGUAGE_CODE
for k in ('queryset', 'to_field_name'):
if k in kwargs:
del kwargs[k]
self.widget = kwargs.pop('widget', TransInput)
super(_TransField, self).__init__(*args, **kwargs)
def clean(self, value):
errors = LocaleList()
value = dict((k, v.strip() if v else v) for (k, v) in value.items())
# Raise an exception if the default locale is required and not present
if self.default_locale.lower() not in value:
value[self.default_locale.lower()] = None
# Now, loop through them and validate them separately.
for locale, val in value.items():
try:
# Only the default locale can be required; all non-default
# fields are automatically optional.
if self.default_locale.lower() == locale:
super(_TransField, self).validate(val)
super(_TransField, self).run_validators(val)
except forms.ValidationError, e:
errors.extend(e.messages, locale)
if errors:
raise LocaleValidationError(errors)
return value
class LocaleValidationError(forms.ValidationError):
def __init__(self, messages, code=None, params=None):
self.messages = messages
class TransField(_TransField, forms.CharField):
"""
A CharField subclass that can deal with multiple locales.
Most validators are run over the data for each locale. The required
validator is only run on the default_locale, which is hooked up to the
instance with TranslationFormMixin.
"""
@staticmethod
def adapt(cls, opts={}):
"""Get a new TransField that subclasses cls instead of CharField."""
return type('Trans%s' % cls.__name__, (_TransField, cls), opts)
# Subclass list so that isinstance(list) in Django works.
class LocaleList(dict):
"""
List-like objects that maps list elements to a locale.
>>> LocaleList([1, 2], 'en')
[1, 2]
['en', 'en']
This is useful for validation error lists where we want to associate an
error with a locale.
"""
def __init__(self, seq=None, locale=None):
self.seq, self.locales = [], []
if seq:
assert seq and locale
self.extend(seq, locale)
def __iter__(self):
return iter(self.zip())
def extend(self, seq, locale):
self.seq.extend(seq)
self.locales.extend([locale] * len(seq))
def __nonzero__(self):
return bool(self.seq)
def __contains__(self, item):
return item in self.seq
def zip(self):
return zip(self.locales, self.seq)