Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Minor for fallbacks refactor - Fixes #41 Fixes #49 #55

Merged
merged 44 commits into from
Jun 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
dd4752f
Minor for fallbacks refactor - Fixes #41 Fixes #49
LegoStormtroopr Jun 14, 2021
d70629d
add missing file
LegoStormtroopr Jun 14, 2021
42cee0e
fix linting
LegoStormtroopr Jun 14, 2021
9c0c21a
fix flake, fix tests, fixes #54
LegoStormtroopr Jun 20, 2021
0cb9d25
add requirements for heroku
LegoStormtroopr Jun 20, 2021
a5e7fb8
simplify requirements, improve homepage
LegoStormtroopr Jun 20, 2021
acb7598
lint
LegoStormtroopr Jun 20, 2021
1c20890
last test fix
LegoStormtroopr Jun 20, 2021
ae9648d
last test fix
LegoStormtroopr Jun 20, 2021
5353be7
heroku
LegoStormtroopr Jun 20, 2021
c748403
heroku
LegoStormtroopr Jun 20, 2021
d63ab62
heroku
LegoStormtroopr Jun 20, 2021
6bd2ecc
heroku
LegoStormtroopr Jun 20, 2021
561e6bb
heroku
LegoStormtroopr Jun 20, 2021
29fb6df
heroku
LegoStormtroopr Jun 20, 2021
325b532
heroku
LegoStormtroopr Jun 20, 2021
48d17e3
heroku
LegoStormtroopr Jun 20, 2021
c6e74a7
more fixes
LegoStormtroopr Jun 21, 2021
9b81386
procfile
LegoStormtroopr Jun 21, 2021
790dec1
procfile
LegoStormtroopr Jun 21, 2021
ebce248
procfile
LegoStormtroopr Jun 21, 2021
048cb74
procfile
LegoStormtroopr Jun 21, 2021
a2a96e0
procfile
LegoStormtroopr Jun 21, 2021
08b1bad
procfile
LegoStormtroopr Jun 21, 2021
8e9dc4e
procfile
LegoStormtroopr Jun 21, 2021
453740c
procfile
LegoStormtroopr Jun 21, 2021
b97b6d4
procfile
LegoStormtroopr Jun 21, 2021
0095bb4
procfile
LegoStormtroopr Jun 21, 2021
5ae5672
procfile
LegoStormtroopr Jun 21, 2021
ac64aae
procfile
LegoStormtroopr Jun 21, 2021
6bd6c7a
procfile
LegoStormtroopr Jun 21, 2021
ac202aa
procfile
LegoStormtroopr Jun 21, 2021
49983d4
procfile
LegoStormtroopr Jun 21, 2021
244037d
procfile
LegoStormtroopr Jun 21, 2021
2bc2cdb
procfile
LegoStormtroopr Jun 21, 2021
2a72b76
procfile
LegoStormtroopr Jun 21, 2021
f926af2
procfile
LegoStormtroopr Jun 21, 2021
f01d090
changes
LegoStormtroopr Jun 21, 2021
01705be
changes
LegoStormtroopr Jun 21, 2021
1189da4
changes
LegoStormtroopr Jun 21, 2021
7ee97c4
final layouts for demo site
LegoStormtroopr Jun 21, 2021
9fe29db
Update garnett/utils.py
LegoStormtroopr Jun 22, 2021
5587ea4
Merge branch 'refactor' of https://github.com/Aristotle-Metadata-Ente…
LegoStormtroopr Jun 21, 2021
d1fe2f8
Fixes minor code formatting issues
s-i-l-k-e Jun 23, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ jobs:

- name: Install test tools
run: pip install tox coverage


- name: Flake8
run: pip install flake8 && flake8 --ignore=E501

- name: Run SQLite tests
run: tox -e dj-31-db-sqlite

Expand Down
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: bash ./run.sh
49 changes: 38 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# 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.

Want a demo? https://django-garnett.herokuapp.com/
s-i-l-k-e marked this conversation as resolved.
Show resolved Hide resolved

<a href="https://www.aristotlemetadata.com"
style="text-decoration: none">
Made with <i style="color:#d63384">♥</i> by
<img src="https://brand.aristotlemetadata.com/images/svgs/thick.svg" height=20> Aristotle Metadata
</a>

In summary it allows you to do this:

Expand Down Expand Up @@ -97,7 +105,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

Expand All @@ -108,7 +116,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.

Expand All @@ -128,7 +136,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:

Expand All @@ -146,21 +154,37 @@ 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:

