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:
@@ -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
128
backend/beyond_api/i18n.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user