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 text | Record content | |
|---|---|---|
| Examples | field captions, tabs, buttons, menus, Selection choices | product name, category name, website copy |
| Keyed by | the English source text, shared across the whole install | the record and the field |
| Written by | the translation pipeline | a user, one record at a time |
| How | see Translations | this 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.
Declaring a translatable field
Section titled “Declaring a translatable field”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") # likewiseIt 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.
Where translate=True is refused
Section titled “Where translate=True is refused”Each of these reads correctly and writes wrongly, so each fails at import rather than at the first German edit:
| Declaration | Why |
|---|---|
Any type other than Char/Text | A Selection’s stored value is its label and is localized at display time; nothing else holds prose. |
Encrypted | Holds 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=True | An identifier. Lookups resolve against the source column, so a translated key could never be found. |
| A transient (wizard) model | No row to store an overlay against. |
Reading
Section titled “Reading”Display translates; logic does not. That one sentence covers every case.
product.name # "Acoustic Partitions" — ALWAYS the sourceproduct.translated("name") # "Akustik-Trennwände" for a German readerOrdinary 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).
The reader’s language
Section titled “The reader’s language”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.
display_name
Section titled “display_name”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.
Writing
Section titled “Writing”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.
What the API sends
Section titled “What the API sends”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.
Walking a record’s translatable fields
Section titled “Walking a record’s translatable fields”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=TrueProduct.translatable_json_fields() # JSON fields whose leaves are translatable by path
product.translatable_values() # {"name": "Acoustic Partitions", …} — SOURCE text, # skipping anything emptytranslatable_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 stepawait 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).
Searching and sorting
Section titled “Searching and sorting”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 Germanawait Product.filter(name__icontains="Akustik").all() # finds itawait Product.filter(name__icontains="Acoustic").all() # also finds itThis 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'));Structured content (a JSON field)
Section titled “Structured content (a JSON field)”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.
Vocabulary your module ships
Section titled “Vocabulary your module ships”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:
- Extraction.
./fullfinity-translatecollects a seeded record’snamewhen the model declares it translatable — so the string reaches your module’si18n/<lang>.jsonand gets translated with everything else. (Without the flag,nameis treated as an identifier or a data value and is deliberately not collected.) - Shipping. Enabling a language copies each shipped translation into that record’s own
translationsblob. - 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.
Schema changes
Section titled “Schema changes”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.
What this does not cover
Section titled “What this does not cover”- Interface text — labels, buttons, menus, tabs,
Selectionchoices,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.