* `GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE`
* Stores the default language to be used for reading and writing fields if no language is set in a context manager or by a request.
* By default it is 'en-AU' the [language code][term-language-code] for 'Strayan, the native tongue of inhabitants of 'Straya (or more commonly known as Australia).
* Can also be callable that returns list of language codes
* Can also be callable that returns default language code
* default: `'en-AU'`
* `GARNETT_TRANSLATABLE_LANGUAGES`:
* Stores a list of [language codes][term-language-code] that users can use to save against TranslatableFields.
* Can also be callable that returns default language code
* Can also be callable that returns list of language codes
* default `[GARNETT_DEFAULT_TRANSLATABLE_LANGUAGE]`
* `GARNETT_REQUEST_LANGUAGE_SELECTORS`:
* A list of string modules that determines the order of options used to determine the language selected by the user. The first selector found is used for the language for the request, if none are found the DEFAULT_LANGUAGE is used. These can any of the following in any order:
Expand Down Expand Up @@ -190,18 +214,21 @@ 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

* `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
* `contains == icontains` - On SQLite only, when doing a contains query
it does a case insensitive search. 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
3 changes: 3 additions & 0 deletions dev/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion garnett/context.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
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")


class set_field_language(ContextDecorator):
def __init__(self, language):
self.language = language
if isinstance(language, Language):
self.language = language
else:
self.language = Language.get(language)
self.token = None

def __enter__(self):
Expand Down
4 changes: 1 addition & 3 deletions garnett/context_processors.py
Original file line number Diff line number Diff line change
@@ -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(),
}
100 changes: 26 additions & 74 deletions garnett/fields.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,23 @@
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,
is_valid_language,
)

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

Expand All @@ -68,20 +27,19 @@ def validate_translation_dict(all_ts: dict) -> None:
raise exceptions.ValidationError("Invalid value assigned to translatable field")

# Check language codes
languages = set(get_languages())
for code, value in all_ts.items():
if not isinstance(code, str) or code not in languages:
raise exceptions.ValidationError(f'"{code}" is not a valid language code')

if not isinstance(value, str):
raise exceptions.ValidationError(f'Invalid value for language "{code}"')

if not is_valid_language(code):
raise exceptions.ValidationError(f'"{code}" is not a valid language code')


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()}

Expand All @@ -96,11 +54,14 @@ class TranslatedField(JSONField):

def __init__(self, field, *args, fallback=None, **kwargs):
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 = [
Expand Down Expand Up @@ -142,7 +103,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):
Expand All @@ -154,22 +115,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):
Expand All @@ -188,7 +139,8 @@ def translator(ego, value):
all_ts = {}

if isinstance(value, str):
all_ts[get_current_language()] = value
language_code = get_current_language_code()
all_ts[language_code] = value
elif isinstance(value, dict):
all_ts = value
else:
Expand Down Expand Up @@ -231,7 +183,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 [lang for lang in get_languages() if lang.language in langs]

setattr(cls, "available_languages", available_languages)

Expand Down Expand Up @@ -265,7 +217,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


Expand All @@ -289,4 +241,4 @@ def __call__(self, *args, **kwargs):


# Import lookups here so that they are registered by just importing the field
from garnett import lookups
from garnett import lookups # noqa: F401, E402
16 changes: 13 additions & 3 deletions garnett/lookups.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db.models.fields import json
from django.db.models import lookups
from django.db.models.fields import json, CharField
from django.db.models.functions import Cast
from django.contrib.postgres.fields.jsonb import KeyTextTransform
from django.contrib.postgres.lookups import SearchLookup, TrigramSimilar
from django.contrib.postgres.search import TrigramSimilarity
Expand Down Expand Up @@ -111,6 +112,8 @@ def process_rhs(self, compiler, connection):
return super().process_rhs(compiler, connection)


# Override contains lookup for after a key lookup i.e. title__en__contains="thing"
@TranslatedKeyTransform.register_lookup
class KeyTransformContains(json.KeyTransformTextLookupMixin, lookups.Contains):
lookup_name = "contains"

Expand All @@ -121,8 +124,15 @@ def process_rhs(self, compiler, connection):
return super().process_rhs(compiler, connection)


# Override contains lookup for after a key lookup i.e. title__en__contains="thing"
TranslatedKeyTransform.register_lookup(KeyTransformContains)
@TranslatedKeyTransform.register_lookup
class KeyTransformExact(json.KeyTransformExact):
def process_lhs(self, compiler, connection):
self.lhs = Cast(self.lhs, CharField())
return super().process_lhs(compiler, connection)

def process_rhs(self, compiler, connection):
self.rhs = Cast(self.lhs, CharField())
return super().process_lhs(compiler, connection)


@TranslatedField.register_lookup
Expand Down
Loading