gergelypolonkai-web-jekyll/_posts/2017-03-14-sqlalchemy-i18n-...

5.0 KiB
Raw Permalink Blame History

layout title date tags published author
post SQLAlchemy-i18n with application factory, dynamic locales, and Celery 2017-03-14 00:25:52+01:00
development
python
flask
true
name email
Gergely Polonkai gergely@polonkai.eu

SQLAlchemy-i18n is an awesome project if you need to make some of your model fields translatable. However, it is only usable if your application class can be direectly instantiated; if you use application factories and you have the available locales in your config, you are on your own.

SQLAlchemy-i18n basics

According to the QuickStart section of the SQLAlchemy-i18n documentation, you have to initialize the module like this:

from flask_babel import get_locale
from sqlalchemy_i18n import make_translatable, translation_base, Translatable
import sqlalchemy_utils


make_translatable(options={'locales': ['fi', 'en']})


class Article(Translatable, Base):
    __tablename__ = 'articles'
    __translatable__ = {'locales': ['fi', 'en']}  # Available locales
    locale = 'en'  # Default locale

    id = sa.Column(sa.Integer, primary_key=True)
    author = sa.Column(sa.Unicode(255))


class ArticleTranslation(translation_base(Article)):
    __tablename__ = 'article_translations'

    name = sa.Column(sa.Unicode(255))
    content = sa.Column(sa.UnicodeText)

Add application factories to the mixture

In one of my current projects, I have the following config (excerpt):

from flask_babel import lazy_gettext as _

class Config:
    AVAILABLE_LOCALES = {
        'en': _('English')
        'hu': _('Hungarian')
    }
    DEFAULT_LOCALE = 'en'

And the following application factory (again, excerpt):

from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

from app import models

def create_app(config_name):
    app = Flask(__name__)
    db.init_app(app)

Unfortunately, adding the make_translatable call to create_app and leaving the __translatable__ definition (assuming the options of the make_translatable are the default) is not working (its value is not a default for models), so here is what I came up with:

from sqlalchemy_i18n import Translatable, translation_base

from app import db


class Article(Translatable, db.Model):
    __tablename__ = 'articles'
    __translatable__ = {
        'locales': current_app.config['AVAILABLE_LOCALES'],
    }
    locale = current_app.config['DEFAULT_LOCALE']

    id = db.Column(sa.Integer, primary_key=True)
    author = sa.Column(sa.Unicode(255))

This brings up a problem: my models have to be initialized with an active app context. Here is a solution for that:

from config import config
# I have removed the models import from here

def create_app(config_name):
    app = Flask(__name__)
    app.config.from_object(config[config_name])

    make_translatable(options={
        'locales': list(app.config['AVAILABLE_LOCALES'].keys()),
    })

    with app.app_context():
        # Import models so they are registered with SQLAlchemy
        from app import models

The only drawback I found is that I cannot import my models without creating the app first; however, given I need to instantiate the app to have a working database connection, its actually not a problem. Well, not until Celery comes into play.

--Chemical X-- Celery

Up to this point I havent used Celery for this app. When I set that up, due to some import order madness (which I could overcome, but doesnt worth it), my models got imported before my Celery tasks and the Celery app, so my well-crafted solution failed miserably.

Fortunately, SQLAlchemy-i18n doesnt tamper with metaclasses neither does monkey patching on my model classes. I only have to extend Translatable, and the magic happens on its own. There are two properties Translatable needs: __translatable__, a dictionary which can, among other things, define the available locales (languages) for the model, and locale, which sets the default locale. As there are no tricky metaclasses are involved, I could easily set these properties during app initialization time:

from app import models


def create_app(config):
    app = Flask(__name__)

    # Iterate over everything in the models module
    for _, member in models.__dict__.items():
        # We look for subclasses of Translatable, but not Translatable
        # itself
        if isinstance(member, type) and \
           issubclass(member, Translatable) and \
           member != Translatable:
            member.__translatable__['locales'] = list(app.config['AVAILABLE_LOCALES'].keys())
            member.locale = app.config['DEFAULT_LOCALE']

Honestly, Im not particularly happy with this solution. It works nice with the current version of SQLAlchemy-i18n, but as soon as they switch their implementation, Im screwed (but thats where testing comes in, right?)