-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathtranslation.py
627 lines (509 loc) · 22.8 KB
/
translation.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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
from __future__ import annotations
import json
import warnings
from functools import partial
from itertools import chain
from django import forms
from django.apps import apps
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.db.models.fields.json import KeyTransform
from django.forms.fields import InvalidJSONInput
from django.utils import translation
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from bx_django_utils.admin_utils.admin_urls import admin_change_url
from bx_django_utils.models.manipulate import CreateOrUpdateResult, FieldUpdate, update_model_field
META_KEY = '_meta'
class TranslationWidget(forms.Widget):
template_name = 'bx_django_utils/translation_input.html'
def __init__(self, languages: tuple, attrs=None):
self.languages = languages
super().__init__(attrs=attrs)
def format_value(self, value):
# for some reason, value is always a string. convert back to dict.
return json.loads(value)
def value_from_datadict(self, data, files, name):
fieldnames = { # e.g. "fieldname__de-de" -> "de-de"
f'{name}__{entry[0]}': entry[0] for entry in self.languages
}
res = { # e.g. "de-de" -> "the translation string"
fieldnames[fieldname]: data[fieldname] for fieldname in data if fieldname in fieldnames
}
if f'{name}__{META_KEY}' in data:
meta_json = data[f'{name}__{META_KEY}']
meta = json.loads(meta_json) if meta_json else {}
res[META_KEY] = meta
return res
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['widget']['languages'] = self.languages
return context
class MultilineTranslationWidget(TranslationWidget):
template_name = 'bx_django_utils/translation_input_multiline.html'
class TranslationFormField(forms.JSONField):
"""
Default form field for TranslationField.
"""
widget = None # set by __init__
def __init__(
self,
*args,
languages: tuple,
min_value_length: int | None = None,
max_value_length: int | None = None,
widget_class=TranslationWidget,
**kwargs,
):
self.min_value_length = min_value_length
self.max_value_length = max_value_length
self.widget = widget_class
# self.widget can be a class or an instance.
# abuse that to pass the language codes of the related TranslationField
# to the widget.
self.widget = self.widget(languages=languages)
super().__init__(*args, **kwargs)
def bound_data(self, data, initial):
if self.disabled:
return initial
if data is None:
return None
if isinstance(data, str):
try:
return json.loads(data, cls=self.decoder)
except json.JSONDecodeError:
return InvalidJSONInput(data)
return data
def widget_attrs(self, widget):
attrs = super().widget_attrs(widget)
if self.max_value_length is not None and not widget.is_hidden:
attrs["maxlength"] = str(self.max_value_length)
if self.min_value_length is not None and not widget.is_hidden:
attrs["minlength"] = str(self.min_value_length)
return attrs
class FieldTranslation(dict):
"""
Dict-like container that maps language codes to a translated string.
Is returned when accessing the value of TranslationField.
"""
UNTRANSLATED = '<UNTRANSLATED>'
def get_first(self, codes):
"""
Returns the first translation found for the given language codes.
"""
for code in codes:
if translation := self.get(code):
return translation
return self.UNTRANSLATED
def __repr__(self):
return f'FieldTranslation({super().__repr__()})'
class TranslationField(models.JSONField):
"""
A field designed to hold translations for a given set of language codes.
You may optionally define which widget this field should render as by default via
the `widget_class` kwarg. The widget must always return a dict-like value, so
it can be stored as JSON in the database.
TranslationFields always have null=True. If no value is set, an empty dict will
be used instead. This ensures a consistent way to access the data.
Accessing this field's value returns a FieldTranslation instance instead of
a regular dict (much like FileField returns a FieldFile instance).
The optional `min_value_length` and `max_value_length` options will apply to the individual translated text.
"""
default_error_messages = {
# Replace "This field cannot be blank." a more appropriate message:
'blank': _('At least one translation is required.'),
}
def __init__(
self,
*args,
language_codes: tuple | None = None,
languages: tuple = None,
min_value_length: int | None = None,
max_value_length: int | None = None,
**kwargs,
):
kwargs['null'] = False
kwargs['default'] = FieldTranslation
if language_codes:
warnings.warn(
'language_codes argument is deprecated in favour of languages', DeprecationWarning, stacklevel=2
)
assert not languages, 'Remove language_codes argument!'
self.languages = tuple((code, code) for code in language_codes)
else:
self.languages = languages
self.min_value_length = min_value_length
self.max_value_length = max_value_length
self.widget_class = kwargs.pop('widget_class', TranslationWidget)
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
for override in ('null', 'default'):
# TranslationField hardcodes values for these kwargs in __init__,
# so omit them from deconstruction
kwargs.pop(override, None)
kwargs['languages'] = self.languages
kwargs['min_value_length'] = self.min_value_length
kwargs['max_value_length'] = self.max_value_length
return name, path, args, kwargs
def from_db_value(self, value, expression, connection):
value = super().from_db_value(value, expression, connection)
if isinstance(expression, KeyTransform) and (isinstance(value, str) or (value is None)):
return value # Getting just one translation, already done
if value is None:
return FieldTranslation()
value = remove_empty_translations(value) # Ignore empty translation from DB
return FieldTranslation(value)
def get_db_prep_save(self, value, *args, **kwargs):
value = remove_empty_translations(value) # Don't store empty translations to DB
return super().get_db_prep_save(value, *args, **kwargs)
def clean(self, value, model_instance):
value = super().clean(value, model_instance)
value = remove_empty_translations(value)
existing_codes = value.keys()
known_codes = {entry[0] for entry in self.languages}
unknown_codes = existing_codes - known_codes - {META_KEY}
if unknown_codes:
raise ValidationError(f'Unknown translation language(s): {", ".join(sorted(unknown_codes))}')
if self.min_value_length is not None or self.max_value_length is not None:
errors = []
for language_code, text in value.items():
length = len(text)
if self.max_value_length is not None and length > self.max_value_length:
errors.append(
ValidationError(
f'Ensure "{language_code}" translation has at most {self.max_value_length} character'
f' (it has {length}).'
)
)
if self.min_value_length is not None and length < self.min_value_length:
errors.append(
ValidationError(
f'Ensure "{language_code}" translation has at least {self.min_value_length} character'
f' (it has {length}).'
)
)
if errors:
raise ValidationError(errors)
return value
def to_python(self, value):
if not value:
return FieldTranslation()
if isinstance(value, str):
try:
value = json.loads(value, cls=self.decoder)
except json.JSONDecodeError as err:
raise ValidationError(f'Invalid JSON data: {err}')
if not isinstance(value, dict):
raise ValidationError(f'Invalid input type: {type(value).__name__}')
value = remove_empty_translations(value)
return FieldTranslation(value)
def formfield(self, **kwargs):
kwargs['form_class'] = TranslationFormField
kwargs['widget_class'] = self.widget_class
kwargs['languages'] = self.languages
kwargs['min_value_length'] = self.min_value_length
kwargs['max_value_length'] = self.max_value_length
return super().formfield(**kwargs)
def slug_generator(source_text):
max_loops = getattr(settings, 'MAX_UNIQUE_QUERY_ATTEMPTS', 1000)
slug = slugify(source_text)
if not slug:
# The source text doesn't contain any normal character
# Fallback and yield numbers
for number in range(1, max_loops + 1):
yield str(number)
else:
yield slug
for number in range(2, max_loops + 1):
yield f'{slug}-{number}'
def additional_uniqueness_exists(*, additional_uniqueness: tuple[dict], lang_code, slug_candidate):
for info in additional_uniqueness:
ModelClass = apps.get_model(app_label=info['app_label'], model_name=info['model_name'])
qs = ModelClass.objects.filter(**{f'{info["field_name"]}__{lang_code}': slug_candidate})
if qs.exists():
return True
return False
def get_unique_translation_slug(
*,
model_instance,
attr_name,
source_text,
lang_code,
additional_uniqueness: tuple[dict] | None = None,
):
ModelClass = model_instance.__class__
base_qs = ModelClass.objects.all()
if pk := model_instance.pk:
base_qs = base_qs.exclude(pk=pk)
for slug_candidate in slug_generator(source_text):
qs = base_qs.filter(**{f'{attr_name}__{lang_code}': slug_candidate})
if qs.exists():
# Slug already exists in the same model -> try next candidate
continue
if additional_uniqueness and additional_uniqueness_exists(
additional_uniqueness=additional_uniqueness, lang_code=lang_code, slug_candidate=slug_candidate
):
# Slug already exists one of the other models -> try next candidate
continue
return slug_candidate
raise RuntimeError(f'Can not find a unique slug for {model_instance} {attr_name=} {lang_code=}, {source_text=}')
class TranslationSlugField(TranslationField):
"""
A unique translation slug field, useful in combination with TranslationField()
All slugs will be unique for every language, by adding a number, started with the second one.
e.g.:
class TranslatedSlugTestModel(models.Model):
LANGUAGE_CODES = ('de-de', 'en-us', 'es')
translated = TranslationField(language_codes=LANGUAGE_CODES)
translated_slug = TranslationSlugField(
language_codes=LANGUAGE_CODES,
populate_from='translated',
)
Optional: Uniqueness between more than one Model
e.g.:
class TranslatedSlugTestModel(models.Model):
#...
translated_slug = TranslationSlugField(
language_codes=LANGUAGE_CODES,
populate_from='translated',
additional_uniqueness=(
dict(
app_label='foo_bar_app',
model_name='FooBarModel',
field_name='translated_slug',
),
),
)
But Note:
The field set `unique=True`, but the database level will only deny
create non-unique slugs, if *all* translations are the same!
So be careful if you use bulk create/update with not unique data!
See Tests.
"""
def __init__(
self,
*args,
populate_from: str = None,
unique=True,
additional_uniqueness: tuple[dict] | None = None,
**kwargs,
):
self.populate_from = populate_from
self.additional_uniqueness = additional_uniqueness
kwargs['blank'] = True
super().__init__(*args, unique=unique, **kwargs)
assert self.blank is True, 'TranslationSlugField must always set blank=True !'
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
kwargs['unique'] = self.unique
kwargs['additional_uniqueness'] = self.additional_uniqueness
return name, path, args, kwargs
def create_slug(self, model_instance, add):
slug_translations = getattr(model_instance, self.attname)
assert isinstance(
slug_translations, (FieldTranslation, dict)
), f'Unexpected type: {type(slug_translations).__name__}'
populate_translations = getattr(model_instance, self.populate_from)
assert isinstance(
populate_translations, (FieldTranslation, dict)
), f'Unexpected type: {type(populate_translations).__name__}'
for lang_code, _lang_name in self.languages:
source_text = slug_translations.get(lang_code) or populate_translations.get(lang_code)
if source_text:
slug = get_unique_translation_slug(
model_instance=model_instance,
attr_name=self.attname,
source_text=source_text,
lang_code=lang_code,
additional_uniqueness=self.additional_uniqueness,
)
assert slug, f'Can not find a slug from {source_text=}'
slug_translations[lang_code] = slug
return slug_translations
def pre_save(self, model_instance, add):
value = self.create_slug(model_instance, add)
return value
class TranslationFieldAdmin(admin.ModelAdmin):
"""
Provides drop-in support for ModelAdmin classes that want to display TranslationFields
in their changelist.
Since TranslationFields are essentially a JSONField, the Django admin will _always_ render
them as a serialized JSON objects (see django.contrib.admin.utils.display_for_field).
This is not helpful in the context of TranslationField, so this base class does some
patching to display one of the translations instead.
"""
@staticmethod
def get_translation_order(fallback_codes):
"""
Determines the order in which a suitable translation is picked from the value of a TranslationField.
Can be overriden by inheriting admin classes, e.g. when using language codes other
than those the Django translation system uses.
"""
return [
translation.get_language(), # equal to current locale of user
settings.LANGUAGE_CODE,
*fallback_codes,
]
def _get_translated_field_display(self, obj, fieldname, field_codes):
field = getattr(obj, fieldname)
order = self.get_translation_order(field_codes)
trans = field.get_first(order)
return trans
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# collect names of fields that are TranslationFields.
# also save the translation codes for each field.
# these will be used below for resolving a user-appropriate translation.
translation_fields = []
translation_codes = {}
for field in self.model._meta.fields:
if not isinstance(field, TranslationField):
continue
translation_fields.append(field.name)
translation_codes[field.name] = field.languages
# add a 'get_<fieldname>' method to the admin for each TranslationField found in the last step.
# the method returns its most suitable translation.
for field in translation_fields:
resolve_func = partial(
self._get_translated_field_display,
fieldname=field,
field_codes=translation_codes[field],
)
resolve_func.__name__ = field
setattr(self, f'get_{field}', resolve_func)
def patch_fieldnames(fieldnames) -> tuple[str, ...]:
return tuple(f'get_{field}' if field in translation_fields else field for field in fieldnames)
# patch each list of fieldnames considered by the changelist to use the new 'get_<fieldname>' methods.
self.list_display = patch_fieldnames(self.list_display)
self.list_display_links = patch_fieldnames(self.list_display_links)
self.readonly_fields = patch_fieldnames(self.readonly_fields)
def create_or_update_translation_callback(*, instance, field_name, old_value, new_value, result: CreateOrUpdateResult):
"""
Callback for create_or_update2() for TranslationField, that will never remove existing translation.
"""
if old_value == new_value:
# Nothing to update -> we are done.
return
model_field = instance._meta.get_field(field_name)
if not isinstance(model_field, TranslationField):
# Normal model field -> use origin callback
return update_model_field(
instance=instance,
field_name=field_name,
old_value=old_value,
new_value=new_value,
result=result,
)
# Merge translations:
merged_value = merge_translations(old_value, new_value)
if old_value == merged_value:
# Nothing to update -> we are done.
return
# The model field value has been changed -> set it on the model instance:
setattr(instance, field_name, merged_value)
# Expand the list of updated fields, used for result and save() call:
result.updated_fields.append(field_name)
# Store update information:
result.update_info.append(FieldUpdate(field_name=field_name, old_value=old_value, new_value=merged_value))
def remove_empty_translations(translations: dict | FieldTranslation) -> FieldTranslation:
"""
Remove all empty/None from a FieldTranslation, e.g.:
>>> remove_empty_translations({'de-de': 'Hallo', 'en': 'Hello', 'es':'', 'mx': None})
FieldTranslation({'de-de': 'Hallo', 'en': 'Hello'})
"""
filtered = {lang_code: text for lang_code, text in translations.items() if text}
return FieldTranslation(filtered)
def merge_translations(
translations1: dict | FieldTranslation, translations2: dict | FieldTranslation
) -> FieldTranslation:
"""
Merge two FieldTranslation and ignore all empty/None values, e.g.:
>>> merge_translations({'de': 'Hallo', 'en': '', 'es': 'Hola'}, {'de': '', 'es': 'HOLA'})
FieldTranslation({'de': 'Hallo', 'es': 'HOLA'})
"""
merged = remove_empty_translations(translations1)
merged.update(remove_empty_translations(translations2))
return FieldTranslation(merged)
def validate_unique_translations(*, ModelClass, instance, field_name, translated_value) -> None:
"""
Deny creating non-unique translation: Creates ValidationError with change list search for doubled entries.
Useable for Form/Model clean methods.
See: bx_django_utils_tests.test_app.admin.TranslatedSlugTestModelForm()
"""
if not translated_value:
raise ValidationError({'translated_name': _('At least one translation is required.')})
q_filters = Q()
for lang_code, value in translated_value.items():
if value:
q_filters |= Q(**{f'{field_name}__{lang_code}': value})
if not q_filters:
raise ValidationError({field_name: _('At least one translation is required.')})
qs = ModelClass.objects.filter(q_filters)
if instance and (own_pk := instance.pk):
qs = qs.exclude(pk=own_pk)
if other_instance := qs.first():
url = admin_change_url(instance=other_instance)
msg = mark_safe(
_('A <a href="%(url)s">other %(model_name)s</a> with one of these translation already exists!')
% {
'url': url,
'model_name': ModelClass._meta.verbose_name_plural,
}
)
raise ValidationError({field_name: msg})
def make_unique(*args) -> tuple:
"""
Flat args and remove duplicate entries while keeping the order intact.
>>> make_unique(['de', 'de-de'], 'en', ('en', 'en-gb'), 'de', ['en-gb', 'en-gb', 'en-int'])
('de', 'de-de', 'en', 'en-gb', 'en-int')
"""
args = [[args] if isinstance(args, str) else args for args in args]
entries = tuple(chain.from_iterable(args))
return tuple(sorted(set(entries), key=entries.index))
def expand_languages_codes(languages_codes) -> list:
"""
Build a complete list if language code with and without dialects.
>>> expand_languages_codes(languages_codes=['de'])
['de', 'de-de']
>>> expand_languages_codes(languages_codes=['en-us', 'en-gb'])
['en-us', 'en-gb', 'en']
>>> expand_languages_codes(languages_codes=['de', 'en-us', 'en-gb'])
['de', 'en-us', 'en-gb', 'de-de', 'en']
"""
assert isinstance(languages_codes, (list, tuple)), f'No list/tuple: {languages_codes=}'
expanded = list(languages_codes)
for code in languages_codes:
if '-' in code:
code = code.split('-')[0]
else:
code = f'{code}-{code}'
if code not in expanded:
expanded.append(code)
return expanded
def get_user_priorities(existing_codes) -> tuple:
"""
Collect usable language codes the current user
"""
user_codes = []
if dj_language := translation.get_language():
for user_code in expand_languages_codes(languages_codes=[dj_language]):
if user_code in existing_codes:
user_codes.append(user_code)
for code in existing_codes:
if code.startswith(user_code):
user_codes.append(code)
return make_unique(user_codes)
def user_language_priorities(fallback_codes, existing_codes) -> tuple:
"""
Returns the order in which to attempt resolving translations of a FieldTranslation model field.
"""
return make_unique(
*get_user_priorities(existing_codes), # Lookup user preferred language with and without dialect first
*fallback_codes, # use fallback
*existing_codes, # at the end: try any existing language
)