From 6f15413bd7b2722a5aeacebb2eaed865db4aea9d Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 16:52:44 -0400 Subject: [PATCH 01/16] fix: Fix translation of application status. feat: Add BorrowerSector. Use gettext to translate enums. --- .github/workflows/i18n.yml | 4 +- .gitignore | 22 +- app/i18n.py | 33 ++- app/models.py | 23 ++ app/routers/downloads.py | 2 +- app/utils/tables.py | 62 +---- babel.cfg | 4 + docs/contributing/index.rst | 12 +- docs/frontend.rst | 40 +-- locale/es/LC_MESSAGES/messages.po | 426 ++++++++++++++++++------------ pyproject.toml | 7 + 11 files changed, 354 insertions(+), 281 deletions(-) diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index ffb7f158..43153d48 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -17,11 +17,11 @@ jobs: run: | sudo apt update sudo apt install translate-toolkit - - run: pip install -r requirements.txt + - run: pip install -r requirements.txt . - name: Update catalogs run: | pybabel extract -F babel.cfg -o messages.pot . - pybabel update -i messages.pot -d locale + pybabel update -N -i messages.pot -d locale - name: Count incomplete translations shell: bash run: | diff --git a/.gitignore b/.gitignore index 92cbcba5..7b6294b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,14 @@ +# Filesystem +.DS_Store + # Editor *.swp *~ +.vscode/launch.json # Virtual environment /.ve /venv -.env # Bytecode __pycache__ @@ -15,23 +18,16 @@ __pycache__ /.coverage /htmlcov -# Sphinx -/docs/_build - # I18n *.mo -# Node -node_modules -npm-debug.log +# Environment +.env -#test dbs -*.db -.vscode/launch.json -.coverage -coverage_re -.DS_Store +# Documentation +/docs/_build /schemaspy /credere-main-*.json /test.json +/*.egg-info /messages.pot diff --git a/app/i18n.py b/app/i18n.py index cb826306..395ec203 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -1,10 +1,13 @@ +import ast import gettext +from collections.abc import Collection from pathlib import Path -from typing import Any +from typing import IO, Any, Generator from app.settings import app_settings -localedir = Path(__file__).absolute().parent.parent / "locale" +basedir = Path(__file__).absolute().parent.parent +localedir = basedir / "locale" translators = { path.name: gettext.translation("messages", localedir, languages=[path.name]) @@ -13,5 +16,31 @@ } +class Visitor(ast.NodeVisitor): + def __init__(self, classes: list[str]): + self.classes = classes + self.messages: list[tuple[int, str, str]] = [] + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + if node.name in self.classes: + for child in node.body: + # Skip docstrings. + if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant): + continue + assert isinstance(child, ast.Assign) and isinstance(child.value, ast.Constant), ast.unparse(child) + self.messages.append((child.value.lineno, child.value.value, node.name)) + + def _(message: str, language: str = app_settings.email_template_lang, **kwargs: Any) -> str: return translators[language].gettext(message) % kwargs + + +# https://babel.pocoo.org/en/latest/api/messages/extract.html#babel.messages.extract.extract_python uses tokenize, +# but it is easier with ast. (Compare to `python -m tokenize -e app/models.py`.) +def extract_enum( + fileobj: IO[bytes], keywords: dict[str, Any], comment_tags: Collection[str], options: dict[str, Any] +) -> Generator[tuple[int, str, str, list[str]], None, None]: + visitor = Visitor(options.get("classes", "").split(",")) + visitor.visit(ast.parse((basedir / "app" / "models.py").read_text())) + for lineno, text, comment in visitor.messages: + yield lineno, "", text, [comment] diff --git a/app/models.py b/app/models.py index 39af223c..b677ae15 100644 --- a/app/models.py +++ b/app/models.py @@ -285,6 +285,29 @@ class BorrowerSize(StrEnum): BIG = "BIG" +class BorrowerSector(StrEnum): + AGRICULTURA = "agricultura" + MINAS = "minas" + MANUFACTURA = "manufactura" + ELECTRICIDAD = "electricidad" + AGUA = "agua" + CONSTRUCCION = "construccion" + TRANSPORTE = "transporte" + ALOJAMIENTO = "alojamiento" + COMUNICACIONES = "comunicaciones" + ACTIVIDADES_FINANCIERAS = "actividades_financieras" + ACTIVIDADES_INMOBILIARIAS = "actividades_inmobiliarias" + ACTIVIDADES_PROFESIONALES = "actividades_profesionales" + ACTIVIDADES_SERVICIOS_ADMINISTRATIVOS = "actividades_servicios_administrativos" + ADMINISTRACION_PUBLICA = "administracion_publica" + EDUCACION = "educacion" + ATENCION_SALUD = "atencion_salud" + ACTIVIDADES_ARTISTICAS = "actividades_artisticas" + OTRAS_ACTIVIDADES = "otras_actividades" + ACTIVIDADES_HOGARES = "actividades_hogares" + ACTIVIDADES_ORGANIZACIONES_EXTRATERRITORIALES = "actividades_organizaciones_extraterritoriales" + + class CreditType(StrEnum): LOAN = "LOAN" CREDIT_LINE = "CREDIT_LINE" diff --git a/app/routers/downloads.py b/app/routers/downloads.py index a1a9bf37..0c693393 100644 --- a/app/routers/downloads.py +++ b/app/routers/downloads.py @@ -149,7 +149,7 @@ async def export_applications( _("Legal Name", lang): application.borrower.legal_name, _("Email Address", lang): application.primary_email, _("Submission Date", lang): application.borrower_submitted_at, - _("Stage", lang): _(application.status.capitalize(), lang), + _("Stage", lang): _(application.status, lang), } for application in ( models.Application.submitted_to_lender(session, user.lender_id) diff --git a/app/utils/tables.py b/app/utils/tables.py index 266576ae..097172f9 100644 --- a/app/utils/tables.py +++ b/app/utils/tables.py @@ -191,49 +191,6 @@ def create_borrower_table(borrower: models.Borrower, application: models.Applica :return: The generated table. """ - # Keep in sync with MSME_TYPES_NAMES in credere-frontend - borrower_size = { - models.BorrowerSize.NOT_INFORMED: _("Not informed", lang), - models.BorrowerSize.MICRO: _("0 to 10", lang), - models.BorrowerSize.SMALL: _("11 to 50", lang), - models.BorrowerSize.MEDIUM: _("51 to 200", lang), - models.BorrowerSize.BIG: _("+ 200", lang), - }[borrower.size] - - # Keep in sync with SECTOR_TYPES in credere-frontend - borrower_sector = { - "agricultura": _("Agricultura, ganadería, caza, silvicultura y pesca"), - "minas": _("Explotación de minas y canteras"), - "manufactura": _("Industrias manufactureras"), - "electricidad": _("Suministro de electricidad, gas, vapor y aire acondicionado"), - "agua": _( - "Distribución de agua; evacuación y tratamiento de aguas residuales, gestión de desechos y actividades de " - "saneamiento ambiental" - ), - "construccion": _("Construcción"), - "transporte": _("Transporte y almacenamiento"), - "alojamiento": _("Alojamiento y servicios de comida"), - "comunicaciones": _("Información y comunicaciones"), - "actividades_financieras": _("Actividades financieras y de seguros"), - "actividades_inmobiliarias": _("Actividades inmobiliarias"), - "actividades_profesionales": _("Actividades profesionales, científicas y técnicas"), - "actividades_servicios_administrativos": _("Actividades de servicios administrativos y de apoyo"), - "administracion_publica": _( - "Administración pública y defensa; planes de seguridad social de afiliación obligatoria" - ), - "educacion": _("Educación"), - "atencion_salud": _("Actividades de atención de la salud humana y de asistencia social"), - "actividades_artisticas": _("Actividades artísticas, de entretenimiento y recreación"), - "otras_actividades": _("Otras actividades de servicios"), - "actividades_hogares": _( - "Actividades de los hogares individuales en calidad de empleadores; actividades no diferenciadas de los " - "hogares individuales como productores de bienes yservicios para uso propio" - ), - "actividades_organizaciones_extraterritoriales": _( - "Actividades de organizaciones y entidades extraterritoriales" - ), - }[borrower.sector] - return create_table( [ [ @@ -258,11 +215,11 @@ def create_borrower_table(borrower: models.Borrower, application: models.Applica ], [ _("Size", lang), - borrower_size, + _(borrower.size, lang), ], [ _("Sector", lang), - borrower_sector, + _(borrower.sector, lang), ], [ _("Annual Revenue", lang), @@ -285,19 +242,6 @@ def create_documents_table(documents: list[models.BorrowerDocument], lang: str) :return: The generated table. """ - # Keep in sync with DOCUMENT_TYPES_NAMES in credere-frontend - document_types = { - models.BorrowerDocumentType.INCORPORATION_DOCUMENT: _("Incorporation document"), - models.BorrowerDocumentType.SUPPLIER_REGISTRATION_DOCUMENT: _("Supplier registration document"), - models.BorrowerDocumentType.BANK_NAME: _("Bank name"), - models.BorrowerDocumentType.BANK_CERTIFICATION_DOCUMENT: _("Bank certification document"), - models.BorrowerDocumentType.FINANCIAL_STATEMENT: _("Financial statement"), - models.BorrowerDocumentType.SIGNED_CONTRACT: _("Signed contract"), - models.BorrowerDocumentType.SHAREHOLDER_COMPOSITION: _("Shareholder composition"), - models.BorrowerDocumentType.CHAMBER_OF_COMMERCE: _("Chamber of Commerce"), - models.BorrowerDocumentType.THREE_LAST_BANK_STATEMENT: _("Three last bank statement"), - } - data = [ [ _("MSME Documents", lang), @@ -307,7 +251,7 @@ def create_documents_table(documents: list[models.BorrowerDocument], lang: str) for document in documents: data.append( [ - document_types[document.type], + _(document.type), document.name, ] ) diff --git a/babel.cfg b/babel.cfg index 19d23b74..5c35dc33 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1 +1,5 @@ [python: app/**.py] + +# Use a dummy value. Line numbers refer to models.py. https://github.com/python-babel/babel/pull/1121 +[enum: README.rst] +classes = ApplicationStatus,BorrowerDocumentType,BorrowerSize,BorrowerSector diff --git a/docs/contributing/index.rst b/docs/contributing/index.rst index 5059f035..7ab1fc24 100644 --- a/docs/contributing/index.rst +++ b/docs/contributing/index.rst @@ -39,6 +39,12 @@ Setup alembic upgrade head +#. Install the entry point for Babel: + + .. code-block:: bash + + pip install -e . + #. Compile message catalogs: .. code-block:: bash @@ -193,7 +199,7 @@ Update translations .. code-block:: bash pybabel extract -F babel.cfg -o messages.pot . - pybabel update -i messages.pot -d locale + pybabel update -N -i messages.pot -d locale #. Compile the message catalogs (in development): @@ -201,6 +207,10 @@ Update translations pybabel compile -f -d locale +.. note:: + + The ``babel.cfg`` file indicates from which ``StrEnum`` classes to extract messages. If Credere is deployed in English, we must add an ``en`` locale to translate these. Otherwise, the translations will be the database values, like "MICRO" instead of "0 to 10". + .. admonition:: Reference ``pybabel`` commands in `Translate with Transifex `__ diff --git a/docs/frontend.rst b/docs/frontend.rst index 7bb9cc3c..c7bb0a49 100644 --- a/docs/frontend.rst +++ b/docs/frontend.rst @@ -21,22 +21,6 @@ In some cases, this callback calls ``handleRequestError``, which looks up the `` That is why some ``detail`` values are like ``util.ERROR_CODES.DOCUMENT_VERIFICATION_MISSING``. -Schemas and models ------------------- - -Credere frontend's ``/src/schemas/`` schemas should match ``app.parsers``, ``app.serializers`` and ``app.models`` models. - -This table is contructed by running this command, and filling in information from Credere frontend's ``src/api/`` files: - -.. code-block:: bash - - python -m app.commands dev routes --csv-format - -.. csv-table:: - :file: _static/routes.csv - :header-rows: 1 - :class: datatable - Enumerations ------------ @@ -62,16 +46,18 @@ Credere frontend's ``src/constants/index.ts`` constants should match ``app.model * - UserType - USER_TYPES -The translatable strings in three ``dict``s in ``app/utils/tables.py`` should match ``src/constants/index.ts`` constants: +Schemas and models +------------------ -.. list-table:: - :header-rows: 1 +Credere frontend's ``/src/schemas/`` schemas should match ``app.parsers``, ``app.serializers`` and ``app.models`` models. - * - Backend - - Frontend - * - borrower_size - - MSME_TYPES_NAMES - * - borrower_sector - - SECTOR_TYPES - * - document_types - - DOCUMENT_TYPES_NAMES +This table is contructed by running this command, and filling in information from Credere frontend's ``src/api/`` files: + +.. code-block:: bash + + python -m app.commands dev routes --csv-format + +.. csv-table:: + :file: _static/routes.csv + :header-rows: 1 + :class: datatable diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index 5a3bcb30..8ffd11ae 100644 --- a/locale/es/LC_MESSAGES/messages.po +++ b/locale/es/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-08-21 00:55-0400\n" +"POT-Creation-Date: 2024-08-21 16:46-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -19,6 +19,231 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.16.0\n" +#. BorrowerDocumentType +#: README.rst:125 +msgid "INCORPORATION_DOCUMENT" +msgstr "Documento de incorporación" + +#. BorrowerDocumentType +#: README.rst:126 +msgid "SUPPLIER_REGISTRATION_DOCUMENT" +msgstr "Documento de registro de proveedor" + +#. BorrowerDocumentType +#: README.rst:127 +msgid "BANK_NAME" +msgstr "Nombre del banco" + +#. BorrowerDocumentType +#: README.rst:128 +msgid "BANK_CERTIFICATION_DOCUMENT" +msgstr "Certificado bancario" + +#. BorrowerDocumentType +#: README.rst:129 +msgid "FINANCIAL_STATEMENT" +msgstr "Estados financieros" + +#. BorrowerDocumentType +#: README.rst:130 +msgid "SIGNED_CONTRACT" +msgstr "Contrato firmado" + +#. BorrowerDocumentType +#: README.rst:131 +msgid "SHAREHOLDER_COMPOSITION" +msgstr "Composición accionaria" + +#. BorrowerDocumentType +#: README.rst:132 +msgid "CHAMBER_OF_COMMERCE" +msgstr "Cámara de comercio" + +#. BorrowerDocumentType +#: README.rst:133 +msgid "THREE_LAST_BANK_STATEMENT" +msgstr "Últimos tres extractos bancarios" + +#. ApplicationStatus +#: README.rst:158 +msgid "PENDING" +msgstr "Pendiente" + +#. ApplicationStatus +#: README.rst:162 +msgid "DECLINED" +msgstr "Declinado" + +#. ApplicationStatus +#: README.rst:166 +msgid "ACCEPTED" +msgstr "Aceptado" + +#. ApplicationStatus +#: README.rst:170 +msgid "SUBMITTED" +msgstr "Enviado" + +#. ApplicationStatus +#: README.rst:174 +msgid "STARTED" +msgstr "Empezado" + +#. ApplicationStatus +#: README.rst:179 +msgid "REJECTED" +msgstr "Rechazado" + +#. ApplicationStatus +#: README.rst:183 +msgid "INFORMATION_REQUESTED" +msgstr "Información solicitada" + +#. ApplicationStatus +#: README.rst:187 +msgid "LAPSED" +msgstr "Caducado" + +#. ApplicationStatus +#: README.rst:191 +msgid "APPROVED" +msgstr "Precalificado" + +#. ApplicationStatus +#: README.rst:195 +msgid "CONTRACT_UPLOADED" +msgstr "Contrato cargado" + +#. ApplicationStatus +#: README.rst:199 +msgid "COMPLETED" +msgstr "Completado" + +#. BorrowerSize +#: README.rst:281 +msgid "NOT_INFORMED" +msgstr "No informado" + +#. BorrowerSize +#: README.rst:282 +msgid "MICRO" +msgstr "0 a 10" + +#. BorrowerSize +#: README.rst:283 +msgid "SMALL" +msgstr "11 a 50" + +#. BorrowerSize +#: README.rst:284 +msgid "MEDIUM" +msgstr "51 a 200" + +#. BorrowerSize +#: README.rst:285 +msgid "BIG" +msgstr "+ 200" + +#. BorrowerSector +#: README.rst:289 +msgid "agricultura" +msgstr "Agricultura, ganadería, caza, silvicultura y pesca" + +#. BorrowerSector +#: README.rst:290 +msgid "minas" +msgstr "Explotación de minas y canteras" + +#. BorrowerSector +#: README.rst:291 +msgid "manufactura" +msgstr "Industrias manufactureras" + +#. BorrowerSector +#: README.rst:292 +msgid "electricidad" +msgstr "Suministro de electricidad, gas, vapor y aire acondicionado" + +#. BorrowerSector +#: README.rst:293 +msgid "agua" +msgstr "Distribución de agua; evacuación y tratamiento de aguas residuales, gestión de desechos y actividades de saneamiento ambiental" + +#. BorrowerSector +#: README.rst:294 +msgid "construccion" +msgstr "Construcción" + +#. BorrowerSector +#: README.rst:295 +msgid "transporte" +msgstr "Transporte y almacenamiento" + +#. BorrowerSector +#: README.rst:296 +msgid "alojamiento" +msgstr "Alojamiento y servicios de comida" + +#. BorrowerSector +#: README.rst:297 +msgid "comunicaciones" +msgstr "Información y comunicaciones" + +#. BorrowerSector +#: README.rst:298 +msgid "actividades_financieras" +msgstr "Actividades financieras y de seguros" + +#. BorrowerSector +#: README.rst:299 +msgid "actividades_inmobiliarias" +msgstr "Actividades inmobiliarias" + +#. BorrowerSector +#: README.rst:300 +msgid "actividades_profesionales" +msgstr "Actividades profesionales, científicas y técnicas" + +#. BorrowerSector +#: README.rst:301 +msgid "actividades_servicios_administrativos" +msgstr "Actividades de servicios administrativos y de apoyo" + +#. BorrowerSector +#: README.rst:302 +msgid "administracion_publica" +msgstr "Administración pública y defensa; planes de seguridad social de afiliación obligatoria" + +#. BorrowerSector +#: README.rst:303 +msgid "educacion" +msgstr "Educación" + +#. BorrowerSector +#: README.rst:304 +msgid "atencion_salud" +msgstr "Actividades de atención de la salud humana y de asistencia social" + +#. BorrowerSector +#: README.rst:305 +msgid "actividades_artisticas" +msgstr "Actividades artísticas, de entretenimiento y recreación" + +#. BorrowerSector +#: README.rst:306 +msgid "otras_actividades" +msgstr "Otras actividades de servicios" + +#. BorrowerSector +#: README.rst:307 +msgid "actividades_hogares" +msgstr "Actividades de los hogares individuales en calidad de empleadores; actividades no diferenciadas de los hogares individuales como productores de bienes yservicios para uso propio" + +#. BorrowerSector +#: README.rst:308 +msgid "actividades_organizaciones_extraterritoriales" +msgstr "Actividades de organizaciones y entidades extraterritoriales" + #: app/mail.py:99 msgid "Your credit application has been prequalified" msgstr "Revisión de tu aplicación completada exitosamente" @@ -101,11 +326,11 @@ msgstr "Detalles de la aplicación" msgid "Previous Public Sector Contracts" msgstr "Anteriores contratos con el sector público" -#: app/routers/downloads.py:148 app/utils/tables.py:252 +#: app/routers/downloads.py:148 app/utils/tables.py:209 msgid "National Tax ID" msgstr "Identificación fiscal nacional" -#: app/routers/downloads.py:149 app/utils/tables.py:244 +#: app/routers/downloads.py:149 app/utils/tables.py:201 msgid "Legal Name" msgstr "Nombre legal" @@ -125,8 +350,8 @@ msgstr "Etapa" msgid "Financing Options" msgstr "Opciones crediticias" -#: app/utils/tables.py:42 app/utils/tables.py:127 app/utils/tables.py:241 -#: app/utils/tables.py:304 +#: app/utils/tables.py:42 app/utils/tables.py:128 app/utils/tables.py:198 +#: app/utils/tables.py:248 msgid "Data" msgstr "Datos" @@ -171,237 +396,86 @@ msgstr "Monto del contrato" msgid "Credit amount" msgstr "Monto del crédito" -#: app/utils/tables.py:126 +#: app/utils/tables.py:127 msgid "Award Data" msgstr "Datos de la adjudicación" -#: app/utils/tables.py:130 +#: app/utils/tables.py:131 msgid "View data in SECOP II" msgstr "Ver datos en SECOP II" -#: app/utils/tables.py:134 +#: app/utils/tables.py:135 msgid "Award Title" msgstr "Título de la adjudicación" -#: app/utils/tables.py:138 +#: app/utils/tables.py:139 msgid "Contracting Process ID" msgstr "Identificador del proceso de contratación" -#: app/utils/tables.py:142 +#: app/utils/tables.py:143 msgid "Award Description" msgstr "Descripción de la adjudicación" -#: app/utils/tables.py:146 +#: app/utils/tables.py:147 msgid "Award Date" msgstr "Fecha de la adjudicación" -#: app/utils/tables.py:150 +#: app/utils/tables.py:151 msgid "Award Value Currency & Amount" msgstr "Monto y moneda de la adjudicación" -#: app/utils/tables.py:154 +#: app/utils/tables.py:155 msgid "Contract Start Date" msgstr "Fecha de inicio del contrato" -#: app/utils/tables.py:158 +#: app/utils/tables.py:159 msgid "Contract End Date" msgstr "Fecha de fin del contrato" -#: app/utils/tables.py:162 +#: app/utils/tables.py:163 msgid "Payment Method" msgstr "Método de pago" -#: app/utils/tables.py:166 +#: app/utils/tables.py:167 msgid "Buyer Name" msgstr "Nombre de la entidad compradora" -#: app/utils/tables.py:173 +#: app/utils/tables.py:174 msgid "Procurement Method" msgstr "Método de contratación" -#: app/utils/tables.py:177 +#: app/utils/tables.py:178 msgid "Contract Type" msgstr "Tipo de contrato" -#: app/utils/tables.py:196 -msgid "Not informed" -msgstr "No informado" - #: app/utils/tables.py:197 -msgid "0 to 10" -msgstr "0 a 10" - -#: app/utils/tables.py:198 -msgid "11 to 50" -msgstr "11 a 50" - -#: app/utils/tables.py:199 -msgid "51 to 200" -msgstr "51 a 200" - -#: app/utils/tables.py:200 -msgid "+ 200" -msgstr "+ 200" - -#: app/utils/tables.py:205 -msgid "Agricultura, ganadería, caza, silvicultura y pesca" -msgstr "Agricultura, ganadería, caza, silvicultura y pesca" - -#: app/utils/tables.py:206 -msgid "Explotación de minas y canteras" -msgstr "Explotación de minas y canteras" - -#: app/utils/tables.py:207 -msgid "Industrias manufactureras" -msgstr "Industrias manufactureras" - -#: app/utils/tables.py:208 -msgid "Suministro de electricidad, gas, vapor y aire acondicionado" -msgstr "Suministro de electricidad, gas, vapor y aire acondicionado" - -#: app/utils/tables.py:209 -msgid "" -"Distribución de agua; evacuación y tratamiento de aguas residuales, " -"gestión de desechos y actividades de saneamiento ambiental" -msgstr "" -"Distribución de agua; evacuación y tratamiento de aguas residuales, " -"gestión de desechos y actividades de saneamiento ambiental" - -#: app/utils/tables.py:213 -msgid "Construcción" -msgstr "Construcción" - -#: app/utils/tables.py:214 -msgid "Transporte y almacenamiento" -msgstr "Transporte y almacenamiento" - -#: app/utils/tables.py:215 -msgid "Alojamiento y servicios de comida" -msgstr "Alojamiento y servicios de comida" - -#: app/utils/tables.py:216 -msgid "Información y comunicaciones" -msgstr "Información y comunicaciones" - -#: app/utils/tables.py:217 -msgid "Actividades financieras y de seguros" -msgstr "Actividades financieras y de seguros" - -#: app/utils/tables.py:218 -msgid "Actividades inmobiliarias" -msgstr "Actividades inmobiliarias" - -#: app/utils/tables.py:219 -msgid "Actividades profesionales, científicas y técnicas" -msgstr "Actividades profesionales, científicas y técnicas" - -#: app/utils/tables.py:220 -msgid "Actividades de servicios administrativos y de apoyo" -msgstr "Actividades de servicios administrativos y de apoyo" - -#: app/utils/tables.py:221 -msgid "" -"Administración pública y defensa; planes de seguridad social de " -"afiliación obligatoria" -msgstr "" -"Administración pública y defensa; planes de seguridad social de " -"afiliación obligatoria" - -#: app/utils/tables.py:224 -msgid "Educación" -msgstr "Educación" - -#: app/utils/tables.py:225 -msgid "Actividades de atención de la salud humana y de asistencia social" -msgstr "Actividades de atención de la salud humana y de asistencia social" - -#: app/utils/tables.py:226 -msgid "Actividades artísticas, de entretenimiento y recreación" -msgstr "Actividades artísticas, de entretenimiento y recreación" - -#: app/utils/tables.py:227 -msgid "Otras actividades de servicios" -msgstr "Otras actividades de servicios" - -#: app/utils/tables.py:228 -msgid "" -"Actividades de los hogares individuales en calidad de empleadores; " -"actividades no diferenciadas de los hogares individuales como productores" -" de bienes yservicios para uso propio" -msgstr "" -"Actividades de los hogares individuales en calidad de empleadores; " -"actividades no diferenciadas de los hogares individuales como productores" -" de bienes y servicios para uso propio" - -#: app/utils/tables.py:232 -msgid "Actividades de organizaciones y entidades extraterritoriales" -msgstr "Actividades de organizaciones y entidades extraterritoriales" - -#: app/utils/tables.py:240 msgid "MSME Data" msgstr "Datos de la MIPYME" -#: app/utils/tables.py:248 +#: app/utils/tables.py:205 msgid "Address" msgstr "Dirección" -#: app/utils/tables.py:256 +#: app/utils/tables.py:213 msgid "Registration Type" msgstr "Tipo de registro" -#: app/utils/tables.py:260 +#: app/utils/tables.py:217 msgid "Size" msgstr "Tamaño" -#: app/utils/tables.py:264 +#: app/utils/tables.py:221 msgid "Sector" msgstr "Sector" -#: app/utils/tables.py:268 +#: app/utils/tables.py:225 msgid "Annual Revenue" msgstr "Valor estimado de facturación anual de su empresa" -#: app/utils/tables.py:272 +#: app/utils/tables.py:229 msgid "Business Email" msgstr "Correo institucional" -#: app/utils/tables.py:290 -msgid "Incorporation document" -msgstr "Documento de incorporación" - -#: app/utils/tables.py:291 -msgid "Supplier registration document" -msgstr "Documento de registro de proveedor" - -#: app/utils/tables.py:292 -msgid "Bank name" -msgstr "Nombre del banco" - -#: app/utils/tables.py:293 -msgid "Bank certification document" -msgstr "Certificado bancario" - -#: app/utils/tables.py:294 -msgid "Financial statement" -msgstr "Estados financieros" - -#: app/utils/tables.py:295 -msgid "Signed contract" -msgstr "Contrato firmado" - -#: app/utils/tables.py:296 -msgid "Shareholder composition" -msgstr "Composición accionaria" - -#: app/utils/tables.py:297 -msgid "Chamber of Commerce" -msgstr "Cámara de comercio" - -#: app/utils/tables.py:298 -msgid "Three last bank statement" -msgstr "Últimos tres extractos bancarios" - -#: app/utils/tables.py:303 +#: app/utils/tables.py:247 msgid "MSME Documents" msgstr "Documentos de la MIPYME" - diff --git a/pyproject.toml b/pyproject.toml index b947c915..62766891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,3 +22,10 @@ disallow_untyped_defs = true init_forbid_extra = true init_typed = true warn_required_dynamic_aliases = true + +[project] +name = "credere" +version = "0.0.0" + +[project.entry-points."babel.extractors"] +enum = "app.i18n:extract_enum" From 8bc5b71db2740a3889497abb4d1596f71f1fe4aa Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:00:25 -0400 Subject: [PATCH 02/16] chore: Move Babel extractor to new file, to not upset CI --- app/babel.py | 32 ++++++++++++++++++++++++++++++++ app/i18n.py | 33 ++------------------------------- docs/contributing/index.rst | 1 + pyproject.toml | 2 +- 4 files changed, 36 insertions(+), 32 deletions(-) create mode 100644 app/babel.py diff --git a/app/babel.py b/app/babel.py new file mode 100644 index 00000000..0a9e9135 --- /dev/null +++ b/app/babel.py @@ -0,0 +1,32 @@ +import ast +from collections.abc import Collection +from pathlib import Path +from typing import IO, Any, Generator + +basedir = Path(__file__).absolute().parent.parent + + +class Visitor(ast.NodeVisitor): + def __init__(self, classes: list[str]): + self.classes = classes + self.messages: list[tuple[int, str, str]] = [] + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + if node.name in self.classes: + for child in node.body: + # Skip docstrings. + if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant): + continue + assert isinstance(child, ast.Assign) and isinstance(child.value, ast.Constant), ast.unparse(child) + self.messages.append((child.value.lineno, child.value.value, node.name)) + + +# https://babel.pocoo.org/en/latest/api/messages/extract.html#babel.messages.extract.extract_python uses tokenize, +# but it is easier with ast. (Compare to `python -m tokenize -e app/models.py`.) +def extract_enum( + fileobj: IO[bytes], keywords: dict[str, Any], comment_tags: Collection[str], options: dict[str, Any] +) -> Generator[tuple[int, str, str, list[str]], None, None]: + visitor = Visitor(options.get("classes", "").split(",")) + visitor.visit(ast.parse((basedir / "app" / "models.py").read_text())) + for lineno, text, comment in visitor.messages: + yield lineno, "", text, [comment] diff --git a/app/i18n.py b/app/i18n.py index 395ec203..cb826306 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -1,13 +1,10 @@ -import ast import gettext -from collections.abc import Collection from pathlib import Path -from typing import IO, Any, Generator +from typing import Any from app.settings import app_settings -basedir = Path(__file__).absolute().parent.parent -localedir = basedir / "locale" +localedir = Path(__file__).absolute().parent.parent / "locale" translators = { path.name: gettext.translation("messages", localedir, languages=[path.name]) @@ -16,31 +13,5 @@ } -class Visitor(ast.NodeVisitor): - def __init__(self, classes: list[str]): - self.classes = classes - self.messages: list[tuple[int, str, str]] = [] - - def visit_ClassDef(self, node: ast.ClassDef) -> None: - if node.name in self.classes: - for child in node.body: - # Skip docstrings. - if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant): - continue - assert isinstance(child, ast.Assign) and isinstance(child.value, ast.Constant), ast.unparse(child) - self.messages.append((child.value.lineno, child.value.value, node.name)) - - def _(message: str, language: str = app_settings.email_template_lang, **kwargs: Any) -> str: return translators[language].gettext(message) % kwargs - - -# https://babel.pocoo.org/en/latest/api/messages/extract.html#babel.messages.extract.extract_python uses tokenize, -# but it is easier with ast. (Compare to `python -m tokenize -e app/models.py`.) -def extract_enum( - fileobj: IO[bytes], keywords: dict[str, Any], comment_tags: Collection[str], options: dict[str, Any] -) -> Generator[tuple[int, str, str, list[str]], None, None]: - visitor = Visitor(options.get("classes", "").split(",")) - visitor.visit(ast.parse((basedir / "app" / "models.py").read_text())) - for lineno, text, comment in visitor.messages: - yield lineno, "", text, [comment] diff --git a/docs/contributing/index.rst b/docs/contributing/index.rst index 7ab1fc24..40178702 100644 --- a/docs/contributing/index.rst +++ b/docs/contributing/index.rst @@ -63,6 +63,7 @@ Repository structure ├── __init__.py ├── auth.py # Permissions and JWT token verification ├── aws.py # Amazon Web Services API clients + ├── babel.py # Babel extractors ├── commands.py # Typer commands ├── db.py # SQLAlchemy database operations and session management ├── dependencies.py # FastAPI dependencies diff --git a/pyproject.toml b/pyproject.toml index 62766891..387e7e9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,4 +28,4 @@ name = "credere" version = "0.0.0" [project.entry-points."babel.extractors"] -enum = "app.i18n:extract_enum" +enum = "app.babel:extract_enum" From 0615c52b0202615ffa4f42564371fe99832dc7ae Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:22:44 -0400 Subject: [PATCH 03/16] fix: Return translated error messages instead of returning ERROR_CODES, #362 --- app/dependencies.py | 4 ++-- app/routers/applications.py | 5 +++-- app/routers/guest/applications.py | 3 ++- app/util.py | 7 ------- docs/frontend.rst | 14 -------------- locale/es/LC_MESSAGES/messages.po | 28 ++++++++++++++++++++++++---- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index 7434d0da..f10f6823 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -5,7 +5,7 @@ from fastapi import Depends, Form, HTTPException, Request, status from sqlalchemy.orm import Session, defaultload, joinedload -from app import auth, aws, models, parsers, util +from app import auth, aws, models, parsers from app.db import get_db USER_CAN_EDIT_AWARD_BORROWER_DATA = ( @@ -150,7 +150,7 @@ def _get_application_as_guest_via_uuid(session: Session, uuid: str) -> models.Ap if application.status == models.ApplicationStatus.LAPSED: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=util.ERROR_CODES.APPLICATION_LAPSED, + detail="Application lapsed", ) return application diff --git a/app/routers/applications.py b/app/routers/applications.py index 2067ac1a..61ec60c3 100644 --- a/app/routers/applications.py +++ b/app/routers/applications.py @@ -10,6 +10,7 @@ from app import aws, dependencies, mail, models, parsers, serializers, util from app.db import get_db, rollback_on_error +from app.i18n import _ from app.util import SortOrder, get_order_by logger = logging.getLogger(__name__) @@ -171,7 +172,7 @@ async def approve_application( if not_validated_fields: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=util.ERROR_CODES.BORROWER_FIELD_VERIFICATION_MISSING, + detail=_("Some borrower data field are not verified"), ) # Check all documents are verified. @@ -182,7 +183,7 @@ async def approve_application( if not_validated_documents: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=util.ERROR_CODES.DOCUMENT_VERIFICATION_MISSING, + detail=_("Some documents are not verified"), ) # Approve the application. diff --git a/app/routers/guest/applications.py b/app/routers/guest/applications.py index 9d9633b2..5428c162 100644 --- a/app/routers/guest/applications.py +++ b/app/routers/guest/applications.py @@ -10,6 +10,7 @@ from app import aws, dependencies, mail, models, parsers, serializers, util from app.db import get_db, rollback_on_error +from app.i18n import _ logger = logging.getLogger(__name__) @@ -800,7 +801,7 @@ async def find_alternative_credit_option( if app_action: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=util.ERROR_CODES.APPLICATION_ALREADY_COPIED, + detail=_("A new application has alredy been created from this one"), ) # Copy the application, changing the uuid, status, and borrower_accepted_at. diff --git a/app/util.py b/app/util.py index f0f05b5d..b9d66c99 100644 --- a/app/util.py +++ b/app/util.py @@ -26,13 +26,6 @@ ALLOWED_EXTENSIONS = {".png", ".pdf", ".jpeg", ".jpg"} -class ERROR_CODES(StrEnum): - BORROWER_FIELD_VERIFICATION_MISSING = "BORROWER_FIELD_VERIFICATION_MISSING" - DOCUMENT_VERIFICATION_MISSING = "DOCUMENT_VERIFICATION_MISSING" - APPLICATION_LAPSED = "APPLICATION_LAPSED" - APPLICATION_ALREADY_COPIED = "APPLICATION_ALREADY_COPIED" - - class SortOrder(StrEnum): ASC = "asc" DESC = "desc" diff --git a/docs/frontend.rst b/docs/frontend.rst index c7bb0a49..51957b64 100644 --- a/docs/frontend.rst +++ b/docs/frontend.rst @@ -7,20 +7,6 @@ Frontend integration .. seealso:: `Credere frontend `__ -Error handling --------------- - -.. attention:: This behavior might be changed. :issue:`362` - -An endpoint can return HTTP error status codes. The error response is JSON text with a "detail" key. The frontend uses `TanStack Query v4 `__ and the ``onError`` callback of these APIs to handle these errors: - -- `useQuery `__ (``onError`` is `deprecated `__) -- `useMutation `__ - -In some cases, this callback calls ``handleRequestError``, which looks up the ``detail`` value in ``ERRORS_MESSAGES``, and, if there's a match, it uses that message. - -That is why some ``detail`` values are like ``util.ERROR_CODES.DOCUMENT_VERIFICATION_MISSING``. - Enumerations ------------ diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index 8ffd11ae..51652fef 100644 --- a/locale/es/LC_MESSAGES/messages.po +++ b/locale/es/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-08-21 16:46-0400\n" +"POT-Creation-Date: 2024-08-21 17:16-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -167,7 +167,9 @@ msgstr "Suministro de electricidad, gas, vapor y aire acondicionado" #. BorrowerSector #: README.rst:293 msgid "agua" -msgstr "Distribución de agua; evacuación y tratamiento de aguas residuales, gestión de desechos y actividades de saneamiento ambiental" +msgstr "" +"Distribución de agua; evacuación y tratamiento de aguas residuales, " +"gestión de desechos y actividades de saneamiento ambiental" #. BorrowerSector #: README.rst:294 @@ -212,7 +214,9 @@ msgstr "Actividades de servicios administrativos y de apoyo" #. BorrowerSector #: README.rst:302 msgid "administracion_publica" -msgstr "Administración pública y defensa; planes de seguridad social de afiliación obligatoria" +msgstr "" +"Administración pública y defensa; planes de seguridad social de " +"afiliación obligatoria" #. BorrowerSector #: README.rst:303 @@ -237,7 +241,10 @@ msgstr "Otras actividades de servicios" #. BorrowerSector #: README.rst:307 msgid "actividades_hogares" -msgstr "Actividades de los hogares individuales en calidad de empleadores; actividades no diferenciadas de los hogares individuales como productores de bienes yservicios para uso propio" +msgstr "" +"Actividades de los hogares individuales en calidad de empleadores; " +"actividades no diferenciadas de los hogares individuales como productores" +" de bienes yservicios para uso propio" #. BorrowerSector #: README.rst:308 @@ -318,6 +325,14 @@ msgstr "Opción de crédito alternativa" msgid "Application updated" msgstr "Aplicación actualizada" +#: app/routers/applications.py:174 +msgid "Some borrower data field are not verified" +msgstr "Algunos campos de datos de la MIPYME no están verificados" + +#: app/routers/applications.py:185 +msgid "Some documents are not verified" +msgstr "Algunos documentos no fueron verificados" + #: app/routers/downloads.py:88 app/routers/downloads.py:106 msgid "Application Details" msgstr "Detalles de la aplicación" @@ -346,6 +361,10 @@ msgstr "Fecha de envío" msgid "Stage" msgstr "Etapa" +#: app/routers/guest/applications.py:803 +msgid "A new application has alredy been created from this one" +msgstr "Una nueva aplicación ya ha sido creada desde esta" + #: app/utils/tables.py:41 msgid "Financing Options" msgstr "Opciones crediticias" @@ -479,3 +498,4 @@ msgstr "Correo institucional" #: app/utils/tables.py:247 msgid "MSME Documents" msgstr "Documentos de la MIPYME" + From e5c7c28939b3ca55475182605f9fe8d6da0a9879 Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:44:53 -0400 Subject: [PATCH 04/16] feat: Make all error messages translatable, #362 --- app/auth.py | 20 +++- app/dependencies.py | 37 ++++-- app/routers/applications.py | 14 ++- app/routers/guest/applications.py | 22 ++-- app/routers/guest/emails.py | 7 +- app/routers/lenders.py | 10 +- app/routers/users.py | 26 ++--- app/util.py | 9 +- locale/es/LC_MESSAGES/messages.po | 177 ++++++++++++++++++++++++++++- tests/routers/test_applications.py | 6 +- 10 files changed, 270 insertions(+), 58 deletions(-) diff --git a/app/auth.py b/app/auth.py index c14872a8..6ed411fc 100644 --- a/app/auth.py +++ b/app/auth.py @@ -7,6 +7,7 @@ from jwt.utils import base64url_decode from pydantic import BaseModel +from app.i18n import _ from app.settings import app_settings JWK = dict[str, str] @@ -53,7 +54,10 @@ def verify_jwk_token(self, jwt_credentials: JWTAuthorizationCredentials) -> bool try: public_key = self.kid_to_jwk[jwt_credentials.header["kid"]] except KeyError: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="JWK public key not found") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=_("JWK public key not found"), + ) msg = jwt_credentials.message.encode() sig = base64url_decode(jwt_credentials.signature.encode()) @@ -77,7 +81,7 @@ async def __call__(self, request: Request) -> JWTAuthorizationCredentials | None if not credentials.scheme == "Bearer": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Wrong authentication method", + detail=_("Wrong authentication method"), ) jwt_token = credentials.credentials @@ -93,16 +97,22 @@ async def __call__(self, request: Request) -> JWTAuthorizationCredentials | None message=message, ) except jwt.InvalidTokenError: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="JWK invalid") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=_("JWK invalid"), + ) if not self.verify_jwk_token(jwt_credentials): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="JWK invalid") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=_("JWK invalid"), + ) return jwt_credentials else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Not authenticated", + detail=_("Not authenticated"), ) diff --git a/app/dependencies.py b/app/dependencies.py index f10f6823..4fd71b00 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,6 +7,7 @@ from app import auth, aws, models, parsers from app.db import get_db +from app.i18n import _ USER_CAN_EDIT_AWARD_BORROWER_DATA = ( models.ApplicationStatus.SUBMITTED, @@ -38,7 +39,10 @@ async def get_current_user(credentials: auth.JWTAuthorizationCredentials = Depen try: return credentials.claims["username"] except KeyError: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Username missing") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=_("Username missing"), + ) async def get_user(username: str = Depends(get_current_user), session: Session = Depends(get_db)) -> models.User: @@ -52,7 +56,10 @@ async def get_user(username: str = Depends(get_current_user), session: Session = """ user = models.User.first_by(session, "external_id", username) if not user: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not found") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=_("User not found"), + ) return user @@ -60,7 +67,7 @@ async def get_admin_user(user: models.User = Depends(get_user)) -> models.User: if not user.is_ocp(): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Insufficient permissions", + detail=_("Insufficient permissions"), ) return user @@ -86,18 +93,24 @@ def raise_if_unauthorized( case _: raise NotImplementedError else: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is not authorized") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=_("User is not authorized"), + ) if ApplicationScope.UNEXPIRED in scopes: expired_at = application.expired_at if expired_at and expired_at < datetime.now(expired_at.tzinfo): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Application expired") + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=_("Application expired"), + ) if statuses: if application.status not in statuses: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"Application status should not be {application.status}", + detail=_("Application status should not be %(status)s", status=application.status), ) @@ -108,7 +121,10 @@ def get_application_as_user(id: int, session: Session = Depends(get_db)) -> mode .first() ) if not application: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_("Application not found"), + ) return application @@ -145,12 +161,15 @@ def _get_application_as_guest_via_uuid(session: Session, uuid: str) -> models.Ap .first() ) if not application: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Application not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_("Application not found"), + ) if application.status == models.ApplicationStatus.LAPSED: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Application lapsed", + detail=_("Application lapsed"), ) return application diff --git a/app/routers/applications.py b/app/routers/applications.py index 61ec60c3..10418bb7 100644 --- a/app/routers/applications.py +++ b/app/routers/applications.py @@ -327,7 +327,10 @@ async def update_application_award( """ with rollback_on_error(session): if not application.award: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Award not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_("Award not found"), + ) # Update the award. application.award.update(session, **jsonable_encoder(payload, exclude_unset=True)) @@ -369,7 +372,10 @@ async def update_application_borrower( """ with rollback_on_error(session): if not application.borrower: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Borrower not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_("Borrower not found"), + ) # Update the borrower. update_dict = jsonable_encoder(payload, exclude_unset=True) @@ -377,7 +383,7 @@ async def update_application_borrower( if not application.borrower.missing_data[field]: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="This column cannot be updated", + detail=_("This column cannot be updated"), ) application.borrower.update(session, **update_dict) @@ -576,7 +582,7 @@ async def email_borrower( logger.exception(e) return HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="There was an error", + detail=_("There was an error"), ) models.Message.create( session, diff --git a/app/routers/guest/applications.py b/app/routers/guest/applications.py index 5428c162..b51fb038 100644 --- a/app/routers/guest/applications.py +++ b/app/routers/guest/applications.py @@ -318,13 +318,13 @@ async def rollback_select_credit_product( if not application.credit_product_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Credit product not selected", + detail=_("Credit product not selected"), ) if application.lender_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot rollback at this stage", + detail=_("Cannot rollback at this stage"), ) application.credit_product_id = None @@ -368,14 +368,14 @@ async def confirm_credit_product( if not application.credit_product_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Credit product not selected", + detail=_("Credit product not selected"), ) credit_product = models.CreditProduct.first_by(session, "id", application.credit_product_id) if not credit_product: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Credit product not found", + detail=_("Credit product not found"), ) application.lender_id = credit_product.lender_id @@ -457,14 +457,14 @@ async def rollback_confirm_credit_product( if not application.credit_product_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Credit product not selected", + detail=_("Credit product not selected"), ) credit_product = models.CreditProduct.first_by(session, "id", application.credit_product_id) if not credit_product: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Credit product not found", + detail=_("Credit product not found"), ) application.lender_id = None @@ -523,13 +523,13 @@ async def update_apps_send_notifications( if not application.credit_product_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Credit product not selected", + detail=_("Credit product not selected"), ) if not application.lender: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Lender not selected", + detail=_("Lender not selected"), ) application.status = models.ApplicationStatus.SUBMITTED @@ -545,7 +545,7 @@ async def update_apps_send_notifications( logger.exception(e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="There was an error submitting the application", + detail=_("There was an error submitting the application"), ) models.Message.create( session, @@ -594,7 +594,7 @@ async def upload_document( if not application.pending_documents: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot upload document at this stage", + detail=_("Cannot upload document at this stage"), ) document = util.create_or_update_borrower_document( @@ -820,7 +820,7 @@ async def find_alternative_credit_option( except Exception as e: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"There was a problem copying the application.{e}", + detail=_("There was a problem copying the application. %(exception)s", exception=e), ) models.ApplicationAction.create( diff --git a/app/routers/guest/emails.py b/app/routers/guest/emails.py index 35e50c8a..5300124e 100644 --- a/app/routers/guest/emails.py +++ b/app/routers/guest/emails.py @@ -4,6 +4,7 @@ from app import aws, dependencies, mail, models, parsers, util from app.db import get_db, rollback_on_error +from app.i18n import _ router = APIRouter() @@ -30,7 +31,7 @@ async def change_email( if not util.is_valid_email(new_email): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="New email is not valid", + detail=_("New email is not valid"), ) confirmation_email_token = util.generate_uuid(new_email) @@ -77,14 +78,14 @@ async def confirm_email( if not application.pending_email_confirmation: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Application is not pending an email confirmation", + detail=_("Application is not pending an email confirmation"), ) new_email, token = application.confirmation_email_token.split("---")[:2] if token != payload.confirmation_email_token: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="Not authorized to modify this application", + detail=_("Not authorized to modify this application"), ) application.primary_email = new_email diff --git a/app/routers/lenders.py b/app/routers/lenders.py index 436000b7..c5292d8c 100644 --- a/app/routers/lenders.py +++ b/app/routers/lenders.py @@ -7,6 +7,7 @@ from app import dependencies, models, serializers from app.db import get_db, rollback_on_error +from app.i18n import _ from app.sources import colombia as data_access from app.util import get_object_or_404 @@ -45,7 +46,7 @@ async def create_lender( except IntegrityError: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Lender already exists", + detail=_("Lender already exists"), ) @@ -122,7 +123,7 @@ async def update_lender( except IntegrityError: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Lender already exists", + detail=_("Lender already exists"), ) @@ -186,7 +187,10 @@ async def get_credit_product( .first() ) if not credit_product: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credit product not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=_("Credit product not found"), + ) return credit_product diff --git a/app/routers/users.py b/app/routers/users.py index b6d2b70c..f5c1c3ed 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -8,6 +8,7 @@ from app import aws, dependencies, mail, models, parsers, serializers from app.db import get_db, rollback_on_error +from app.i18n import _ from app.settings import app_settings from app.util import SortOrder, get_object_or_404, get_order_by @@ -57,7 +58,7 @@ async def create_user( except (client.cognito.exceptions.UsernameExistsException, IntegrityError): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Username already exists", + detail=_("Username already exists"), ) @@ -100,7 +101,7 @@ def change_password( associate_response = client.cognito.associate_software_token(Session=response["Session"]) return serializers.ChangePasswordResponse( - detail="Password changed with MFA setup required", + detail=_("Password changed with MFA setup required"), secret_code=associate_response["SecretCode"], session=associate_response["Session"], username=payload.username, @@ -109,14 +110,14 @@ def change_password( if e.response["Error"]["Code"] == "ExpiredTemporaryPasswordException": raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Temporal password is expired, please request a new one", + detail=_("Temporal password is expired, please request a new one"), ) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="There was an error trying to update the password", + detail=_("There was an error trying to update the password"), ) - return serializers.ResponseBase(detail="Password changed") + return serializers.ResponseBase(detail=_("Password changed")) @router.put( @@ -141,14 +142,14 @@ def setup_mfa( if e.response["Error"]["Code"] == "NotAuthorizedException": raise HTTPException( status_code=status.HTTP_408_REQUEST_TIMEOUT, - detail="Invalid session for the user, session is expired", + detail=_("Invalid session for the user, session is expired"), ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="There was an error trying to setup mfa", + detail=_("There was an error trying to setup mfa"), ) - return serializers.ResponseBase(detail="MFA configured successfully") + return serializers.ResponseBase(detail=_("MFA configured successfully")) @router.post( @@ -189,7 +190,7 @@ def login( if e.response["Error"]["Code"] == "ExpiredTemporaryPasswordException": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Temporal password is expired, please request a new one", + detail=_("Temporal password is expired, please request a new one"), ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -233,7 +234,7 @@ def logout( except ClientError as e: logger.exception(e) - return serializers.ResponseBase(detail="User logged out successfully") + return serializers.ResponseBase(detail=_("User logged out successfully")) @router.get( @@ -266,7 +267,6 @@ def forgot_password( Email the user a temporary password and a reset link. """ - detail = "An email with a reset link was sent to end user" try: temporary_password = client.generate_password() @@ -283,7 +283,7 @@ def forgot_password( logger.exception("Error resetting password") # always return 200 to avoid user enumeration - return serializers.ResponseBase(detail=detail) + return serializers.ResponseBase(detail=_("An email with a reset link was sent to end user")) @router.get( @@ -364,5 +364,5 @@ async def update_user( except IntegrityError: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="User already exists", + detail=_("User already exists"), ) diff --git a/app/util.py b/app/util.py index b9d66c99..6e9d2b5a 100644 --- a/app/util.py +++ b/app/util.py @@ -18,6 +18,7 @@ from app import models from app.db import get_db, handle_skipped_award from app.exceptions import SkippedAwardError +from app.i18n import _ from app.settings import app_settings from app.sources import colombia as data_access @@ -49,7 +50,9 @@ def get_order_by(sort_field: str, sort_order: str, model: type[SQLModel] | None def get_object_or_404(session: Session, model: type[T], field: str, value: Any) -> T: obj = model.first_by(session, field, value) if not obj: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{model.__name__} not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=_("%(model_name)s not found", model_name=model.__name__) + ) return obj @@ -106,13 +109,13 @@ def validate_file(file: UploadFile = File(...)) -> tuple[bytes, str | None]: if os.path.splitext(filename)[1].lower() not in ALLOWED_EXTENSIONS: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Format not allowed. It must be a PNG, JPEG, or PDF file", + detail=_("Format not allowed. It must be a PNG, JPEG, or PDF file"), ) new_file = file.file.read() if len(new_file) >= MAX_FILE_SIZE: # 10MB in bytes raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="File is too large", + detail=_("File is too large"), ) return new_file, filename diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index 51652fef..20cad557 100644 --- a/locale/es/LC_MESSAGES/messages.po +++ b/locale/es/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-08-21 17:16-0400\n" +"POT-Creation-Date: 2024-08-21 17:40-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -251,6 +251,55 @@ msgstr "" msgid "actividades_organizaciones_extraterritoriales" msgstr "Actividades de organizaciones y entidades extraterritoriales" +#: app/auth.py:56 +msgid "JWK public key not found" +msgstr "" + +#: app/auth.py:80 +msgid "Wrong authentication method" +msgstr "" + +#: app/auth.py:96 app/auth.py:99 +msgid "JWK invalid" +msgstr "" + +#: app/auth.py:105 +msgid "Not authenticated" +msgstr "" + +#: app/dependencies.py:41 +msgid "Username missing" +msgstr "" + +#: app/dependencies.py:55 +msgid "User not found" +msgstr "" + +#: app/dependencies.py:63 +msgid "Insufficient permissions" +msgstr "" + +#: app/dependencies.py:89 +msgid "User is not authorized" +msgstr "" + +#: app/dependencies.py:94 +msgid "Application expired" +msgstr "" + +#: app/dependencies.py:100 +#, python-format +msgid "Application status should not be %(status)s" +msgstr "" + +#: app/dependencies.py:111 app/dependencies.py:148 +msgid "Application not found" +msgstr "" + +#: app/dependencies.py:153 +msgid "Application lapsed" +msgstr "" + #: app/mail.py:99 msgid "Your credit application has been prequalified" msgstr "Revisión de tu aplicación completada exitosamente" @@ -325,14 +374,43 @@ msgstr "Opción de crédito alternativa" msgid "Application updated" msgstr "Aplicación actualizada" -#: app/routers/applications.py:174 +#: app/util.py:53 +#, python-format +msgid "%(model_name)s not found" +msgstr "" + +#: app/util.py:111 +msgid "Format not allowed. It must be a PNG, JPEG, or PDF file" +msgstr "" + +#: app/util.py:117 +msgid "File is too large" +msgstr "" + +#: app/routers/applications.py:175 msgid "Some borrower data field are not verified" msgstr "Algunos campos de datos de la MIPYME no están verificados" -#: app/routers/applications.py:185 +#: app/routers/applications.py:186 msgid "Some documents are not verified" msgstr "Algunos documentos no fueron verificados" +#: app/routers/applications.py:330 +msgid "Award not found" +msgstr "" + +#: app/routers/applications.py:372 +msgid "Borrower not found" +msgstr "" + +#: app/routers/applications.py:380 +msgid "This column cannot be updated" +msgstr "" + +#: app/routers/applications.py:579 +msgid "There was an error" +msgstr "" + #: app/routers/downloads.py:88 app/routers/downloads.py:106 msgid "Application Details" msgstr "Detalles de la aplicación" @@ -361,10 +439,101 @@ msgstr "Fecha de envío" msgid "Stage" msgstr "Etapa" -#: app/routers/guest/applications.py:803 +#: app/routers/lenders.py:48 app/routers/lenders.py:125 +msgid "Lender already exists" +msgstr "" + +#: app/routers/guest/applications.py:378 app/routers/guest/applications.py:467 +#: app/routers/lenders.py:189 +msgid "Credit product not found" +msgstr "" + +#: app/routers/users.py:60 +msgid "Username already exists" +msgstr "" + +#: app/routers/users.py:103 +msgid "Password changed with MFA setup required" +msgstr "" + +#: app/routers/users.py:112 app/routers/users.py:192 +msgid "Temporal password is expired, please request a new one" +msgstr "" + +#: app/routers/users.py:116 +msgid "There was an error trying to update the password" +msgstr "" + +#: app/routers/users.py:119 +msgid "Password changed" +msgstr "" + +#: app/routers/users.py:144 +msgid "Invalid session for the user, session is expired" +msgstr "" + +#: app/routers/users.py:148 +msgid "There was an error trying to setup mfa" +msgstr "" + +#: app/routers/users.py:151 +msgid "MFA configured successfully" +msgstr "" + +#: app/routers/users.py:236 +msgid "User logged out successfully" +msgstr "" + +#: app/routers/users.py:285 +msgid "An email with a reset link was sent to end user" +msgstr "" + +#: app/routers/users.py:366 +msgid "User already exists" +msgstr "" + +#: app/routers/guest/applications.py:321 app/routers/guest/applications.py:371 +#: app/routers/guest/applications.py:460 app/routers/guest/applications.py:526 +msgid "Credit product not selected" +msgstr "" + +#: app/routers/guest/applications.py:327 +msgid "Cannot rollback at this stage" +msgstr "" + +#: app/routers/guest/applications.py:532 +msgid "Lender not selected" +msgstr "" + +#: app/routers/guest/applications.py:548 +msgid "There was an error submitting the application" +msgstr "" + +#: app/routers/guest/applications.py:597 +msgid "Cannot upload document at this stage" +msgstr "" + +#: app/routers/guest/applications.py:804 msgid "A new application has alredy been created from this one" msgstr "Una nueva aplicación ya ha sido creada desde esta" +#: app/routers/guest/applications.py:823 +#, python-format +msgid "There was a problem copying the application. %(exception)s" +msgstr "" + +#: app/routers/guest/emails.py:33 +msgid "New email is not valid" +msgstr "" + +#: app/routers/guest/emails.py:80 +msgid "Application is not pending an email confirmation" +msgstr "" + +#: app/routers/guest/emails.py:87 +msgid "Not authorized to modify this application" +msgstr "" + #: app/utils/tables.py:41 msgid "Financing Options" msgstr "Opciones crediticias" diff --git a/tests/routers/test_applications.py b/tests/routers/test_applications.py index bc06e2b3..0f923378 100644 --- a/tests/routers/test_applications.py +++ b/tests/routers/test_applications.py @@ -246,7 +246,7 @@ def test_approve_application_cycle( # lender tries to approve the application without verifying legal_name response = client.post(f"/applications/{appid}/approve-application", json=approve_payload, headers=lender_header) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "BORROWER_FIELD_VERIFICATION_MISSING"} + assert response.json() == {"detail": "Some borrower data field are not verified"} # verify legal_name response = client.put(f"/applications/{appid}/verify-data-field", json={"legal_name": True}, headers=lender_header) @@ -264,7 +264,7 @@ def test_approve_application_cycle( # lender tries to approve the application without verifying INCORPORATION_DOCUMENT response = client.post(f"/applications/{appid}/approve-application", json=approve_payload, headers=lender_header) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "DOCUMENT_VERIFICATION_MISSING"} + assert response.json() == {"detail": "Some documents are not verified"} # verify borrower document response = client.put( @@ -345,7 +345,7 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get(f"/applications/uuid/{pending_application.uuid}") assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "APPLICATION_LAPSED"} + assert response.json() == {"detail": "Application lapsed"} response = client.get("/applications/uuid/123-456") assert response.status_code == status.HTTP_404_NOT_FOUND From f9eeac245473f4427e51d3081d7f3811469737fe Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 18:38:17 -0400 Subject: [PATCH 05/16] fix: Translate application status inside error message. test: Change _() signature to allow language to be overridden in tests. Get tests passing in null and "es" languages. --- app/dependencies.py | 2 +- app/i18n.py | 5 ++- tests/conftest.py | 8 +++- tests/routers/guest/test_applications.py | 7 ++-- tests/routers/test_applications.py | 51 ++++++++++++------------ tests/routers/test_lenders.py | 17 ++++---- tests/routers/test_users.py | 13 +++--- tests/test_i18n.py | 16 ++++++++ 8 files changed, 73 insertions(+), 46 deletions(-) create mode 100644 tests/test_i18n.py diff --git a/app/dependencies.py b/app/dependencies.py index 4fd71b00..d8069639 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -110,7 +110,7 @@ def raise_if_unauthorized( if application.status not in statuses: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=_("Application status should not be %(status)s", status=application.status), + detail=_("Application status should not be %(status)s", status=_(application.status)), ) diff --git a/app/i18n.py b/app/i18n.py index cb826306..021642c0 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -13,5 +13,6 @@ } -def _(message: str, language: str = app_settings.email_template_lang, **kwargs: Any) -> str: - return translators[language].gettext(message) % kwargs +def _(message: str, language: str | None = None, **kwargs: Any) -> str: + translator = translators.get(language or app_settings.email_template_lang, gettext.NullTranslations()) + return translator.gettext(message) % kwargs diff --git a/tests/conftest.py b/tests/conftest.py index 707dfd7f..859636f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,12 @@ from tests import create_user, get_test_db +@pytest.fixture(scope="session", autouse=True, params=["es", "en"]) +def language(request): + app_settings.email_template_lang = request.param + yield + + @pytest.fixture(scope="session") def app() -> Generator[FastAPI, Any, None]: yield main.app @@ -107,7 +113,7 @@ def aws_client(mock_aws): ses_client = boto3.client("ses", config=config) ses_client.verify_email_identity(EmailAddress=app_settings.email_sender_address) - for key in ("-es", ""): + for key in ("-es", "-en"): ses_client.create_template( Template={ "TemplateName": f"credere-main{key}", diff --git a/tests/routers/guest/test_applications.py b/tests/routers/guest/test_applications.py index a37c2b9f..68a18b61 100644 --- a/tests/routers/guest/test_applications.py +++ b/tests/routers/guest/test_applications.py @@ -3,6 +3,7 @@ from fastapi import status from app import models +from app.i18n import _ from tests import assert_ok @@ -18,7 +19,7 @@ def test_application_declined(client, pending_application): response = client.post("/applications/access-scheme", json={"uuid": pending_application.uuid}) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application status should not be DECLINED"} + assert response.json() == {"detail": _("Application status should not be %(status)s", status=_("DECLINED"))} def test_application_rollback_declined(client, session, declined_application): @@ -34,7 +35,7 @@ def test_application_rollback_declined(client, session, declined_application): response = client.post("/applications/rollback-decline", json={"uuid": declined_application.uuid}) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application status should not be PENDING"} + assert response.json() == {"detail": _("Application status should not be %(status)s", status=_("PENDING"))} def test_application_declined_feedback(client, declined_application): @@ -65,7 +66,7 @@ def test_access_expired_application(client, session, pending_application): response = client.get(f"/applications/uuid/{pending_application.uuid}") assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application expired"} + assert response.json() == {"detail": _("Application expired")} def test_rollback_credit_product(client, accepted_application): diff --git a/tests/routers/test_applications.py b/tests/routers/test_applications.py index 0f923378..de9eb251 100644 --- a/tests/routers/test_applications.py +++ b/tests/routers/test_applications.py @@ -4,6 +4,7 @@ from fastapi import status from app import models, util +from app.i18n import _ from tests import BASEDIR, MockResponse, assert_ok, load_json_file @@ -20,7 +21,7 @@ def test_reject_application(client, session, lender_header, pending_application) # tries to reject the application before its started response = client.post(f"/applications/{appid}/reject-application", json=payload, headers=lender_header) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application status should not be PENDING"} + assert response.json() == {"detail": _("Application status should not be %(status)s", status=_("PENDING"))} pending_application.status = models.ApplicationStatus.STARTED session.commit() @@ -63,7 +64,7 @@ def test_approve_application_cycle( # Application should be accepted now so it cannot be accepted again response = client.post("/applications/access-scheme", json={"uuid": pending_application.uuid}) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application status should not be ACCEPTED"} + assert response.json() == {"detail": _("Application status should not be %(status)s", status=_("ACCEPTED"))} response = client.post( "/applications/credit-product-options", @@ -100,12 +101,12 @@ def test_approve_application_cycle( # different lender user tries to fetch previous awards response = client.get(f"/applications/{appid}/previous-awards", headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # different lender tries to get the application response = client.get(f"/applications/id/{appid}", headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # borrower tries to change their email to a non valid one response = client.post( @@ -113,7 +114,7 @@ def test_approve_application_cycle( json={"uuid": pending_application.uuid, "new_email": "wrong_email@@noreply!$%&/().open-contracting.org"}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.json() == {"detail": "New email is not valid"} + assert response.json() == {"detail": _("New email is not valid")} response = client.post( "/applications/change-email", json={"uuid": pending_application.uuid, "new_email": new_email} @@ -139,7 +140,7 @@ def test_approve_application_cycle( json={"uuid": pending_application.uuid, "confirmation_email_token": "confirmation_email_token"}, ) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application is not pending an email confirmation"} + assert response.json() == {"detail": _("Application is not pending an email confirmation")} response = client.post("/applications/submit", json={"uuid": pending_application.uuid}) assert_ok(response) @@ -153,22 +154,22 @@ def test_approve_application_cycle( files={"file": (file_to_upload.name, file_to_upload, "image/jpeg")}, ) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application status should not be SUBMITTED"} + assert response.json() == {"detail": _("Application status should not be %(status)s", status=_("SUBMITTED"))} # different lender user tries to start the application response = client.post(f"/applications/{appid}/start", headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # different lender user tries to update the award response = client.put(f"/applications/{appid}/award", json=award_payload, headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # different lender user tries to update the borrower response = client.put(f"/applications/{appid}/borrower", json=borrower_payload, headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # The lender user starts application response = client.post(f"/applications/{appid}/start", headers=lender_header) @@ -178,22 +179,22 @@ def test_approve_application_cycle( # different lender user tries to update the award response = client.put(f"/applications/{appid}/award", json=award_payload, headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # different lender user tries to update the borrower response = client.put(f"/applications/{appid}/borrower", json=borrower_payload, headers=unauthorized_lender_header) assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "User is not authorized"} + assert response.json() == {"detail": _("User is not authorized")} # Lender user tries to update non existing award response = client.put("/applications/999/award", json=award_payload, headers=lender_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Application not found"} + assert response.json() == {"detail": _("Application not found")} # Lender user tries to update non existing borrower response = client.put("/applications/999/borrower", json=borrower_payload, headers=lender_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Application not found"} + assert response.json() == {"detail": _("Application not found")} # Lender user tries to update the award response = client.put(f"/applications/{appid}/award", json=award_payload, headers=lender_header) @@ -217,7 +218,7 @@ def test_approve_application_cycle( files={"file": (file_to_upload.name, file_to_upload, "image/jpeg")}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.json() == {"detail": "Format not allowed. It must be a PNG, JPEG, or PDF file"} + assert response.json() == {"detail": _("Format not allowed. It must be a PNG, JPEG, or PDF file")} with open(file, "rb") as file_to_upload: response = client.post( @@ -241,12 +242,12 @@ def test_approve_application_cycle( # OCP ask for a file that does not exist response = client.get("/applications/documents/id/999", headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "BorrowerDocument not found"} + assert response.json() == {"detail": _("BorrowerDocument not found")} # lender tries to approve the application without verifying legal_name response = client.post(f"/applications/{appid}/approve-application", json=approve_payload, headers=lender_header) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Some borrower data field are not verified"} + assert response.json() == {"detail": _("Some borrower data field are not verified")} # verify legal_name response = client.put(f"/applications/{appid}/verify-data-field", json={"legal_name": True}, headers=lender_header) @@ -264,7 +265,7 @@ def test_approve_application_cycle( # lender tries to approve the application without verifying INCORPORATION_DOCUMENT response = client.post(f"/applications/{appid}/approve-application", json=approve_payload, headers=lender_header) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Some documents are not verified"} + assert response.json() == {"detail": _("Some documents are not verified")} # verify borrower document response = client.put( @@ -313,7 +314,7 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get("/applications/admin-list/?page=1&page_size=4&sort_field=borrower.legal_name&sort_order=asc") assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "Not authenticated"} + assert response.json() == {"detail": _("Not authenticated")} response = client.get(f"/applications/id/{appid}", headers=admin_header) assert_ok(response) @@ -323,19 +324,19 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get(f"/applications/id/{appid}") assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "Not authenticated"} + assert response.json() == {"detail": _("Not authenticated")} # tries to get a non existing application response = client.get("/applications/id/999", headers=lender_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Application not found"} + assert response.json() == {"detail": _("Application not found")} response = client.get("/applications", headers=lender_header) assert_ok(response) response = client.get("/applications") assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": "Not authenticated"} + assert response.json() == {"detail": _("Not authenticated")} response = client.get(f"/applications/uuid/{pending_application.uuid}") assert_ok(response) @@ -345,12 +346,12 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get(f"/applications/uuid/{pending_application.uuid}") assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"detail": "Application lapsed"} + assert response.json() == {"detail": _("Application lapsed")} response = client.get("/applications/uuid/123-456") assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Application not found"} + assert response.json() == {"detail": _("Application not found")} response = client.post("/applications/access-scheme", json={"uuid": "123-456"}) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Application not found"} + assert response.json() == {"detail": _("Application not found")} diff --git a/tests/routers/test_lenders.py b/tests/routers/test_lenders.py index 1259fecd..a01742eb 100644 --- a/tests/routers/test_lenders.py +++ b/tests/routers/test_lenders.py @@ -3,6 +3,7 @@ from fastapi import status from app import models +from app.i18n import _ from tests import assert_ok @@ -43,7 +44,7 @@ def test_create_credit_product(client, admin_header, lender_header, lender): # Lender tries to create credit product response = client.post(f"/lenders/{lender.id}/credit-products", json=create_payload, headers=lender_header) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json() == {"detail": "Insufficient permissions"} + assert response.json() == {"detail": _("Insufficient permissions")} with warnings.catch_warnings(): # "Pydantic serializer warnings" "Expected `decimal` but got `float` - serialized value may not be as expected" @@ -57,7 +58,7 @@ def test_create_credit_product(client, admin_header, lender_header, lender): # OCP user tries to create a credit product for a non existent lender response = client.post("/lenders/999/credit-products", json=create_payload, headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Lender not found"} + assert response.json() == {"detail": _("Lender not found")} with warnings.catch_warnings(): # "Pydantic serializer warnings" "Expected `decimal` but got `int` - serialized value may not be as expected" @@ -72,7 +73,7 @@ def test_create_credit_product(client, admin_header, lender_header, lender): # tries to update a credit product that does not exist response = client.put("/credit-products/999", json=update_payload, headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "CreditProduct not found"} + assert response.json() == {"detail": _("CreditProduct not found")} response = client.get(f"/credit-products/{credit_product_id}") assert_ok(response) @@ -93,11 +94,11 @@ def test_create_lender(client, admin_header, lender_header, lender): # tries to create same lender twice response = client.post("/lenders", json=payload, headers=admin_header) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.json() == {"detail": "Lender already exists"} + assert response.json() == {"detail": _("Lender already exists")} response = client.post("/lenders", json=payload, headers=lender_header) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json() == {"detail": "Insufficient permissions"} + assert response.json() == {"detail": _("Insufficient permissions")} def test_create_lender_with_credit_product(client, admin_header, lender_header, lender): @@ -136,7 +137,7 @@ def test_create_lender_with_credit_product(client, admin_header, lender_header, # lender user tries to create lender response = client.post("/lenders", json=payload, headers=lender_header) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json() == {"detail": "Insufficient permissions"} + assert response.json() == {"detail": _("Insufficient permissions")} def test_get_lender(client, admin_header, lender_header, unauthorized_lender_header, lender): @@ -151,7 +152,7 @@ def test_get_lender(client, admin_header, lender_header, unauthorized_lender_hea response = client.get("/lenders/999", headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "Lender not found"} + assert response.json() == {"detail": _("Lender not found")} def test_update_lender(client, admin_header, lender_header, lender): @@ -169,7 +170,7 @@ def test_update_lender(client, admin_header, lender_header, lender): # Lender user tries to update lender response = client.put(f"/lenders/{lender.id}", json=payload, headers=lender_header) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json() == {"detail": "Insufficient permissions"} + assert response.json() == {"detail": _("Insufficient permissions")} response = client.put(f"/lenders/{lender.id}", json={"sla_days": "not_valid_value"}, headers=admin_header) data = response.json() diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index 1adf6c1a..62884085 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -2,6 +2,7 @@ from fastapi import status +from app.i18n import _ from tests import assert_ok @@ -23,7 +24,7 @@ def test_create_and_get_user(client, admin_header, lender_header, user_payload): # try to get a non-existing user response = client.get("/users/200") assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": "User not found"} + assert response.json() == {"detail": _("User not found")} # try to get all users response = client.get("/users?page=0&page_size=5&sort_field=created_at&sort_order=desc", headers=admin_header) @@ -31,7 +32,7 @@ def test_create_and_get_user(client, admin_header, lender_header, user_payload): response = client.get("/users?page=0&page_size=5&sort_field=created_at&sort_order=desc", headers=lender_header) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json() == {"detail": "Insufficient permissions"} + assert response.json() == {"detail": _("Insufficient permissions")} def test_update_user(client, admin_header, lender_header, user_payload): @@ -54,7 +55,7 @@ def test_update_user(client, admin_header, lender_header, user_payload): headers=lender_header, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED - assert response.json() == {"detail": "Insufficient permissions"} + assert response.json() == {"detail": _("Insufficient permissions")} def test_duplicate_user(client, admin_header, user_payload): @@ -64,7 +65,7 @@ def test_duplicate_user(client, admin_header, user_payload): # duplicate user response = client.post("/users", json=user_payload, headers=admin_header) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.json() == {"detail": "Username already exists"} + assert response.json() == {"detail": _("Username already exists")} def test_logout_invalid_authorization_header(client, caplog): @@ -73,7 +74,7 @@ def test_logout_invalid_authorization_header(client, caplog): response = client.get("/users/logout", headers={"Authorization": "Bearer ACCESS_TOKEN"}) assert_ok(response) - assert response.json() == {"detail": "User logged out successfully"} + assert response.json() == {"detail": _("User logged out successfully")} assert not caplog.records @@ -83,5 +84,5 @@ def test_logout_no_authorization_header(client, caplog): response = client.get("/users/logout") assert_ok(response) - assert response.json() == {"detail": "User logged out successfully"} + assert response.json() == {"detail": _("User logged out successfully")} assert not caplog.records diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 00000000..30cc873b --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,16 @@ +import pytest + +from app.i18n import _ +from app.settings import app_settings + + +@pytest.mark.parametrize(("language", "expected"), [("es", "Pendiente"), ("en", "PENDING")]) +def test_translate_implicit(language, expected): + app_settings.email_template_lang = language + + assert _("PENDING") == expected + + +@pytest.mark.parametrize(("language", "expected"), [("es", "Pendiente"), ("en", "PENDING")]) +def test_translate_explicit(language, expected): + assert _("PENDING", language) == expected From 764c40f55d19c58667f18a1308239fb86392de76 Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 18:48:22 -0400 Subject: [PATCH 06/16] test: Update tests to succeed when run in both es and null translations. Avoid errors due to test order. --- tests/commands/test_commands.py | 2 +- tests/routers/test_lenders.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 52184131..cb54bc46 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -57,7 +57,7 @@ def test_set_lapsed_applications_no_lapsed(pending_application): assert_success(result) -def test_send_overdue_reminders(session, mock_send_templated_email, started_application): +def test_send_overdue_reminders(reset_database, session, mock_send_templated_email, started_application): started_application.lender_started_at = datetime.now(started_application.tz) - timedelta( days=started_application.lender.sla_days + 1 ) diff --git a/tests/routers/test_lenders.py b/tests/routers/test_lenders.py index a01742eb..28524e69 100644 --- a/tests/routers/test_lenders.py +++ b/tests/routers/test_lenders.py @@ -1,3 +1,4 @@ +import uuid import warnings from fastapi import status @@ -81,7 +82,7 @@ def test_create_credit_product(client, admin_header, lender_header, lender): def test_create_lender(client, admin_header, lender_header, lender): payload = { - "name": "John Doe", + "name": str(uuid.uuid4()), "email_group": "lenders@noreply.open-contracting.org", "type": "Some Type", "sla_days": 5, @@ -103,7 +104,7 @@ def test_create_lender(client, admin_header, lender_header, lender): def test_create_lender_with_credit_product(client, admin_header, lender_header, lender): payload = { - "name": "test lender", + "name": str(uuid.uuid4()), "email_group": "test@noreply.open-contracting.org", "type": "Some Type", "sla_days": 5, @@ -157,7 +158,7 @@ def test_get_lender(client, admin_header, lender_header, unauthorized_lender_hea def test_update_lender(client, admin_header, lender_header, lender): payload = { - "name": "John smith", + "name": str(uuid.uuid4()), "email_group": "lenders@noreply.open-contracting.org", "type": "Some Type", "sla_days": 5, From c948c7024c39978d4edf85fd0a9ae84f13ca88f1 Mon Sep 17 00:00:00 2001 From: James McKinney <26463+jpmckinney@users.noreply.github.com> Date: Wed, 21 Aug 2024 18:56:32 -0400 Subject: [PATCH 07/16] docs: Remove stub on frontend page --- docs/frontend.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/frontend.rst b/docs/frontend.rst index 51957b64..1bcddda5 100644 --- a/docs/frontend.rst +++ b/docs/frontend.rst @@ -3,8 +3,6 @@ Frontend integration ==================== -.. note:: This page is a stub. - .. seealso:: `Credere frontend `__ Enumerations From 25718923c25e7ade0c6cc7755e2dd3ceadfab45c Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Wed, 21 Aug 2024 19:44:17 -0400 Subject: [PATCH 08/16] fix: _format_currency check for null values --- app/utils/tables.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/utils/tables.py b/app/utils/tables.py index 097172f9..5cafbfd0 100644 --- a/app/utils/tables.py +++ b/app/utils/tables.py @@ -10,10 +10,10 @@ def _format_currency(number: Decimal | None, currency: str) -> str: - if isinstance(number, str): + if isinstance(number, str) or not number: try: number = int(number) - except ValueError: + except (ValueError, TypeError): return "-" locale.setlocale(locale.LC_ALL, "") From 6d449979d57c557933da8314038f2a77240eba35 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Wed, 21 Aug 2024 19:58:47 -0400 Subject: [PATCH 09/16] i18n: fix and add missing translations --- locale/es/LC_MESSAGES/messages.po | 98 +++++++++++++++---------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index 20cad557..76a379a6 100644 --- a/locale/es/LC_MESSAGES/messages.po +++ b/locale/es/LC_MESSAGES/messages.po @@ -244,7 +244,7 @@ msgid "actividades_hogares" msgstr "" "Actividades de los hogares individuales en calidad de empleadores; " "actividades no diferenciadas de los hogares individuales como productores" -" de bienes yservicios para uso propio" +" de bienes y servicios para uso propio" #. BorrowerSector #: README.rst:308 @@ -253,52 +253,52 @@ msgstr "Actividades de organizaciones y entidades extraterritoriales" #: app/auth.py:56 msgid "JWK public key not found" -msgstr "" +msgstr "La clave pública JWK no fue encontrada" #: app/auth.py:80 msgid "Wrong authentication method" -msgstr "" +msgstr "Método de autenticación equivocado" #: app/auth.py:96 app/auth.py:99 msgid "JWK invalid" -msgstr "" +msgstr "JWK inválido" #: app/auth.py:105 msgid "Not authenticated" -msgstr "" +msgstr "No autenticado" #: app/dependencies.py:41 msgid "Username missing" -msgstr "" +msgstr "Falta el nombre de usuario" #: app/dependencies.py:55 msgid "User not found" -msgstr "" +msgstr "Usuario no encontrado" #: app/dependencies.py:63 msgid "Insufficient permissions" -msgstr "" +msgstr "Permisos insuficientes" #: app/dependencies.py:89 msgid "User is not authorized" -msgstr "" +msgstr "Usuario no autorizado" #: app/dependencies.py:94 msgid "Application expired" -msgstr "" +msgstr "La aplicación ha expirado" #: app/dependencies.py:100 #, python-format msgid "Application status should not be %(status)s" -msgstr "" +msgstr "El estado de la aplicación no debe ser %(status)s" #: app/dependencies.py:111 app/dependencies.py:148 msgid "Application not found" -msgstr "" +msgstr "La aplicación no ha sido encontrada" #: app/dependencies.py:153 msgid "Application lapsed" -msgstr "" +msgstr "La aplicación ha caducado" #: app/mail.py:99 msgid "Your credit application has been prequalified" @@ -318,7 +318,7 @@ msgstr "Bienvenido/a" #: app/mail.py:190 msgid "New contract submission" -msgstr "Una MIPYME ha subido su contrato" +msgstr "Una empresa ha subido su contrato" #: app/mail.py:212 msgid "Thank you for uploading the signed contract" @@ -335,7 +335,7 @@ msgstr "Restablecer contraseña" #: app/mail.py:307 app/mail.py:325 msgid "Opportunity to access MSME credit for being awarded a public contract" msgstr "" -"Oportunidad de acceso a crédito MIPYME por ser adjudicatario de contrato " +"Oportunidad de acceso a crédito por ser adjudicatario de contrato " "estatal" #: app/mail.py:343 @@ -343,7 +343,7 @@ msgid "" "Reminder - Opportunity to access MSME credit for being awarded a public " "contract" msgstr "" -"Recordatorio - Oportunidad de acceso a crédito MIPYME por ser " +"Recordatorio - Oportunidad de acceso a crédito por ser " "adjudicatario de contrato estatal" #: app/mail.py:368 app/mail.py:387 @@ -377,19 +377,19 @@ msgstr "Aplicación actualizada" #: app/util.py:53 #, python-format msgid "%(model_name)s not found" -msgstr "" +msgstr "%(model_name)s no encontrado" #: app/util.py:111 msgid "Format not allowed. It must be a PNG, JPEG, or PDF file" -msgstr "" +msgstr "Formato no permitido. El formato debe ser un PNG, JPEG or archivo PDF" #: app/util.py:117 msgid "File is too large" -msgstr "" +msgstr "El archivo es muy grande" #: app/routers/applications.py:175 msgid "Some borrower data field are not verified" -msgstr "Algunos campos de datos de la MIPYME no están verificados" +msgstr "Algunos campos de datos de la empresa no fueron verificados" #: app/routers/applications.py:186 msgid "Some documents are not verified" @@ -397,19 +397,19 @@ msgstr "Algunos documentos no fueron verificados" #: app/routers/applications.py:330 msgid "Award not found" -msgstr "" +msgstr "La adjudicación no ha sido encontrada" #: app/routers/applications.py:372 msgid "Borrower not found" -msgstr "" +msgstr "La empresa no ha sido encontrada" #: app/routers/applications.py:380 msgid "This column cannot be updated" -msgstr "" +msgstr "Esta columna no puede ser actualizada" #: app/routers/applications.py:579 msgid "There was an error" -msgstr "" +msgstr "Ocurrió un error" #: app/routers/downloads.py:88 app/routers/downloads.py:106 msgid "Application Details" @@ -417,7 +417,7 @@ msgstr "Detalles de la aplicación" #: app/routers/downloads.py:99 msgid "Previous Public Sector Contracts" -msgstr "Anteriores contratos con el sector público" +msgstr "Contratos anteriores con el sector público" #: app/routers/downloads.py:148 app/utils/tables.py:209 msgid "National Tax ID" @@ -441,77 +441,77 @@ msgstr "Etapa" #: app/routers/lenders.py:48 app/routers/lenders.py:125 msgid "Lender already exists" -msgstr "" +msgstr "La entidad financiera ya existe" #: app/routers/guest/applications.py:378 app/routers/guest/applications.py:467 #: app/routers/lenders.py:189 msgid "Credit product not found" -msgstr "" +msgstr "Producto crediticio no encontrado" #: app/routers/users.py:60 msgid "Username already exists" -msgstr "" +msgstr "El nombre de usuario ya existe" #: app/routers/users.py:103 msgid "Password changed with MFA setup required" -msgstr "" +msgstr "Cambio de contraseña con configuración de MFA requerido" #: app/routers/users.py:112 app/routers/users.py:192 msgid "Temporal password is expired, please request a new one" -msgstr "" +msgstr "La contraseña temporal ha expirado, por favor solicite una nueva" #: app/routers/users.py:116 msgid "There was an error trying to update the password" -msgstr "" +msgstr "Ocurrió un error al tratar de actualizar la contraseña" #: app/routers/users.py:119 msgid "Password changed" -msgstr "" +msgstr "Contraseña cambiada" #: app/routers/users.py:144 msgid "Invalid session for the user, session is expired" -msgstr "" +msgstr "Sesión inválida para el usuario, la sesión ha expirado" #: app/routers/users.py:148 msgid "There was an error trying to setup mfa" -msgstr "" +msgstr "Ocurrió un error tratando de configurar el MFA" #: app/routers/users.py:151 msgid "MFA configured successfully" -msgstr "" +msgstr "El MFA fue configurado correctamente" #: app/routers/users.py:236 msgid "User logged out successfully" -msgstr "" +msgstr "Usuario deslogueado correctamente" #: app/routers/users.py:285 msgid "An email with a reset link was sent to end user" -msgstr "" +msgstr "Se envió un correo con el enlace de reseteo de contraseña." #: app/routers/users.py:366 msgid "User already exists" -msgstr "" +msgstr "El usuario ya existe" #: app/routers/guest/applications.py:321 app/routers/guest/applications.py:371 #: app/routers/guest/applications.py:460 app/routers/guest/applications.py:526 msgid "Credit product not selected" -msgstr "" +msgstr "El producto crediticio no fue seleccionado" #: app/routers/guest/applications.py:327 msgid "Cannot rollback at this stage" -msgstr "" +msgstr "No se puede retroceder en esta etapa" #: app/routers/guest/applications.py:532 msgid "Lender not selected" -msgstr "" +msgstr "No se ha seleccionado la entidad financiera" #: app/routers/guest/applications.py:548 msgid "There was an error submitting the application" -msgstr "" +msgstr "Ocurrió un error enviando la aplicación" #: app/routers/guest/applications.py:597 msgid "Cannot upload document at this stage" -msgstr "" +msgstr "No se pueden subir documentos en esta etapa" #: app/routers/guest/applications.py:804 msgid "A new application has alredy been created from this one" @@ -520,19 +520,19 @@ msgstr "Una nueva aplicación ya ha sido creada desde esta" #: app/routers/guest/applications.py:823 #, python-format msgid "There was a problem copying the application. %(exception)s" -msgstr "" +msgstr "Ocurrió un problema copiando la aplicación. %(exception)s" #: app/routers/guest/emails.py:33 msgid "New email is not valid" -msgstr "" +msgstr "El nuevo correo no es válido" #: app/routers/guest/emails.py:80 msgid "Application is not pending an email confirmation" -msgstr "" +msgstr "La aplicación no tiene la confirmación de correo pendiente" #: app/routers/guest/emails.py:87 msgid "Not authorized to modify this application" -msgstr "" +msgstr "No autorizado a modificar la aplicación en esta etapa" #: app/utils/tables.py:41 msgid "Financing Options" @@ -638,7 +638,7 @@ msgstr "Tipo de contrato" #: app/utils/tables.py:197 msgid "MSME Data" -msgstr "Datos de la MIPYME" +msgstr "Datos de la empresa" #: app/utils/tables.py:205 msgid "Address" @@ -666,5 +666,5 @@ msgstr "Correo institucional" #: app/utils/tables.py:247 msgid "MSME Documents" -msgstr "Documentos de la MIPYME" +msgstr "Documentos de la empresa" From e9359eaa8421fe42c64e815bb9ec5ac10049f070 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 12:34:43 -0400 Subject: [PATCH 10/16] fix: update locale --- locale/es/LC_MESSAGES/messages.po | 80 +++++++++++++++---------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index 76a379a6..979d4ab5 100644 --- a/locale/es/LC_MESSAGES/messages.po +++ b/locale/es/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-08-21 17:40-0400\n" +"POT-Creation-Date: 2024-08-22 12:34-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -251,52 +251,52 @@ msgstr "" msgid "actividades_organizaciones_extraterritoriales" msgstr "Actividades de organizaciones y entidades extraterritoriales" -#: app/auth.py:56 +#: app/auth.py:59 msgid "JWK public key not found" msgstr "La clave pública JWK no fue encontrada" -#: app/auth.py:80 +#: app/auth.py:84 msgid "Wrong authentication method" msgstr "Método de autenticación equivocado" -#: app/auth.py:96 app/auth.py:99 +#: app/auth.py:102 app/auth.py:108 msgid "JWK invalid" msgstr "JWK inválido" -#: app/auth.py:105 +#: app/auth.py:115 msgid "Not authenticated" msgstr "No autenticado" -#: app/dependencies.py:41 +#: app/dependencies.py:44 msgid "Username missing" msgstr "Falta el nombre de usuario" -#: app/dependencies.py:55 +#: app/dependencies.py:61 msgid "User not found" msgstr "Usuario no encontrado" -#: app/dependencies.py:63 +#: app/dependencies.py:70 msgid "Insufficient permissions" msgstr "Permisos insuficientes" -#: app/dependencies.py:89 +#: app/dependencies.py:98 msgid "User is not authorized" msgstr "Usuario no autorizado" -#: app/dependencies.py:94 +#: app/dependencies.py:106 msgid "Application expired" msgstr "La aplicación ha expirado" -#: app/dependencies.py:100 +#: app/dependencies.py:113 #, python-format msgid "Application status should not be %(status)s" msgstr "El estado de la aplicación no debe ser %(status)s" -#: app/dependencies.py:111 app/dependencies.py:148 +#: app/dependencies.py:126 app/dependencies.py:166 msgid "Application not found" msgstr "La aplicación no ha sido encontrada" -#: app/dependencies.py:153 +#: app/dependencies.py:172 msgid "Application lapsed" msgstr "La aplicación ha caducado" @@ -334,17 +334,15 @@ msgstr "Restablecer contraseña" #: app/mail.py:307 app/mail.py:325 msgid "Opportunity to access MSME credit for being awarded a public contract" -msgstr "" -"Oportunidad de acceso a crédito por ser adjudicatario de contrato " -"estatal" +msgstr "Oportunidad de acceso a crédito por ser adjudicatario de contrato estatal" #: app/mail.py:343 msgid "" "Reminder - Opportunity to access MSME credit for being awarded a public " "contract" msgstr "" -"Recordatorio - Oportunidad de acceso a crédito por ser " -"adjudicatario de contrato estatal" +"Recordatorio - Oportunidad de acceso a crédito por ser adjudicatario de " +"contrato estatal" #: app/mail.py:368 app/mail.py:387 msgid "New application submission" @@ -374,16 +372,16 @@ msgstr "Opción de crédito alternativa" msgid "Application updated" msgstr "Aplicación actualizada" -#: app/util.py:53 +#: app/util.py:54 #, python-format msgid "%(model_name)s not found" msgstr "%(model_name)s no encontrado" -#: app/util.py:111 +#: app/util.py:112 msgid "Format not allowed. It must be a PNG, JPEG, or PDF file" msgstr "Formato no permitido. El formato debe ser un PNG, JPEG or archivo PDF" -#: app/util.py:117 +#: app/util.py:118 msgid "File is too large" msgstr "El archivo es muy grande" @@ -395,19 +393,19 @@ msgstr "Algunos campos de datos de la empresa no fueron verificados" msgid "Some documents are not verified" msgstr "Algunos documentos no fueron verificados" -#: app/routers/applications.py:330 +#: app/routers/applications.py:332 msgid "Award not found" msgstr "La adjudicación no ha sido encontrada" -#: app/routers/applications.py:372 +#: app/routers/applications.py:377 msgid "Borrower not found" msgstr "La empresa no ha sido encontrada" -#: app/routers/applications.py:380 +#: app/routers/applications.py:386 msgid "This column cannot be updated" msgstr "Esta columna no puede ser actualizada" -#: app/routers/applications.py:579 +#: app/routers/applications.py:585 msgid "There was an error" msgstr "Ocurrió un error" @@ -439,56 +437,56 @@ msgstr "Fecha de envío" msgid "Stage" msgstr "Etapa" -#: app/routers/lenders.py:48 app/routers/lenders.py:125 +#: app/routers/lenders.py:49 app/routers/lenders.py:126 msgid "Lender already exists" msgstr "La entidad financiera ya existe" #: app/routers/guest/applications.py:378 app/routers/guest/applications.py:467 -#: app/routers/lenders.py:189 +#: app/routers/lenders.py:192 msgid "Credit product not found" msgstr "Producto crediticio no encontrado" -#: app/routers/users.py:60 +#: app/routers/users.py:61 msgid "Username already exists" msgstr "El nombre de usuario ya existe" -#: app/routers/users.py:103 +#: app/routers/users.py:104 msgid "Password changed with MFA setup required" msgstr "Cambio de contraseña con configuración de MFA requerido" -#: app/routers/users.py:112 app/routers/users.py:192 +#: app/routers/users.py:113 app/routers/users.py:193 msgid "Temporal password is expired, please request a new one" msgstr "La contraseña temporal ha expirado, por favor solicite una nueva" -#: app/routers/users.py:116 +#: app/routers/users.py:117 msgid "There was an error trying to update the password" msgstr "Ocurrió un error al tratar de actualizar la contraseña" -#: app/routers/users.py:119 +#: app/routers/users.py:120 msgid "Password changed" msgstr "Contraseña cambiada" -#: app/routers/users.py:144 +#: app/routers/users.py:145 msgid "Invalid session for the user, session is expired" msgstr "Sesión inválida para el usuario, la sesión ha expirado" -#: app/routers/users.py:148 +#: app/routers/users.py:149 msgid "There was an error trying to setup mfa" msgstr "Ocurrió un error tratando de configurar el MFA" -#: app/routers/users.py:151 +#: app/routers/users.py:152 msgid "MFA configured successfully" msgstr "El MFA fue configurado correctamente" -#: app/routers/users.py:236 +#: app/routers/users.py:237 msgid "User logged out successfully" msgstr "Usuario deslogueado correctamente" -#: app/routers/users.py:285 +#: app/routers/users.py:286 msgid "An email with a reset link was sent to end user" msgstr "Se envió un correo con el enlace de reseteo de contraseña." -#: app/routers/users.py:366 +#: app/routers/users.py:367 msgid "User already exists" msgstr "El usuario ya existe" @@ -522,15 +520,15 @@ msgstr "Una nueva aplicación ya ha sido creada desde esta" msgid "There was a problem copying the application. %(exception)s" msgstr "Ocurrió un problema copiando la aplicación. %(exception)s" -#: app/routers/guest/emails.py:33 +#: app/routers/guest/emails.py:34 msgid "New email is not valid" msgstr "El nuevo correo no es válido" -#: app/routers/guest/emails.py:80 +#: app/routers/guest/emails.py:81 msgid "Application is not pending an email confirmation" msgstr "La aplicación no tiene la confirmación de correo pendiente" -#: app/routers/guest/emails.py:87 +#: app/routers/guest/emails.py:88 msgid "Not authorized to modify this application" msgstr "No autorizado a modificar la aplicación en esta etapa" From b9377281962ba629c53ed80562013693629ad4c8 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 12:35:48 -0400 Subject: [PATCH 11/16] Apply suggestions from code review --- app/utils/tables.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/utils/tables.py b/app/utils/tables.py index 5cafbfd0..762f5554 100644 --- a/app/utils/tables.py +++ b/app/utils/tables.py @@ -10,10 +10,12 @@ def _format_currency(number: Decimal | None, currency: str) -> str: - if isinstance(number, str) or not number: + if number is None: + return "-" + if isinstance(number, str): try: number = int(number) - except (ValueError, TypeError): + except ValueError: return "-" locale.setlocale(locale.LC_ALL, "") From d099a009b72546b23f21b7bd8a43df2c3bab9f37 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 12:48:20 -0400 Subject: [PATCH 12/16] fix tests to match locale definition --- tests/routers/test_lenders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/routers/test_lenders.py b/tests/routers/test_lenders.py index 28524e69..4d483744 100644 --- a/tests/routers/test_lenders.py +++ b/tests/routers/test_lenders.py @@ -59,7 +59,7 @@ def test_create_credit_product(client, admin_header, lender_header, lender): # OCP user tries to create a credit product for a non existent lender response = client.post("/lenders/999/credit-products", json=create_payload, headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": _("Lender not found")} + assert response.json() == {"detail": _("%(model_name)s not found", model_name="Lender")} with warnings.catch_warnings(): # "Pydantic serializer warnings" "Expected `decimal` but got `int` - serialized value may not be as expected" @@ -74,7 +74,7 @@ def test_create_credit_product(client, admin_header, lender_header, lender): # tries to update a credit product that does not exist response = client.put("/credit-products/999", json=update_payload, headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": _("CreditProduct not found")} + assert response.json() == {"detail": _("%(model_name)s not found", model_name="CreditProduct")} response = client.get(f"/credit-products/{credit_product_id}") assert_ok(response) @@ -153,7 +153,7 @@ def test_get_lender(client, admin_header, lender_header, unauthorized_lender_hea response = client.get("/lenders/999", headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": _("Lender not found")} + assert response.json() == {"detail": _("%(model_name)s not found", model_name="Lender")} def test_update_lender(client, admin_header, lender_header, lender): From 608ab87836a2d419f0f6872e01d9e792e4888c5f Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 12:51:29 -0400 Subject: [PATCH 13/16] fix test_users --- tests/routers/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/routers/test_users.py b/tests/routers/test_users.py index 62884085..16c155ba 100644 --- a/tests/routers/test_users.py +++ b/tests/routers/test_users.py @@ -24,7 +24,7 @@ def test_create_and_get_user(client, admin_header, lender_header, user_payload): # try to get a non-existing user response = client.get("/users/200") assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": _("User not found")} + assert response.json() == {"detail": _("%(model_name)s not found", model_name="User")} # try to get all users response = client.get("/users?page=0&page_size=5&sort_field=created_at&sort_order=desc", headers=admin_header) From 36b5c55b2bfb1c3bb8101e8e44040df479ef8700 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 13:06:41 -0400 Subject: [PATCH 14/16] fix one test applications lang issue --- tests/routers/test_applications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/routers/test_applications.py b/tests/routers/test_applications.py index de9eb251..3b6ec373 100644 --- a/tests/routers/test_applications.py +++ b/tests/routers/test_applications.py @@ -242,7 +242,7 @@ def test_approve_application_cycle( # OCP ask for a file that does not exist response = client.get("/applications/documents/id/999", headers=admin_header) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json() == {"detail": _("BorrowerDocument not found")} + assert response.json() == {"detail": _("%(model_name)s not found", model_name="BorrowerDocument")} # lender tries to approve the application without verifying legal_name response = client.post(f"/applications/{appid}/approve-application", json=approve_payload, headers=lender_header) From 17f8a7ac99b0e55163bdee4212968a5529e88232 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 13:45:39 -0400 Subject: [PATCH 15/16] fix: add a note about fastapi error --- tests/routers/test_applications.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/routers/test_applications.py b/tests/routers/test_applications.py index 3b6ec373..c4cb1622 100644 --- a/tests/routers/test_applications.py +++ b/tests/routers/test_applications.py @@ -314,7 +314,8 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get("/applications/admin-list/?page=1&page_size=4&sort_field=borrower.legal_name&sort_order=asc") assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": _("Not authenticated")} + # This error comes from fastapi and therefore is not translated + assert response.json() == {"detail": "Not authenticated"} response = client.get(f"/applications/id/{appid}", headers=admin_header) assert_ok(response) From 8bef13d2a6f86ae9b790ba8cfac9dd5303ab61e4 Mon Sep 17 00:00:00 2001 From: Yohanna Lisnichuk Date: Thu, 22 Aug 2024 14:02:12 -0400 Subject: [PATCH 16/16] tests: temove lang from messages from fastapi --- tests/routers/test_applications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/routers/test_applications.py b/tests/routers/test_applications.py index c4cb1622..18589632 100644 --- a/tests/routers/test_applications.py +++ b/tests/routers/test_applications.py @@ -325,7 +325,7 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get(f"/applications/id/{appid}") assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": _("Not authenticated")} + assert response.json() == {"detail": "Not authenticated"} # tries to get a non existing application response = client.get("/applications/id/999", headers=lender_header) @@ -337,7 +337,7 @@ def test_get_applications(client, session, admin_header, lender_header, pending_ response = client.get("/applications") assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == {"detail": _("Not authenticated")} + assert response.json() == {"detail": "Not authenticated"} response = client.get(f"/applications/uuid/{pending_application.uuid}") assert_ok(response)