Skip to content

Translatable Values

A product name, a category, a blog post, the copy on a shop page — content your users write and your customers read. A German customer should see the German name.

That is a different mechanism from translating the interface, and the two must never be joined up:

Interface textRecord content
Examplesfield captions, tabs, buttons, menus, Selection choicesproduct name, category name, website copy
Keyed bythe English source text, shared across the whole installthe record and the field
Written bythe translation pipelinea user, one record at a time
Howsee Translationsthis page

Keying record content by its English text is the trap here: it cannot tell a caption from a value. “Admin” is a menu category and what an administrator might be called, so one shared entry would rename every record that happened to say “Admin”. Record content is keyed by the record.

One keyword on a Char or Text field:

class Product(Model):
_verbose_name = "Product"
name = Char(max_length=255, description="Product Name", translate=True)
description = Text(description="Description", translate=True)
default_code = Char(max_length=100, description="SKU") # a key, not prose
barcode = Char(max_length=100, description="Barcode") # likewise

It is opt-in, per field, and the example above is why. name and barcode are the same type declared two lines apart, so no rule about the field type could separate them. Leaving a field out is visible and harmless — its value simply can’t be translated, and one keyword fixes it later at no cost (adding one is an additive migration: no ledger entry, no hook).

Declaring at least one translatable field is the whole opt-in. The model gains a single translations JSON column holding every language for the row, keyed field → language:

product row
id : 214
name : "Acoustic Partitions" ← unchanged; the source, still varchar
translations : {"name": {"de": "Akustik-Trennwände"}}

A model that declares none — a junction table, anything holding only codes and numbers — never gets the column.

Each of these reads correctly and writes wrongly, so each fails at import rather than at the first German edit:

DeclarationWhy
Any type other than Char/TextA Selection’s stored value is its label and is localized at display time; nothing else holds prose.
EncryptedHolds a credential.
store=False / calculate=A derived value is recalculated on read, so an overlay on it is overwritten. Translate the fields it is computed from.
related_field=The value belongs to the record at the far end, and so do its translations. Declare it on the model that owns the value and read it through the relation.
unique=TrueAn identifier. Lookups resolve against the source column, so a translated key could never be found.
A transient (wizard) modelNo row to store an overlay against.

Display translates; logic does not. That one sentence covers every case.

product.name # "Acoustic Partitions" — ALWAYS the source
product.translated("name") # "Akustik-Trennwände" for a German reader

Ordinary attribute access always returns the source, which is what business logic, comparisons, exports, imports and lookups must see. translated() is the display accessor — synchronous and query-free (the overlay came back with the record), so it works in a Jinja template as well as in Python:

<h1>{{ product.translated('name') }}</h1>

The API does this for you: serialize() resolves every translatable field to the reader’s language, so lists, forms, kanban cards, dropdown labels and display_name all arrive translated with nothing to remember. A field with no translation returns its source — so nothing anywhere changes until somebody writes one.

Pass translate=False to serialize() for a payload that must be the data rather than the presentation (an export, an outbound integration).

Resolved most-specific-first: the page language a website or portal render computed for its visitor, else the authenticated user’s own language, else English. A public storefront in German reads German whoever happens to be logged in.

Takes the name field’s translation when the label is that field’s value — the default implementation, and the vast majority of models. A model that composes its label from several fields ("{Parent}, {Name}") keeps the label it built, because substituting into a composed string would mean guessing which span came from where.

The field’s own input always writes the source. There is deliberately no rule that turns an ordinary edit into a translation based on the editor’s interface language — “my screen is in German” is not “I am authoring German”, and conflating them means a German-speaking colleague fixing a typo silently creates a translation and leaves the typo in the English.

Per-language text is written only where a language is named explicitly:

await product.set_translations({
"name": {"de": "Akustik-Trennwände", "fr": "Cloisons acoustiques"},
})
  • Merges, so two people translating different languages don’t overwrite each other.
  • An empty string clears that language — the field falls back to the source again.
  • The source language is refused. It lives in the field’s own column; writing it here is the confusion this whole design exists to prevent.
  • Goes through update(), so access control, tracking and validation all apply.

In the app, users do this through the translate button beside a translatable field. Its values are held in the form until the record’s normal Save, so a translation lands in the same write as the rest of the edit, and a brand-new record can be translated before it exists.

A serialized record carries the display value in the field and the languages beside it, with the source filled in:

{ "name": "Akustik-Trennwände",
"translations": { "name": { "en": "Acoustic Partitions",
"de": "Akustik-Trennwände" } } }

The en entry is not stored anywhere — it is read off the source column when the record is serialized. It ships because the field’s own key holds the value being displayed, and an editable surface needs the source it was overlaid onto. So: display reads the field, an editor reads translations[field].en.

To write, post translations with the languages you want (omit the source language):

{ "translations": { "name": { "de": "Akustik-Trennwände" } } }

