Translations (i18n)
Fullfinity supports multi-language translations using an approach where the English source text serves as the translation key.
Overview
Section titled “Overview”- Backend-driven: All translations are managed on the backend and served to the frontend
- English-text-as-key format: Translation files use
{"English Text": "Translated Text"}format - AI-powered: Translations are generated using OpenAI or Anthropic APIs
- Per-module: Each module has its own
i18n/folder with translation files
Translation File Format
Section titled “Translation File Format”Translation files are stored in each module’s i18n/ directory:
modules/crm/├── models/├── views/└── i18n/ ├── es.json # Spanish ├── fr.json # French └── de.json # GermanEach file uses English text as the key:
{ "Customer": "Cliente", "Expected Revenue": "Ingresos esperados", "Mark as Won": "Marcar como ganado", "Leads in this stage are considered won": "Los leads en esta etapa se consideran ganados"}Translation CLI
Section titled “Translation CLI”The fullfinity-translate CLI extracts translatable strings and generates translations.
Basic Usage
Section titled “Basic Usage”# Extract strings from a module (shows counts)./fullfinity-translate -c config.yaml --extract fullfinity/modules/crm
# Translate a single module to Spanish./fullfinity-translate -c config.yaml fullfinity/modules/crm es
# Translate all modules to multiple languages./fullfinity-translate -c config.yaml fullfinity/modules es,fr,de
# Translate to all supported languages./fullfinity-translate -c config.yaml --all fullfinity/modules/invoicing
# List supported languages./fullfinity-translate --list-languagesFrontend strings
Section titled “Frontend strings”React t() strings are extracted from app/src and stored with the core module
(modules/core/i18n/<lang>.json), because all translations are served from the backend.
They are translated automatically whenever core is within the path you pass, e.g.:
./fullfinity-translate -c config.yaml fullfinity/modules es # all modules + frontend./fullfinity-translate -c config.yaml fullfinity/modules/core es # core + frontendLoading into the database
Section titled “Loading into the database”Generated i18n/*.json files are not read at runtime — they’re loaded into the
Translation table. After generating, reload them:
- UI: Languages → pick the language → Generate translations (generates + loads in one step), or Reload translations to just load existing files.
- These map to
Language.action_generate_translations/action_load_translations. Loading only happens for installed modules.
Supported Languages
Section titled “Supported Languages”| Code | Language |
|---|---|
| es | Spanish |
| fr | French |
| de | German |
| pt-BR | Portuguese (Brazil) |
| zh-CN | Chinese (Simplified) |
| ja | Japanese |
| ar | Arabic |
| it | Italian |
| nl | Dutch |
| ru | Russian |
| ko | Korean |
| hi | Hindi |
| tr | Turkish |
| pl | Polish |
| vi | Vietnamese |
Checking that everything is actually translated
Section titled “Checking that everything is actually translated”A string only reaches a user in their language if three things hold, and each fails silently and independently:
- it is wrapped —
t()in code,T()in a module-scope table, or a display key in YAML; - it is extracted into
i18n/<lang>.json; - the value is looked up where it renders.
All three failures look identical on screen — English text — so finding them by reading the app means opening every page in a real language. Two things replace that.
Catalog completeness (checked when you release)
Section titled “Catalog completeness (checked when you release)”Every catalog that exists must contain every string the extractor can see:
./fullfinity-server -c config.yaml check --only i18n-catalogsIt lists the exact shortfall per catalog, so a partial AI run, an expired key, or a merge that added copy without regenerating is caught with the missing strings named.
This gate is release-time, not commit-time — like check --only version-bump, it is not
part of a bare check. The only way to satisfy it is to run the translator, which is an LLM
call per language; blocking every commit that adds a line of copy on that just teaches people
to ignore a permanently red build. Regenerate, then check:
./fullfinity-translate -c config.yaml fullfinity/modules/<module> es,fr,deAdding a language stays opt-in per module: a module with no es.json is never asked for
Spanish. But once the file is there, it has to be complete.
What is still only caught by looking
Section titled “What is still only caught by looking”Extraction is checked by the release gate above; wrapping is checked on every commit by
./fullfinity-server check --only i18n.
The third link — a value that IS translated but is served past its lookup — is checked per
endpoint (TestViewEndpointsTranslate), because that is where it has actually gone wrong: the
wizard endpoint composed its arch and returned it without translating, so every wizard in the
product rendered English regardless of language while the catalog beside it was complete.
If you add a new endpoint that serves view text or field labels, translate it there and add it to that test. Nothing else will notice.
Configuration
Section titled “Configuration”Add an AI API key to your config.yaml:
# Use either OpenAI or AnthropicOPENAI_API_KEY: "sk-proj-..."# orANTHROPIC_API_KEY: "sk-ant-..."What Gets Translated
Section titled “What Gets Translated”From Python Models
Section titled “From Python Models”The CLI extracts:
- Field descriptions:
description="Customer Name" - Field hints:
hint="Enter the customer's full name" - Error messages:
raise ValidationError("Invalid email format")— including a fallback inside the raise, e.g.raise UserError(remote_error or "Refund failed.") t("...")message templates (the first argument literal — see Dynamic messages below)
class Contact(Model): name = Char( description="Contact Name", # Extracted hint="Full name of the contact", # Extracted max_length=255 )A field label must be a literal the extractor can read
Section titled “A field label must be a literal the extractor can read”Extraction parses your source, so the label has to be in the source. A long hint split across lines is fine — Python joins the fragments into one string and the extractor reads that same joined string, so the key matches what renders:
hint="Extra days added to the promised delivery date, on top of " "each product's own customer lead time." # ✅ one key, the full sentenceSharing a hint between fields via a module-level constant is fine too — the extractor resolves it:
_BLANK_HINT = "Leave blank to use the mapped account."
expense_account = ManyToOne("Account", description="Expense Account", hint=_BLANK_HINT) # ✅What does not work is a label assembled at runtime, because there is no literal to collect — no key reaches the catalogue and the label stays English in every language:
weight = Float(description=f"Weight ({unit})") # ❌ f-stringstate = Char(description="Status: " + suffix) # ❌ concatenationnote = Char(description=labels["note"]) # ❌ lookup./fullfinity-server check --only i18n fails on these (CI and pre-commit), naming the
file and line. If a label genuinely has to vary, keep the field’s description static
and put the dynamic part in the value or a calculated field instead.
Error messages: static needs nothing, dynamic needs t()
Section titled “Error messages: static needs nothing, dynamic needs t()”A static error message needs no ceremony at all. Write it plainly and it is
extracted by its English text and translated for you — the framework translates the
message where it builds the error response, so every raise below ships in every
language you generate:
raise UserError("Only documents in draft stage can be posted.") # ✅ translated for youraise MissingError("The record has been deleted.") # ✅raise UserError(result.get("error") or "Refund failed.") # ✅ the fallback ships tooThe same applies to a message a helper returns — but there the literal has to be
wrapped, because a return is not a raise and nothing marks it as user-facing:
async def is_valid(self): if self.state == "Expired": return False, t("This coupon has expired.") # ✅ wrapped at the source ...
is_valid, reason = await coupon.is_valid()if not is_valid: raise UserError(reason) # ✅ already translatedDynamic messages — translate the template, don’t build a string
Section titled “Dynamic messages — translate the template, don’t build a string”A message that contains a runtime value must not be built with an f-string:
# WRONG — not translatable. The extractor can't key an f-string, and at runtime the# concrete string ("...at most 1 of Widget.") never matches a translation key.raise UserError(f"You can return at most {qty} of {name}.")Instead use the backend t() helper. It mirrors the frontend t('...{x}', {x}): the
static template is the translation key, and the dynamic values are interpolated
after the template is translated — so runtime values never have to match a key:
from fullfinity.engine.base import t # also available via `from ...base import *`
raise UserError( t("You can return at most {qty} of {name}.", qty=qty, name=name))- The first argument is a static literal with
{name}placeholders — never an f-string. It’s what gets extracted and translated. t()is synchronous — noawait, so it works in both sync and async code. It resolves the current user’s language and reads the in-process translation cache, then fills in the params. A static message with no placeholders works too (t("Only draft returns can be confirmed.")).- Because translation happens server-side at the call site, it works for anything —
UserError/ValidationErrormessages, email bodies, notifications — not just React. - Format specs can’t live in the template (they’d break the translation key). Pre-format
the value and pass the string:
t("At most {qty}…", qty=f"{n:g}").
Naming records in a message — summarize_list
Section titled “Naming records in a message — summarize_list”A message that names the rows something went wrong on must not grow with how many rows there are. Joining the whole list reads fine while you’re testing with three lines and is unreadable at thirty — and the list or form behind the toast is already showing that same per-record detail, where the user can scroll, sort and filter it:
# WRONG — the toast is as long as the document.raise UserError(t("Still short: {items}.", items=", ".join(names)))summarize_list names a few and counts the rest, so the message has a fixed ceiling
whatever the data does:
from fullfinity.engine.base import summarize_list # also via `from ...base import *`
raise UserError( t("Still short: {items}. Reserve the missing stock.", items=summarize_list(names)))# -> "Still short: Widget, Gadget, Bolt and 27 more. Reserve the missing stock."- The total survives the cap — the overflow count is what the user needs; the identities of rows 4..30 belong on the rows.
limitis how many are named before overflow: the default3suits an inline list in a toast,5or so suits a bulletedseparator="\n"list, which has more room. A list exactly one item over the limit is shown in full, since naming the last item beats “and 1 more”.separatorjoins the shown items. The overflow phrase goes on its own line for line-based separators and after a space otherwise.- Returns
""for an empty list, sosummarize_list(x) or t("…")works as a fallback. - If a full list genuinely has to be preserved, put it on the record — a field, a log line, a chatter note — and let the message point at it. Only the transient message is capped.
A list that’s bounded by code rather than data — the valid choices for a Selection, a fixed set of column names — needs none of this; naming all of them is the useful answer.
./fullfinity-server check --only i18n (CI and pre-commit) fails on a user-facing
exception — UserError, ValidationError, AccessError, MissingError — whose message
is assembled with an f-string, %, .format() or concatenation, naming the file and
line. The other exception types render a developer modal with a traceback, so they are
not checked.
If a message really is output for whoever wrote the module rather than for an end user — a bad view definition, an upgrade punch-list, a CLI hint — mark the raise and it is skipped by both the gate and extraction:
raise UserError(f"UiView {path}: 'confirm' needs a message.") # i18n: diagnosticBuild the message at raise time, not at import time. A module-level constant is
evaluated once, before any request exists, so t() there would freeze whatever language
the worker started in — wrap it in a small function instead:
_CONFLICT = t("Someone else changed this report…") # ❌ resolved once, at importdef _conflict() -> str: return t("Someone else changed this report…") # ✅ resolved per requestFrom JSON Views
Section titled “From JSON Views”The CLI extracts these keys from view definitions:
title- Section/tab titleslabel- Field labelsdescription- Descriptionsplaceholder- Input placeholdershint- Help textmessage- Messagesname- Only fromUiMenuandWindowAction(display names)
{ "type": "field", "name": "contact", "properties": { "label": "Customer", "placeholder": "Select a customer", "hint": "The customer for this order" }}From Frontend (React)
Section titled “From Frontend (React)”Strings wrapped in the t() function are extracted. The English text is the key —
pass it as the first argument:
t("Save Changes") // key + fallback in onet("Saved {count} records", { count }) // with interpolation paramsDo not invent a separate identifier key, e.g. t("save_changes", "Save Changes").
The whole pipeline (extraction, i18n/*.json, the Translation.key/source columns,
and the served lookup dict) is keyed by the English source text, so a synthetic key
like "save_changes" is never present at runtime and the string silently falls back to
English. Keying by the English text is also collision-free — the source string is
already unique, whereas a lowercased/underscored identifier can collapse distinct
strings together ("Sign up" and "Sign-up").
From Server-Rendered Templates (Jinja)
Section titled “From Server-Rendered Templates (Jinja)”Portal, website and email templates translate explicitly, through the same English-text-is-the-key rule:
<h1>{{ t('Your Orders') }}</h1><p>{{ t('Dear {name},', name=contact.name) }}</p>Report and email bodies get t() for free — the framework builds those environments.
A module that builds its own Environment(...) for its page templates must install the
global itself, or every {{ t('…') }} in the templates it renders raises
't' is undefined at render time:
from jinja2 import Environment, FileSystemLoaderfrom fullfinity.engine.jinja_filters import install_translation_global
my_env = install_translation_global( Environment(loader=FileSystemLoader(_template_dir)))Two things to know about it:
- A global, not a context variable. An
{% import %}ed macro never sees the caller’s render context, so a macro that translates its own labels can only read a global. Passingtin the render context alone leaves every macro broken. - A context translator still wins. A page that resolves its own language (from the visitor’s language cookie, or a recipient’s language on an email) puts that translator in the render context, which shadows the global. Installing the global doesn’t override per-visitor language — it’s the floor for renders that never set one.
- It also installs the display filters —
|money,|numberand|date— so a module page never has to format an amount or a date by hand.{{ total|money }}takes the symbol side, decimals and grouping characters from thecurrencyin the render context. Anything you bound yourself before calling it is left alone.
./fullfinity-server check --only i18n fails on a module environment with no translator
bound, alongside the unwrapped-string rules.
Dates follow the user’s language
Section titled “Dates follow the user’s language”Month and weekday names in dates render in the viewer’s language — you don’t translate
them by hand (they aren’t t() strings), the formatter does it.
- Server-rendered templates — the
|dateJinja filter localizes automatically.{{ order.date|date }}uses the page language’sdate_formatfor layout and renders month/day names in that language. Pass an explicit pattern ({{ d|date("MMMM D, YYYY") }}) and the word tokens still localize. The page language comes from the visitor’s choice (a?lang=param or the on-site language switcher’s cookie), falling back to their browser’sAccept-Language. - Backend Python — format a date with
format_localized(value, fmt)(fromfullfinity.engine.jinja_filters) instead ofvalue.strftime(fmt)where the result is shown to a user.fmtis an ordinarystrftimepattern; names localize to the current render’s language, then the logged-in user’s language, else English — and it never raises (any failure falls back to Englishstrftime). Keepstrftimeonly for machine formats that must stay fixed (RSS/RFC-822 dates, filenames, ISO keys). - React — dates render through
dayjs, whose locale is set from the user’s language, so.format('MMM D')and the first-day-of-week both follow that language. No per-call work.
The language carries only the names; numeric layout/order (DD/MM vs MM/DD) still comes
from the language’s date_format.
Numbers follow the user’s language
Section titled “Numbers follow the user’s language”A language also carries how a figure is punctuated: decimal_separator,
thousands_separator, and grouping — the group sizes, counted from the right, with the
last size repeating. "3" is the Western 12,345,678; "3;2" is the South Asian
lakh/crore 1,23,45,678. Amounts, floats and integers rendered by the framework’s widgets
already use these, so a Monetary or Number field needs no per-field work.
Grouping is a property of the reader, not of the money. A Currency may carry its own
separators, so an amount is punctuated the same way for everyone who sees it — but the group
sizes always come from the viewer’s language, which is what lets an en-IN user read a USD
total as $1,23,45,678.00.
In server-rendered templates — reports, portal pages, website pages, emails — the same split applies, and both filters resolve it for you from the render language:
{{ qty | number }}takes all three properties from the page language. There is no currency to speak for a quantity, so a German render prints1.234,56and an Indian one1,23,456.{{ total | money(currency) }}takes the separators from the currency and the group sizes from the page language.
The render language is the reader’s: a customer-facing document resolves it from the
recipient’s contact, falling back to the sending user’s. Nothing per-template is needed — a
body that prints {{ line.quantity | number(rounding=line.uom.rounding) }} already follows
whichever language the document is being rendered in.
How Translation Works at Runtime
Section titled “How Translation Works at Runtime”Backend
Section titled “Backend”Views, menus and field labels are translated as the request is served. Error messages
are translated where the error response is built, so a static message is looked up by
its English text with nothing needed at the raise; a message that carries a runtime
value is already translated by the time it is raised, because t() did it. The framework’s
own messages (a duplicate-key or required-field error, a permission refusal) go through
the same lookup — they live in the core catalog, which every database has.
Outside a request — a scheduled job, a CLI install — there is no user and therefore no
language, so t() returns the English template rather than failing.
The translate_arch() function translates view architectures based on user language:
from fullfinity.engine.translation import translate_arch, get_translations_dict
# Get translations for Spanishtranslations = await get_translations_dict(env, "es")
# Translate a viewtranslated_arch = translate_arch(view.arch, translations)Frontend
Section titled “Frontend”Use the useTranslation hook:
import { useTranslation } from '../contexts/TranslationContext';
function MyComponent() { const { t } = useTranslation();
return ( <Button>{t("Save Changes")}</Button> );}Adding New Translatable Strings
Section titled “Adding New Translatable Strings”- In Python models, use
description=andhint=:
name = Char(description="Product Name", hint="Enter product name")- In JSON views, use translatable keys:
{ "type": "fieldset", "title": "Customer Information", "content": [...]}- In React components, wrap with
t():
<Button>{t("Submit Order")}</Button>- Run the translation CLI to generate translations:
./fullfinity-translate -c config.yaml fullfinity/modules/mymodule esWebsite content: contributing your records to the translator
Section titled “Website content: contributing your records to the translator”The strings above are yours as a developer — field labels, view titles, messages you wrote. A website also displays records the user created: pages, menu labels, and whatever your own module puts on the site. Those are translated by the same source-keyed mechanism, but they have to be collected first, because nothing can scan a database for “text a visitor will read”.
Two halves, and they must agree:
-
Render the value through
t()in your template, exactly as you would a literal. The English text is the key, so{{ t(product.name) }}looks up the product’s own name and falls back to it when no translation exists. -
Contribute it to the harvest by extending
Website._collect_translatable_strings(). Callsuper(), add your own, return the dict. The website module never has to know your model exists — the same shape ascollect_content_sitemap_urlsfor the sitemap.
class WebsiteBlog(Model): __inherit__ = "Website"
async def _collect_translatable_strings(self) -> dict: strings = await super()._collect_translatable_strings()
from fullfinity.modules.website.models.website import collect_text_values
Post = get_model("BlogPost") for post in await Post.filter(published=True).all(): collect_text_values(strings, post.title) collect_text_values(strings, post.summary) return stringscollect_text_values(target, value) walks strings, dicts and lists — so a whole settings
blob can be handed over without knowing its shape — and skips anything that is plainly not
prose (URLs, colours, CSS values, text with no letters in it). Use it rather than writing
into the dict yourself: a translator handed #f5f5f5 will cheerfully invent something.
Collect only what the site actually publishes. Translating a draft is paying for text nobody will read.
A string rendered through t() but never harvested renders in English forever, silently —
which is the failure this pairing exists to prevent, so add both halves in the same change.
Incremental Translation
Section titled “Incremental Translation”The CLI only translates new strings. Existing translations are preserved:
# First run: translates all 50 strings./fullfinity-translate -c config.yaml fullfinity/modules/crm es# Output: Translating 50 new strings...
# Add new fields, run again: only new strings translated./fullfinity-translate -c config.yaml fullfinity/modules/crm es# Output: Translating 3 new strings...Best Practices
Section titled “Best Practices”-
Use descriptive English text - The English text becomes the key, so make it clear and unique
-
Never bake a value into a message -
t("Only {n} left.", n=count), neverf"Only {count} left.". The template is the key; a finished sentence with a value in it can never match one -
Avoid programmatic strings - Don’t translate identifiers, field names, or code
-
Review AI translations - AI translations are good but may need manual review for domain-specific terms
-
Translate early and often - Run translations as part of your development workflow
-
Keep translations in version control - The
i18n/*.jsonfiles should be committed