Repository navigation
feat(i18n): fechas e importes del servidor segun el idioma #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| """Formato de fechas e importes de lo que el servidor dibuja (tarea 10 del plan de i18n). | ||
|
|
||
| El frontend deja esto a `Intl` (`composable-os/src/i18n/format.ts`). El servidor no | ||
| lo tiene: un PDF o un correo se arman con texto y no con un runtime del navegador. En | ||
| vez de anadir `babel` por dos idiomas, una tabla propia con las mismas reglas por | ||
| defecto que el frontend (`REGION_DEFAULTS`: `es` -> `es-MX`, `en` -> `en-US`): | ||
|
|
||
| format_number(1160, "es-MX") -> "1,160.00" | ||
| format_number(1160, "es-ES") -> "1160,00" # el espanol de Espana no agrupa 4 cifras | ||
| format_date(date(2026, 8, 3), "en") -> "08/03/2026" | ||
| format_date(date(2026, 8, 3), "es") -> "03/08/2026" | ||
|
|
||
| Una region que no esta en la tabla (`es-AR`, `en-AU`) usa la regla por defecto de su | ||
| idioma, igual que el catalogo cae de `es-MX` a `es`. Una region con reglas propias se | ||
| anade a `_RULES`. Si el dia de manana el producto atiende muchas regiones, ese es el | ||
| momento de cambiar la tabla por `babel`; el contrato de estas funciones no cambia. | ||
|
|
||
| Una fecha ISO (`"2026-09-01"`) que llega como texto —los eventos la traen asi— se | ||
| interpreta y se formatea; un texto que no es una fecha sale tal cual, porque un | ||
| documento con un dato raro es mejor que un documento que no se genera. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from datetime import date, datetime | ||
|
|
||
| from app.core.i18n import DEFAULT_LOCALE | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class _Rule: | ||
| group: str | ||
| decimal: str | ||
| date_pattern: str | ||
| # Cifras enteras minimas para agrupar (CLDR `minimumGroupingDigits`): en espanol | ||
| # de Espana `1234,50` no lleva separador y `12.345,50` si. | ||
| min_grouping: int = 1 | ||
|
|
||
|
|
||
| _RULES: dict[str, _Rule] = { | ||
| "es-MX": _Rule(group=",", decimal=".", date_pattern="%d/%m/%Y"), | ||
| "es-ES": _Rule(group=".", decimal=",", date_pattern="%d/%m/%Y", min_grouping=2), | ||
| "en-US": _Rule(group=",", decimal=".", date_pattern="%m/%d/%Y"), | ||
| "en-GB": _Rule(group=",", decimal=".", date_pattern="%d/%m/%Y"), | ||
| } | ||
|
|
||
| # La region que se asume cuando solo hay idioma. La misma tabla que el frontend. | ||
| _LANGUAGE_DEFAULTS: dict[str, str] = {"es": "es-MX", "en": "en-US"} | ||
|
|
||
|
|
||
| def _rule_for(locale: str | None) -> _Rule: | ||
| tag = (locale or DEFAULT_LOCALE).replace("_", "-") | ||
| parts = tag.split("-") | ||
| language = parts[0].lower() | ||
| if len(parts) > 1: | ||
| full = f"{language}-{parts[1].upper()}" | ||
| if full in _RULES: | ||
| return _RULES[full] | ||
| default = _LANGUAGE_DEFAULTS.get(language, _LANGUAGE_DEFAULTS[DEFAULT_LOCALE]) | ||
| return _RULES[default] | ||
|
|
||
|
|
||
| def format_number(value: float, locale: str | None = DEFAULT_LOCALE, *, decimals: int = 2) -> str: | ||
| """`1160` -> `1,160.00` en `es`/`en`, con los separadores de la region.""" | ||
| rule = _rule_for(locale) | ||
| text = f"{value:,.{decimals}f}" | ||
| integer_digits = len(text.split(".")[0].lstrip("-").replace(",", "")) | ||
| if integer_digits < rule.min_grouping + 3: | ||
| text = text.replace(",", "") | ||
| # Dos pasos: cambiar `,` por `.` y `.` por `,` en uno solo se pisaria. | ||
| return text.replace(",", "\0").replace(".", rule.decimal).replace("\0", rule.group) | ||
|
|
||
|
|
||
| def format_money( | ||
| value: float, currency: str, locale: str | None = DEFAULT_LOCALE, *, decimals: int = 2 | ||
| ) -> str: | ||
| """`USD 1,160.00`: el codigo de moneda, un espacio y el importe segun el idioma.""" | ||
| return f"{currency} {format_number(value, locale, decimals=decimals)}" | ||
|
|
||
|
|
||
| def format_date(value: date | datetime | str, locale: str | None = DEFAULT_LOCALE) -> str: | ||
| """La fecha en el orden y el separador del idioma (`03/08/2026` o `08/03/2026`).""" | ||
| if isinstance(value, str): | ||
| try: | ||
| value = date.fromisoformat(value[:10]) | ||
| except ValueError: | ||
| return value | ||
| return value.strftime(_rule_for(locale).date_pattern) | ||
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| """ | ||
| Fechas e importes del servidor segun el idioma (`core/i18n/format.py`, tarea 10). | ||
|
|
||
| Las reglas por defecto son las del frontend (`es` -> `es-MX`, `en` -> `en-US`), asi | ||
| que el mismo importe se ve igual en la interfaz y en el PDF. | ||
| """ | ||
| from datetime import date, datetime | ||
|
|
||
| import pytest | ||
|
|
||
| from app.core.i18n.format import format_date, format_money, format_number | ||
|
|
||
| AUG_3 = date(2026, 8, 3) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "locale, expected", | ||
| [ | ||
| ("es", "1,234,567.50"), | ||
| ("es-MX", "1,234,567.50"), | ||
| ("en", "1,234,567.50"), | ||
| ("en-US", "1,234,567.50"), | ||
| ("en_us", "1,234,567.50"), | ||
| ("en-GB", "1,234,567.50"), | ||
| ("es-ES", "1.234.567,50"), | ||
| ("es-AR", "1,234,567.50"), # sin regla propia: la del idioma | ||
| ("fr", "1,234,567.50"), # sin catalogo: la del idioma por defecto | ||
| (None, "1,234,567.50"), | ||
| ], | ||
| ) | ||
| def test_number_separators_follow_the_region(locale, expected): | ||
| assert format_number(1234567.5, locale) == expected | ||
|
|
||
|
|
||
| def test_spanish_of_spain_does_not_group_four_digits(): | ||
| assert format_number(1234.5, "es-ES") == "1234,50" | ||
| assert format_number(12345.5, "es-ES") == "12.345,50" | ||
| assert format_number(-1234.5, "es-ES") == "-1234,50" | ||
| assert format_number(1234.5, "es-MX") == "1,234.50" | ||
|
|
||
|
|
||
| def test_small_and_negative_amounts_keep_their_shape(): | ||
| assert format_number(0, "en") == "0.00" | ||
| assert format_number(999.999, "en") == "1,000.00" | ||
| assert format_number(-1234.5, "en") == "-1,234.50" | ||
| assert format_number(7, "es-ES", decimals=0) == "7" | ||
|
|
||
|
|
||
| def test_money_is_code_space_amount(): | ||
| assert format_money(1160, "USD", "en") == "USD 1,160.00" | ||
| assert format_money(1160, "EUR", "es-ES") == "EUR 1160,00" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "locale, expected", | ||
| [ | ||
| ("es", "03/08/2026"), | ||
| ("es-MX", "03/08/2026"), | ||
| ("es-ES", "03/08/2026"), | ||
| ("en", "08/03/2026"), | ||
| ("en-US", "08/03/2026"), | ||
| ("en-GB", "03/08/2026"), | ||
| ("fr", "03/08/2026"), | ||
| (None, "03/08/2026"), | ||
| ], | ||
| ) | ||
| def test_date_order_follows_the_language(locale, expected): | ||
| assert format_date(AUG_3, locale) == expected | ||
|
|
||
|
|
||
| def test_a_datetime_formats_as_its_date(): | ||
| assert format_date(datetime(2026, 8, 3, 23, 59), "en") == "08/03/2026" | ||
|
|
||
|
|
||
| def test_an_iso_string_is_read_and_formatted(): | ||
| assert format_date("2026-08-03", "en") == "08/03/2026" | ||
| assert format_date("2026-08-03T10:00:00+00:00", "es") == "03/08/2026" | ||
|
|
||
|
|
||
| def test_text_that_is_not_a_date_comes_out_untouched(): | ||
| assert format_date("pronto", "en") == "pronto" | ||
| assert format_date("", "en") == "" |
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For any accepted regional locale not explicitly listed in
_RULES, this falls back to the language default even thoughnormalize_localepreserves arbitrary two-letter regions and the frontend passes the complete tag toIntl. Consequently users with locales such ases-AR,en-AU, oren-INreceive server-generated invoices and emails with Mexican or US formatting while the UI uses their actual regional conventions (for example,en-AUdates become08/03/2026here instead of03/08/2026, andes-ARamounts use commas instead of periods for grouping). Either support every locale the API accepts via a locale-aware formatter or restrict locale validation to the regions represented by this table.Useful? React with 👍 / 👎.