Add English language support with i18n implementation

Implemented comprehensive internationalization (i18n) for both frontend and backend:

Frontend:
- Added react-i18next configuration with Spanish (default) and English
- Created translation files (locales/es.json, locales/en.json)
- Refactored core components to use i18n: LoginPage, DashboardHeader, DataUploader
- Created LanguageSelector component with toggle between ES/EN
- Updated API client to send Accept-Language header

Backend:
- Created i18n module with translation dictionary for error messages
- Updated security.py to return localized authentication errors
- Updated api/analysis.py to return localized validation errors
- Implemented language detection from Accept-Language header

Spanish remains the default language ensuring backward compatibility.
Users can switch between languages using the language selector in the dashboard header.

https://claude.ai/code/session_1N9VX
This commit is contained in:
Claude
2026-02-06 17:46:01 +00:00
parent 9457d3d02f
commit f719d181c0
15 changed files with 768 additions and 58 deletions

View File

@@ -7,11 +7,12 @@ import math
from uuid import uuid4
from typing import Optional, Any, Literal
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Header
from fastapi.responses import JSONResponse
from beyond_api.security import get_current_user
from beyond_api.services.analysis_service import run_analysis_collect_json
from beyond_api.i18n import t, get_language_from_header
# Cache paths - same as in cache.py
CACHE_DIR = Path(os.getenv("CACHE_DIR", "/data/cache"))
@@ -52,6 +53,7 @@ async def analysis_endpoint(
economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user),
accept_language: Optional[str] = Header(None),
):
"""
Ejecuta el pipeline sobre un CSV subido (multipart/form-data) y devuelve
@@ -62,12 +64,13 @@ async def analysis_endpoint(
- "premium": usa la configuración completa por defecto
(p.ej. beyond_metrics_config.json), sin romper lo existente.
"""
lang = get_language_from_header(accept_language)
# Validar `analysis` (por si llega algo raro)
if analysis not in {"basic", "premium"}:
raise HTTPException(
status_code=400,
detail="analysis debe ser 'basic' o 'premium'.",
detail=t("analysis.invalid_type", lang),
)
# 1) Parseo de economía (si viene)
@@ -78,7 +81,7 @@ async def analysis_endpoint(
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="economy_json no es un JSON válido.",
detail=t("analysis.invalid_economy_json", lang),
)
# 2) Guardar el CSV subido en una carpeta de trabajo
@@ -159,23 +162,26 @@ async def analysis_cached_endpoint(
economy_json: Optional[str] = Form(default=None),
analysis: Literal["basic", "premium"] = Form(default="premium"),
current_user: str = Depends(get_current_user),
accept_language: Optional[str] = Header(None),
):
"""
Ejecuta el pipeline sobre el archivo CSV cacheado en el servidor.
Útil para re-analizar sin tener que subir el archivo de nuevo.
"""
lang = get_language_from_header(accept_language)
# Validar que existe el archivo cacheado
if not CACHED_FILE.exists():
raise HTTPException(
status_code=404,
detail="No hay archivo cacheado en el servidor. Sube un archivo primero.",
detail=t("analysis.no_cached_file", lang),
)
# Validar `analysis`
if analysis not in {"basic", "premium"}:
raise HTTPException(
status_code=400,
detail="analysis debe ser 'basic' o 'premium'.",
detail=t("analysis.invalid_type", lang),
)
# Parseo de economía (si viene)
@@ -186,7 +192,7 @@ async def analysis_cached_endpoint(
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="economy_json no es un JSON válido.",
detail=t("analysis.invalid_economy_json", lang),
)
# Extraer metadatos del CSV
@@ -204,7 +210,7 @@ async def analysis_cached_endpoint(
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error ejecutando análisis: {str(e)}",
detail=t("analysis.execution_error", lang, error=str(e)),
)
# Limpiar NaN/inf para que el JSON sea válido