A bulk update over a selection can’t carry translations — the overlay merges into each row’s own value, which one statement applying a single value to every row cannot express — so it is refused rather than silently applied wrongly.

A feature that translates a whole record — a “Translate this page” button, an export, a review screen — needs to know what prose is on it. Read that from the declaration rather than listing field names, so a field another module adds through __inherit__ is picked up without touching your code:

Product.translatable_fields() # ("description", "name") — declared translate=True
Product.translatable_json_fields() # JSON fields whose leaves are translatable by path
product.translatable_values() # {"name": "Acoustic Partitions", …} — SOURCE text,
# skipping anything empty

translatable_values() returns the source text to be translated, keyed by field; pair it with set_translations() to write the answers back:

sources = record.translatable_values()
answers = await translate(sources, "de") # your translation step
await record.set_translations({f: {"de": answers[f]} for f in answers})

Hardcoding the field list instead is what makes such a feature un-extensible: the module that adds a translatable field has no way to join in, and the omission is silent — the field simply never gets translated.

JSON fields are listed separately because they carry no automatic overlay: their text is addressed by path and only the model knows which leaves hold prose (see Structured content).

A human search matches the source or the reader’s language. The translation is an ordinary column value, so this costs one extra condition and no join:

# Reading in German
await Product.filter(name__icontains="Akustik").all() # finds it
await Product.filter(name__icontains="Acoustic").all() # also finds it

This applies to substring and prefix/suffix lookups — contains, icontains, startswith, endswith and their negations — which is what a search box produces.

An exact comparison never does. Q(name="Acoustic Partitions"), a record rule, an automation condition, resolving a barcode, matching an import key: all of those are identity, and identity resolves against the source column alone. A translation is a label for a value, never the value.

Sorting follows the reader’s language too, with the source as the fallback, so a German list is ordered the way it reads on screen:

await Product.filter().order_by("name ASC").all()

If a language ever needs an index at scale, that is one expression index and no design change:

CREATE INDEX product_name_de ON product ((translations->'name'->>'de'));

Some content has no Char field to annotate because its shape is data: a page-builder section stores its copy in one JSON blob whose keys come from the section’s own template. For that, declare the JSON field translatable and its text leaves become addressable by path:

settings_data = JSON(description="Section settings", default={}, translate=True)
translations : { "settings_data.heading": {"de": "Willkommen"},
"settings_data.testimonials.0.quote": {"de": "Sehr gut"} }

The blob’s shape is unchanged — the key is a path instead of a field name — so a repeater’s rows translate independently, which is the point: two testimonials are two records’ worth of content in one field.

The engine does not overlay these for you, and that is deliberate: only your model knows which leaves hold prose. A section reads its template’s schema to find the text/textarea/ richtext entries; everything else in those settings is a colour, a URL, an icon name or a layout flag, and translating one would break the page rather than localize it. Resolve them yourself with overlay_json:

from fullfinity.engine.field_translation import overlay_json
settings = overlay_json(
self.translations, "settings_data", self.settings_data, self.text_keys(schema)
)

Declaring it grants the storage column and the helpers; the model decides the rest.

A group called “Access Management”, a unit of measure called “Dozen”, a category called “Sales” — records your module seeds from data/ or security/, whose names are words rather than a customer’s data. Those translate too, and you get it by declaring the field:

class Group(Model):
name = Char(max_length=255, required=True, translate=True)

That one flag drives all three halves:

  1. Extraction. ./fullfinity-translate collects a seeded record’s name when the model declares it translatable — so the string reaches your module’s i18n/<lang>.json and gets translated with everything else. (Without the flag, name is treated as an identifier or a data value and is deliberately not collected.)
  2. Shipping. Enabling a language copies each shipped translation into that record’s own translations blob.
  3. Reading. Nothing special — it is an ordinary per-record translation from then on.

Only seeded records are filled. Every record has an identifier, because the ORM stamps an auto one (<ModelName>_<uuid>) on create; what marks a record as shipped is an identifier somebody authored in a data file. A record a user created is never touched, however its name reads — which is what keeps a customer called “Admin” from acquiring the translation of the menu category “Admin”.

A translation somebody edited is never overwritten. The fill only writes an entry that is absent, so a customer’s wording survives every upgrade and every language reload.

Renaming or deleting a translatable field carries its translations with it: translations are keyed by the field’s name inside the blob, so the upgrade moves or drops that key in the same step that moves or drops the column. Nothing to do by hand — record the change with ./fullfinity-server resolve as usual.

Duplicating a record carries its translations, since it carries the source values they belong to.

  • Interface text — labels, buttons, menus, tabs, Selection choices, t() messages. Those keep working through the translation pipeline, keyed by their English source text.
  • A bulk “translate everything” action. Nothing in the pipeline reaches record data; if you build such an action, it decides which records and fields to fill.