fix(i18n): translate empty-value labels in reports (v1.39.1) - #74
Merged
Conversation
"(sem marca)", "(sem material)" and "(sem local)" were rendered in
Portuguese for every user, including those browsing in English or
Spanish. The cause was where they were born: `database.py` stamped the
finished label into the data itself --
`COALESCE(NULLIF(s.location,''), '(sem local)')` in `report_by_location()`
and the `fallback` argument of `_breakdown()` in `consumption_report()`.
Translation here is a string lookup performed in the template, so a label
that arrives pre-rendered can never be looked up.
The fix moves the decision to the layer that owns presentation: the data
layer now returns an empty string and the template picks the label.
Why `_()` wraps the literal and never the data
----------------------------------------------
The tempting one-line fix, `{{ _(row.name) }}`, is a bug. Brand, material
and location are free text typed by the user, so a real brand called
"Preco" (a key in translations.py) would silently render as "Price" in
English. `_()` may only wrap the literal, and only on the branch where
the value is empty:
{{ r.name if r.name else _('(sem marca)') }}
Both halves are pinned by tests: one asserts the label is translated, and
`test_real_location_matching_a_translation_key_is_never_translated`
asserts a real value colliding with a translation key comes out
untouched. Reintroducing either bug fails the suite.
The `_()`-over-a-variable calls left in stats.html and spools/list.html
are deliberate and stay: they translate the colour family, a closed
vocabulary produced by `database.classify_color`, not user input.
Side effect, an improvement: `GET /api/stock` no longer emits a
Portuguese UI label into machine-read JSON -- an unset location is now an
empty string.
Tests: 249 passed (was 239).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Three empty-value labels in the reports were shown in Portuguese to every user, including those browsing in English or Spanish:
(sem material)(sem marca)(sem local)(sem marca)/(sem material)shipped in v1.39.0;(sem local)had been leaking for much longer.Why they escaped
_()Not an oversight at the call site — they were born in the wrong layer.
database.pystamped the finished label straight into the data:Translation in this project is a string lookup performed in the template. A label that arrives already rendered has nothing left to look up, so it passes through untouched no matter what language is selected.
The fix moves the decision to the layer that owns presentation. The data layer returns an empty string; the template picks the label.
Why
_()wraps the literal and never the dataThe tempting one-liner,
{{ _(row.name) }}, is itself a bug — and avoiding it is the whole point of this PR.Brand, material and location are free text typed by the user.
translations.pyis a plain dict keyed by Portuguese source strings, so any user value that happens to collide with a key gets silently rewritten. A real brand namedPreçowould render as "Price" in English. There is no guard against this, because a lookup table cannot tell a label from a coincidence.So
_()may only wrap the literal, and only on the branch where the value is empty:{{ r.name if r.name else _('(sem marca)') }}This is also why the
_()-over-a-variable calls intemplates/reports/stats.html:12andtemplates/spools/list.html:10are left alone: those translate the colour family, a closed vocabulary produced bydatabase.classify_color(Preto/Branco/Cinza/…), not user input. The rule is not "never_()a variable" — it is "never_()free user data".Tests
Both halves are pinned, so neither bug can come back quietly:
test_real_location_matching_a_translation_key_is_never_translatedand its brand twin create a real value that collides with a translation key (Tudo→ "All time",Preço→ "Price") and assert it renders unchanged.I verified these tests are not blind by reintroducing both bugs in the template — 3 tests fail, then pass again once reverted.
The HTTP assertions anchor on text unique to the target page body (
Consumed (kg),Stock by Location). This matters here: the bootstrap admin carriesmust_change_password=1, so every route silently 302s to/account/password, which renders the nav and returns 200 — a naive "is the text there?" check would pass against the wrong page.249 passed (was 239).
Side effect, an improvement
GET /api/stockstopped emitting a Portuguese UI label into machine-read JSON (Home Assistant consumes this). An unset location is now""instead of"(sem local)". Noted in the changelog since it is visible to integrations. No documented contract pinned the old literal.Also
docs/ARMADILHAS.mdgets the trap in the same commit, per project rule, including the sister sweep and the note about which_()-on-variable calls are legitimate.VERSION→ 1.39.1 (translation fix = PATCH), with the changelog entry in the same commit.🤖 Generated with Claude Code