128
backend/beyond_api/i18n.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Módulo de internacionalización para Beyond API.
Proporciona traducciones para mensajes de error, validaciones y clasificaciones.
"""
from typing import Literal
Language = Literal["es", "en"]
# Diccionario de traducciones
MESSAGES = {
"es": {
# Errores de autenticación
"auth.credentials_required": "Credenciales requeridas",
"auth.incorrect_credentials": "Credenciales incorrectas",
# Errores de análisis
"analysis.invalid_type": "analysis debe ser 'basic' o 'premium'.",
"analysis.invalid_economy_json": "economy_json no es un JSON válido.",
"analysis.no_cached_file": "No hay archivo cacheado en el servidor. Sube un archivo primero.",
"analysis.execution_error": "Error ejecutando análisis: {error}",
# Errores de validación
"validation.field_not_numeric": "El campo '{field}' debe ser numérico (float). Valor recibido: {value}",
"validation.segments_not_dict": "customer_segments debe ser un diccionario {segment: level}",
"validation.segment_value_not_str": "El valor de customer_segments['{key}'] debe ser str. Valor recibido: {value}",
"validation.csv_not_found": "El CSV no existe: {path}",
"validation.not_csv_file": "La ruta no apunta a un fichero CSV: {path}",
"validation.missing_columns": "Faltan columnas obligatorias para {metric}: {missing}",
# Clasificaciones Agentic Readiness
"agentic.ready_for_copilot": "Listo para Copilot",
"agentic.ready_for_copilot_desc": "Procesos con predictibilidad y simplicidad suficientes para asistencia IA (sugerencias en tiempo real, autocompletado).",
"agentic.optimize_first": "Optimizar primero",
"agentic.optimize_first_desc": "Estandarizar procesos y reducir variabilidad antes de implementar asistencia IA.",
"agentic.requires_human": "Requiere gestión humana",
"agentic.requires_human_desc": "Procesos complejos o variables que necesitan intervención humana antes de considerar automatización.",
},
"en": {
# Authentication errors
"auth.credentials_required": "Credentials required",
"auth.incorrect_credentials": "Incorrect credentials",
# Analysis errors
"analysis.invalid_type": "analysis must be 'basic' or 'premium'.",
"analysis.invalid_economy_json": "economy_json is not valid JSON.",
"analysis.no_cached_file": "No cached file on server. Upload a file first.",
"analysis.execution_error": "Error executing analysis: {error}",
# Validation errors
"validation.field_not_numeric": "Field '{field}' must be numeric (float). Received value: {value}",
"validation.segments_not_dict": "customer_segments must be a dictionary {segment: level}",
"validation.segment_value_not_str": "Value of customer_segments['{key}'] must be str. Received value: {value}",
"validation.csv_not_found": "CSV does not exist: {path}",
"validation.not_csv_file": "Path does not point to a CSV file: {path}",
"validation.missing_columns": "Missing required columns for {metric}: {missing}",
# Agentic Readiness classifications
"agentic.ready_for_copilot": "Ready for Copilot",
"agentic.ready_for_copilot_desc": "Processes with sufficient predictability and simplicity for AI assistance (real-time suggestions, autocomplete).",
"agentic.optimize_first": "Optimize first",
"agentic.optimize_first_desc": "Standardize processes and reduce variability before implementing AI assistance.",
"agentic.requires_human": "Requires human management",
"agentic.requires_human_desc": "Complex or variable processes that need human intervention before considering automation.",
}
}
def t(key: str, lang: Language = "es", **kwargs) -> str:
"""
Traduce un mensaje al idioma especificado.
Args:
key: Clave del mensaje (ej: 'auth.credentials_required')
lang: Idioma ('es' o 'en'). Por defecto 'es'
**kwargs: Variables para interpolación (ej: field='nombre', value='123')
Returns:
Mensaje traducido con variables interpoladas
Examples:
>>> t('auth.credentials_required', 'en')
'Credentials required'
>>> t('validation.field_not_numeric', 'en', field='age', value='abc')
"Field 'age' must be numeric (float). Received value: abc"
"""
messages = MESSAGES.get(lang, MESSAGES["es"])
message = messages.get(key, key)
# Interpolar variables si se proporcionan
if kwargs:
try:
return message.format(**kwargs)
except KeyError:
# Si falta alguna variable, devolver el mensaje sin interpolar
return message
return message
def get_language_from_header(accept_language: str | None) -> Language:
"""
Extrae el idioma preferido del header Accept-Language.
Args:
accept_language: Valor del header Accept-Language (ej: 'en-US,en;q=0.9,es;q=0.8')
Returns:
'en' si el idioma preferido es inglés, 'es' en caso contrario
Examples:
>>> get_language_from_header('en-US,en;q=0.9')
'en'
>>> get_language_from_header('es-ES,es;q=0.9')
'es'
>>> get_language_from_header(None)
'es'
"""
if not accept_language:
return "es"
# Extraer el primer idioma del header
primary_lang = accept_language.split(',')[0].split('-')[0].strip().lower()
return "en" if primary_lang == "en" else "es"

View File

@@ -2,8 +2,11 @@ from __future__ import annotations
import os
import secrets
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from typing import Optional
from .i18n import t, get_language_from_header
# auto_error=False para que no dispare el popup nativo del navegador automáticamente
security = HTTPBasic(auto_error=False)
@@ -13,16 +16,21 @@ BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str:
def get_current_user(
credentials: HTTPBasicCredentials | None = Depends(security),
accept_language: Optional[str] = Header(None)
) -> str:
"""
Valida el usuario/contraseña vía HTTP Basic.
NO envía WWW-Authenticate para evitar el popup nativo del navegador
(el frontend tiene su propio formulario de login).
"""
lang = get_language_from_header(accept_language)
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales requeridas",
detail=t("auth.credentials_required", lang),
)
correct_username = secrets.compare_digest(credentials.username, BASIC_USER)
@@ -31,7 +39,7 @@ def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales incorrectas",
detail=t("auth.incorrect_credentials", lang),
)
return credentials.username