From dd4752f65906e83e2156e1dde4117d277a5a2588 Mon Sep 17 00:00:00 2001 From: Sam Spencer Date: Mon, 14 Jun 2021 22:38:26 +1000 Subject: [PATCH 01/43] Minor for fallbacks refactor - Fixes #41 Fixes #49 --- README.md | 34 ++++++-- dev/docker-compose.yml | 3 + garnett/context.py | 6 +- garnett/context_processors.py | 4 +- garnett/fields.py | 85 ++++--------------- garnett/middleware.py | 6 +- garnett/migrate.py | 4 +- garnett/utils.py | 25 ++++-- tests/library_app/migrations/0001_initial.py | 2 +- .../migrations/0003_defaultbook.py | 3 - tests/library_app/models.py | 14 +-- .../templates/library_app/book_detail.html | 5 +- .../templates/library_app/book_list.html | 35 +++++--- tests/tests/test_fallback.py | 16 ++-- tests/tests/test_middleware.py | 4 +- tests/tests/test_migrate.py | 4 +- 16 files changed, 123 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index dc08e47..f4d505a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # django-garnett -Django Garnett is a field level translation library that allows you to store strings in multiple languages for fields in Django - with minimal changes to your models and without having to rewrite your code (mostly). +Django Garnett is a field level translation library that allows you to store strings in multiple languages for fields in Django - with minimal changes to your models and without having to rewrite your code. In summary it allows you to do this: @@ -97,7 +97,7 @@ Tested on: Pros: * Fetching all translations for a models requires a single query * Translations are stored in a single database field with the model -* Translations act like regular a field `Model.field_name = "some string"` and `print(Model.field_name)` work as you'd expect +* Translations act like regular a field `Model.field_name = "some string"` and `print(Model.field_name)` work as you would expect * Includes a configurable middleware that can set the current language context based on users cookies, query string or HTTP headers * Works nicely with Django Rest Framework @@ -108,7 +108,7 @@ Cons: A few reasons: * Most existing django field translation libraries are static, and add separate database columns per translation. -* We needed a library that could be added in without requiring a rewrite of a large code base. +* We needed a library that could be added in without requiring a rewrite of a very large code base. Note: Field language is different to the django display language. Django can be set up to translate your pages based on the users browser and serve them with a user interface in their preferred language. @@ -128,7 +128,7 @@ Garnett *does not* use the browser language by design - a user with a French bro 5. Re-run `django makemigrations` & `django migrate` for any apps you've updated. 6. Thats mostly it. -You can also add a few value adds: +You can also add a few optional value adds: 7. (Optional) Add a garnett middleware to take care of field language handling: @@ -146,9 +146,25 @@ You can also add a few value adds: * Install `garnett.context_processors.languages` this will add `garnett_languages` (a list of available `Language`s) and `garnett_current_language` (the currently selected language). +10. (Optional) Add a custom translation fallback: + + By default, if a language isn't available for a field, Garnett will show a mesage like: + > No translation of this field available in English + + You can override this either by creating a custom fallback method: + ``` + Translated(CharField(max_length=150), fallback=my_fallback_method)) + ``` + Where `my_fallback_method` takes a dictionary of language codes and corresponding strings, and returns the necessary text. + + Additionally, you can customise how django outputs text in templates by creating a new + `TranslationStr` class, and overriding the [`__html__` method][dunder-html]. + + + ## `Language` vs language -Django Garnett uses the python `langcodes` to determine more information about the languages being used - including the full name and local name of the language being used. This is stored as a `Language` object. +Django Garnett uses the python `langcodes` library to determine more information about the languages being used - including the full name and local name of the language being used. This is stored as a `Language` object. ## Django Settings options: @@ -190,8 +206,9 @@ Advanced Settings (you probably don't need to adjust these) ## Why call it Garnett? * Libraries need a good name. -* Searching for "Famous Translators" will tell you about [Constnace Garnett](https://en.wikipedia.org/wiki/Constance_Garnett). -* Searching for "Garnett Django" shows there was no library with this name. It did however talk about [Garnet Clark](https://en.wikipedia.org/wiki/Garnet_Clark) (also spelled Garnett), a jazz pianist who played with Django Reinhart - the namesake of the Django Web Framework. +* Searching for "Famous Translators" will tell you about [Constance Garnett](https://en.wikipedia.org/wiki/Constance_Garnett). +* Searching for "Django Garnett" showed there was no python library with this name. +* It did however talk about [Garnet Clark](https://en.wikipedia.org/wiki/Garnet_Clark) (also spelled Garnett), a jazz pianist who played with Django Reinhart - the namesake of the Django Web Framework. * Voila - a nice name ## Warnings @@ -199,9 +216,10 @@ Advanced Settings (you probably don't need to adjust these) * `contains == icontains` For cross database compatibility reasons this library treats `contains` like `icontains`. I don't know why - https://www.youtube.com/watch?v=PgGNWRtceag * Due to how django sets admin form fields you will not get the admin specific widgets like `AdminTextAreaWidget` on translated fields in the django admin site by default. They can however - be specified explicitly on the corresponding model form + be specified explicitly on the corresponding admin model form. need to run tests like this for now: PYTHONPATH=../ ./manage.py shell [term-language-code]: https://docs.djangoproject.com/en/3.1/topics/i18n/#term-language-code [django-how]: https://docs.djangoproject.com/en/3.1/topics/i18n/translation/#how-django-discovers-language-preference +[dunder-html]: https://code.djangoproject.com/ticket/7261 \ No newline at end of file diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 8c1ddf1..4a65efa 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -18,6 +18,9 @@ services: build: context: .. dockerfile: ./dev/Dockerfile + environment: + - PYTHONPATH=/usr/src/app:/usr/src/app/tests/ + - DJANGO_SETTINGS_MODULE=library_app.settings image: python # env_file: # - default.env diff --git a/garnett/context.py b/garnett/context.py index b8acfe8..bbfb3ec 100644 --- a/garnett/context.py +++ b/garnett/context.py @@ -1,5 +1,6 @@ from contextlib import ContextDecorator import contextvars +from langcodes import Language # Internal context var should be set via set_field_language and get via get_current_language _ctx_language = contextvars.ContextVar("garnett_language") @@ -7,7 +8,10 @@ class set_field_language(ContextDecorator): def __init__(self, language): - self.language = language + if isinstance(language, Language): + self.language = language + else: + self.language = Language(language) self.token = None def __enter__(self): diff --git a/garnett/context_processors.py b/garnett/context_processors.py index 1cdafbb..2d134d8 100644 --- a/garnett/context_processors.py +++ b/garnett/context_processors.py @@ -1,10 +1,8 @@ from .utils import get_languages, get_current_language -from langcodes import Language def languages(request): - languages = [Language.make(language=l) for l in get_languages()] return { - "garnett_languages": languages, + "garnett_languages": get_languages(), "garnett_current_language": get_current_language(), } diff --git a/garnett/fields.py b/garnett/fields.py index aaade91..2e7f821 100644 --- a/garnett/fields.py +++ b/garnett/fields.py @@ -1,64 +1,19 @@ from django.conf import settings -from django.contrib.admin import widgets -from django.contrib.admin.options import FORMFIELD_FOR_DBFIELD_DEFAULTS from django.core import exceptions -from django.db.models import Model, JSONField +from django.db.models import JSONField from django.db.models.fields.json import KeyTransform from django.utils.translation import gettext as _ from dataclasses import make_dataclass from functools import partial -from langcodes import Language import logging from typing import Callable, Dict, Union -from garnett.utils import get_current_language, get_property_name, get_languages +from garnett.translatedstr import TranslatedStr, VerboseTranslatedStr +from garnett.utils import get_current_language_code, get_property_name, get_languages logger = logging.getLogger(__name__) -class TranslatedStr(str): - def __new__(cls, content, fallback=False, fallback_language=""): - instance = super().__new__(cls, content) - instance.is_fallback = fallback - instance.fallback_language = fallback_language - return instance - - -def translation_fallback(field: "TranslatedField", obj: Model) -> str: - """Default fallback function that returns an error message""" - all_ts = getattr(obj, f"{field.name}_tsall") - language = get_current_language() - lang_name = Language.make(language=language).display_name(language) - lang_en_name = Language.make(language=language).display_name() - - return all_ts.get( - language, - _( - "No translation of %(field)s available in %(lang_name)s" - " [%(lang_en_name)s]." - ) - % { - "field": field.name, - "lang_name": lang_name, - "lang_en_name": lang_en_name, - }, - ) - - -def next_language_fallback(field: "TranslatedField", obj: Model) -> TranslatedStr: - """Fallback that checks each language consecutively""" - all_ts = getattr(obj, f"{field.name}_tsall") - for lang in get_languages(): - if lang in all_ts: - return TranslatedStr(all_ts[lang], fallback=True, fallback_language=lang) - - return TranslatedStr("", fallback=True) - - -def blank_fallback(field, obj): - return "" - - def validate_translation_dict(all_ts: dict) -> None: """Validate that translation dict maps valid lang code to string @@ -81,7 +36,7 @@ def translatable_default( inner_default: Union[str, Callable[[], str]] ) -> Dict[str, str]: """Return default from inner field as dict with current language""" - lang = get_current_language() + lang = get_current_language_code() if callable(inner_default): return {lang: inner_default()} @@ -95,12 +50,16 @@ class TranslatedField(JSONField): """ def __init__(self, field, *args, fallback=None, **kwargs): + # TODO: Alter signiature of this to accept a TranslatedStr, then check tests self.field = field + self._fallback = fallback - if fallback: + if type(fallback) is type and issubclass(fallback, TranslatedStr): self.fallback = fallback + elif callable(fallback): + self.fallback = partial(TranslatedStr, fallback=fallback) else: - self.fallback = translation_fallback + self.fallback = VerboseTranslatedStr # Move some args to outer field outer_args = [ @@ -142,7 +101,7 @@ def value_from_object(self, obj): ) return str(all_ts) - language = get_current_language() + language = get_current_language_code() return all_ts.get(language, None) def get_attname_column(self): @@ -154,22 +113,12 @@ def get_attname_column(self): def contribute_to_class(self, cls, name, private_only=False): super().contribute_to_class(cls, name, private_only) - # We use ego to diferentiate scope here as this is the inner self - # Maybe its not necessary, but it is funny + # We use `ego` to differentiate scope here as this is the inner self + # Maybe its not necessary, but it is funny. @property def translator(ego): - """Getter for main field (without _tsall)""" - value = self.value_from_object(ego) - if value is not None: - return TranslatedStr(value, False) - - fallback_value = self.fallback(self, ego) - # If fallback function didn't return a TranslatedStr wrap it in one - if not isinstance(fallback_value, TranslatedStr): - fallback_value = TranslatedStr(fallback_value, fallback=True) - - return fallback_value + return self.fallback(getattr(ego, f"{self.name}_tsall")) @translator.setter def translator(ego, value): @@ -188,7 +137,7 @@ def translator(ego, value): all_ts = {} if isinstance(value, str): - all_ts[get_current_language()] = value + all_ts[get_current_language_code()] = value elif isinstance(value, dict): all_ts = value else: @@ -231,7 +180,7 @@ def available_languages(ego): langs = set() for field in ego.translatable_fields: langs |= getattr(ego, f"{field.name}_tsall", {}).keys() - return [l for l in get_languages() if l in langs] + return [l for l in get_languages() if l.language in langs] setattr(cls, "available_languages", available_languages) @@ -265,7 +214,7 @@ def get_transform(self, name): def deconstruct(self): name, path, args, kwargs = super().deconstruct() args.insert(0, self.field) - kwargs["fallback"] = self.fallback + kwargs["fallback"] = self._fallback return name, path, args, kwargs diff --git a/garnett/middleware.py b/garnett/middleware.py index 89903e9..cfa3eed 100644 --- a/garnett/middleware.py +++ b/garnett/middleware.py @@ -4,7 +4,7 @@ from langcodes import Language import logging -from .utils import get_languages, get_language_from_request +from .utils import get_languages, get_language_from_request, is_valid_language from .context import set_field_language logger = logging.getLogger(__name__) @@ -41,8 +41,8 @@ class TranslationContextNotFoundMiddleware(TranslationContextMiddleware): """ def validate(self, language): - if language not in get_languages(): - lang_obj = Language.make(language=language) + if not is_valid_language(language): + lang_obj = language #Language.make(language=language) lang_name = lang_obj.display_name(language) lang_en_name = lang_obj.display_name() raise Http404( diff --git a/garnett/migrate.py b/garnett/migrate.py index 08364db..0e07606 100644 --- a/garnett/migrate.py +++ b/garnett/migrate.py @@ -1,7 +1,7 @@ from django.apps.registry import Apps from django.db.migrations import RunPython from django.db.backends.base.schema import BaseDatabaseSchemaEditor -from garnett.utils import get_current_language +from garnett.utils import get_current_language_code import json from typing import Callable, Dict, List @@ -17,7 +17,7 @@ def _get_migrate_function( """ def migrate(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: - current_lang = get_current_language() + current_lang = get_current_language_code() for model_name, fields in model_fields.items(): updated = [] diff --git a/garnett/utils.py b/garnett/utils.py index 0c567c2..b08fceb 100644 --- a/garnett/utils.py +++ b/garnett/utils.py @@ -1,6 +1,9 @@ +from typing import List + from django.conf import settings from django.utils.module_loading import import_string from django.core.exceptions import ImproperlyConfigured +from langcodes import Language from garnett.context import _ctx_language @@ -16,11 +19,13 @@ def get_default_language(): else: default = setting - if isinstance(default, str): + if isinstance(default, Language): return default + elif isinstance(default, str): + return Language.make(default) else: raise ImproperlyConfigured( - "GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE must be a string or callable that returns a string" + "GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE must be a string or callable that returns a string or `Language` object" ) @@ -28,21 +33,29 @@ def get_property_name(): return getattr(settings, "GARNETT_TRANSLATIONS_PROPERTY_NAME", "translations") -def get_current_language(): +def is_valid_language(language): + return language in get_languages() + + +def get_current_language() -> Language: lang = _ctx_language.get(None) if not lang: return get_default_language() return lang -def get_languages(): +def get_current_language_code() -> str: + return get_current_language().language + + +def get_languages() -> List[Language]: langs = getattr( settings, "GARNETT_TRANSLATABLE_LANGUAGES", [get_default_language()] ) if callable(langs): langs = langs() if type(langs) == list: - return langs + return [Language.make(language=l) for l in langs] raise ImproperlyConfigured( "GARNETT_TRANSLATABLE_LANGUAGES must be a list or a callable that returns a list" ) @@ -61,5 +74,5 @@ def get_language_from_request(request): for opt in opt_order: func = import_string(opt) if lang := func(request): - return lang + return Language.make(lang) return get_default_language() diff --git a/tests/library_app/migrations/0001_initial.py b/tests/library_app/migrations/0001_initial.py index 3491c64..84a84d5 100644 --- a/tests/library_app/migrations/0001_initial.py +++ b/tests/library_app/migrations/0001_initial.py @@ -39,7 +39,7 @@ class Migration(migrations.Migration): ( "description", garnett.fields.TranslatedField( - models.TextField(), fallback=garnett.fields.translation_fallback + models.TextField(), ), ), ("category", models.JSONField()), diff --git a/tests/library_app/migrations/0003_defaultbook.py b/tests/library_app/migrations/0003_defaultbook.py index 02368f6..282cbd3 100644 --- a/tests/library_app/migrations/0003_defaultbook.py +++ b/tests/library_app/migrations/0003_defaultbook.py @@ -35,7 +35,6 @@ class Migration(migrations.Migration): *("DEFAULT TITLE",), **{} ), - fallback=garnett.fields.translation_fallback, ), ), ( @@ -49,7 +48,6 @@ class Migration(migrations.Migration): *(library_app.models.default_author,), **{} ), - fallback=garnett.fields.translation_fallback, ), ), ( @@ -59,7 +57,6 @@ class Migration(migrations.Migration): default=functools.partial( garnett.fields.translatable_default, *("",), **{} ), - fallback=garnett.fields.translation_fallback, ), ), ], diff --git a/tests/library_app/models.py b/tests/library_app/models.py index 6d28523..98d115f 100644 --- a/tests/library_app/models.py +++ b/tests/library_app/models.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from garnett import fields -from garnett.utils import get_current_language +from garnett.utils import get_current_language_code def validate_length(value): @@ -11,13 +11,13 @@ def validate_length(value): raise ValidationError(_("Title is too short")) -def title_fallback(field, obj): - current_lang = get_current_language() - if obj.translations.title.items(): - lang, value = list(obj.translations.title.items())[0] - return f"{value} (Book title unavailable in {current_lang}, falling back to {lang})" +def title_fallback(context): + current_lang = get_current_language_code() + if context.items(): + lang, value = list(context.items())[0] + return lang, f"{value} (Book title unavailable in {current_lang}, falling back to {lang})" else: - return "No translations available for this book" + return None, "No translations available for this book" class Book(models.Model): diff --git a/tests/library_app/templates/library_app/book_detail.html b/tests/library_app/templates/library_app/book_detail.html index 657633e..0e8e344 100644 --- a/tests/library_app/templates/library_app/book_detail.html +++ b/tests/library_app/templates/library_app/book_detail.html @@ -24,6 +24,7 @@

{{ book.title }}


{{ book.description }} +
{% if book.translations.title|length > 1 %} This book also available in: {% endif %} - - - {{ book.title_tsall }} +
\ No newline at end of file diff --git a/tests/library_app/templates/library_app/book_list.html b/tests/library_app/templates/library_app/book_list.html index 7917036..200c3c5 100644 --- a/tests/library_app/templates/library_app/book_list.html +++ b/tests/library_app/templates/library_app/book_list.html @@ -15,22 +15,37 @@ {% language_selector "cookie" %}

Here are all the books

-