Clon del gateway Anexo22 -> EFC que ya esta en produccion, con los nombres
cambiados. No es una reinterpretacion: la maquina de reintentos de tres capas,
el corte en 4xx y las cuatro guardas de idempotencia se conservan tal cual.
Sin esto, "EFC es la fuente unica" obligaria a llamar a EFC dentro del request
del usuario, y un EFC caido le haria perder su trabajo. Con outbox + Celery, la
subida responde 201 siempre y el sistema entrega cuando EFC vuelve, sin
duplicar: el crm_document_ref viaja con la subida y EFC devuelve 200 con el
documento que ya existia en vez de crear otro. Es lo que cubre el timeout
ambiguo -EFC commiteo y contesto tarde-, donde el CRM no puede saber si entro.
TRES DESVIACIONES DELIBERADAS DEL ORIGINAL, las tres con su razon en el codigo:
1. SAVEPOINT en vez de db.rollback() en el except del encolado. En Anexo22 el
outbox vive en OTRA base que el pedimento, asi que su rollback solo revertia
la sesion del outbox. El CRM es mono-base y el encolado corre DENTRO de la
transaccion del usuario: heredar ese rollback tumbaba la solicitud y el
expediente recien creados -exactamente lo contrario de best-effort, y en
silencio-. Lo destapo un test y asi se manifestaba:
"InvalidRequestError: Instance '<ServiceRequest>' is not persistent within
this Session". Con el savepoint el fallo deshace solo la fila del outbox.
2. source_table junto a source_id en la guarda _ya_entregado. El CRM tiene DOS
tablas de documentos con secuencias independientes: crm.documents.id = 5 y
ops.shipment_documents.id = 5 son documentos distintos. Con el id solo, haber
entregado el primero haria que el segundo se saltara para siempre sin un solo
error visible. Hay test que lo fija.
3. EFC_UPLOAD_TIMEOUT_MS aparte de EFC_API_TIMEOUT_MS. Los 8 s de los metadatos
no alcanzan para un archivo de 25 MB, y el timeout debe quedar POR DEBAJO del
proxy_read_timeout del nginx de EFC: si el CRM esperara mas, veria un 504
opaco sin saber si el documento entro.
El cliente HTTP llega con las pruebas que el carril de referencia NO tiene
-verificado: en Anexo22 no hay ni un test de EfcClient._request-, asi que alli
el bucle de reintentos, el backoff y el corte en 4xx nunca se ejercitan. Ese
hueco no se clona: 16 casos contra httpx.MockTransport, sin tocar la red.
Las migraciones se GENERAN y se dejan SIN aplicar.
BLOQUEADO: docker-compose.prod.yml no se toco. El ticket pide las 8 variables en
api, worker y beat, pero Orquestacion.md 13.14 y 4.5 lo prohiben expresamente
("ni tocarlo"), y el orquestador manda sobre el ticket. Queda como paso manual
en el reporte; sin el, worker y beat no ven EFC_API_URL y el carril queda
apagado en produccion, que es degradar limpio y no romper.
Verificacion: pytest tests/ EXIT=0, 151 passed 1 skipped (baseline 70 passed).
Cuatro roturas deliberadas y restauradas: quitando source_table de la guarda el
test de la ambiguedad se puso rojo; reintentando los 4xx los tres tests del
corte dieron "assert 3 == 1"; borrando el objeto local antes de subir cayeron
los tres del corte directo; y disparando el ensure por cualquier 404 se rompio
el test del code.
Ticket: T2026-08-046 (fase 6 de 7)
169 lines
6.5 KiB
Python
169 lines
6.5 KiB
Python
"""
|
|
Configuración centralizada de la aplicación usando Pydantic Settings
|
|
"""
|
|
|
|
from typing import List, Literal
|
|
from pydantic import field_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Configuración de la aplicación"""
|
|
|
|
# Application
|
|
APP_NAME: str = "Mi Aplicación"
|
|
# Sobreescribible con APP_VERSION (Dockerfile/Jenkins: build-arg + ENV) o entorno en runtime
|
|
APP_VERSION: str = "dev-local"
|
|
DEBUG: bool = True
|
|
ENVIRONMENT: str = "development"
|
|
|
|
# Auth local para desarrollo (sin Keycloak/Hub)
|
|
# Nunca activar en producción.
|
|
DEV_LOCAL_AUTH: bool = False
|
|
DEV_LOCAL_AUTH_EMAIL: str = "dev@local.test"
|
|
DEV_LOCAL_AUTH_NAME: str = "Dev User"
|
|
DEV_LOCAL_AUTH_TENANT_ID: int = 1
|
|
DEV_LOCAL_AUTH_COMPANY_ID: int = 1
|
|
|
|
# Database - Core (Shared)
|
|
CORE_DB_HOST: str = "postgres"
|
|
CORE_DB_PORT: int = 5432
|
|
CORE_DB_NAME: str = "app_core"
|
|
CORE_DB_USER: str = "postgres"
|
|
CORE_DB_PASSWORD: str = "postgres"
|
|
|
|
# Security
|
|
SECRET_KEY: str = "change-this-secret-key-in-production"
|
|
ALGORITHM: str = "HS256"
|
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
|
|
# Valkey / Redis
|
|
VALKEY_URL: str = "redis://valkey:6379/0"
|
|
PERMISSION_CACHE_ENABLED: bool = True
|
|
PERMISSION_CACHE_TTL_SECONDS: int = 300
|
|
|
|
# Synchronization
|
|
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
|
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
|
SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only)
|
|
|
|
# CORS
|
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
|
|
|
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
|
HUB_URL: str = "http://localhost:8001"
|
|
# Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil)
|
|
HUB_API_BASE_URL: str = ""
|
|
HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000
|
|
# Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak)
|
|
HUB_ADMIN_EMAIL: str = ""
|
|
HUB_ADMIN_PASSWORD: str = ""
|
|
|
|
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
|
APP_PUBLIC_URL: str = "http://localhost:3000"
|
|
|
|
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", "EFC_API_URL", mode="before")
|
|
@classmethod
|
|
def strip_quotes(cls, v: str) -> str:
|
|
if v and isinstance(v, str):
|
|
v = v.strip().strip('"').strip("'")
|
|
# Evitar que solo espacios en .env se conviertan en "/" (rompe httpx: falta protocolo).
|
|
if not v:
|
|
return ""
|
|
if not v.endswith("/"):
|
|
v += "/"
|
|
return v
|
|
return v
|
|
|
|
# External APIs
|
|
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
|
|
COVE_API_URL: str = "https://api.vu.aduanasoft.com"
|
|
COVE_API_VERIFY_SSL: bool = False
|
|
COVE_FIEL_HASH_KEY: str = ""
|
|
COVE_FIEL_HASH_IV: str = ""
|
|
SITAR_API_USER: str = ""
|
|
SITAR_API_PASSWORD: str = ""
|
|
# SMTP Email Configuration
|
|
SMTP_HOST: str = "smtp.gmail.com"
|
|
SMTP_PORT: int = 587
|
|
SMTP_USER: str = ""
|
|
SMTP_PASSWORD: str = ""
|
|
SMTP_FROM_NAME: str = "Mi Aplicación"
|
|
SMTP_USE_TLS: bool = True
|
|
|
|
# CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis
|
|
CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio"
|
|
S3_ENDPOINT_URL: str = "http://minio:9000"
|
|
S3_ACCESS_KEY: str = ""
|
|
S3_SECRET_KEY: str = ""
|
|
S3_BUCKET: str = "app"
|
|
S3_REGION: str = "us-east-1"
|
|
S3_USE_SSL: bool = False
|
|
# Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis)
|
|
S3_FILE_STORAGE: bool = True
|
|
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
|
|
|
# ── EFC (expediente electrónico) ────────────────────────────────────────────────────────
|
|
# Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC.
|
|
# Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC: inventar
|
|
# otros obligaría a quien opera los dos sistemas a recordar dos juegos de variables para
|
|
# exactamente lo mismo.
|
|
# EFC_API_URL vacía = integración APAGADA. Todo el enganche es best-effort y hace no-op:
|
|
# el CRM sigue funcionando igual, guardando los archivos solo en su MinIO.
|
|
EFC_API_URL: str = ""
|
|
EFC_API_KEY: str = "" # == CRM_INTEGRATION_API_KEY del lado de EFC
|
|
EFC_API_VERIFY_SSL: bool = True
|
|
EFC_API_TIMEOUT_MS: int = 8000 # metadatos: resolver, ingest, completar
|
|
# Las subidas van aparte: 8 s no alcanzan para un archivo de 25 MB. Debe quedar POR DEBAJO
|
|
# del proxy_read_timeout del nginx de EFC (ver core/efc_client.py).
|
|
EFC_UPLOAD_TIMEOUT_MS: int = 55000
|
|
# Scaffolding de mTLS: activarlo es configuración, no código.
|
|
EFC_MTLS_CA_PATH: str = ""
|
|
EFC_MTLS_CERT_PATH: str = ""
|
|
EFC_MTLS_KEY_PATH: str = ""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_file=[".env", "../.env"],
|
|
case_sensitive=True,
|
|
extra="ignore",
|
|
env_file_encoding="utf-8",
|
|
)
|
|
|
|
@property
|
|
def core_database_url(self) -> str:
|
|
"""URL de conexión a la base de datos core"""
|
|
return f"postgresql://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
|
|
|
|
@property
|
|
def async_core_database_url(self) -> str:
|
|
"""URL de conexión asíncrona a la base de datos core"""
|
|
return f"postgresql+asyncpg://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
|
|
|
|
@property
|
|
def cors_origins_list(self) -> List[str]:
|
|
"""Lista de orígenes CORS permitidos"""
|
|
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
|
|
|
@property
|
|
def use_s3_object_storage(self) -> bool:
|
|
"""
|
|
Usar MinIO para logos, certificados y Help (mismo bucket que CSV).
|
|
True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo.
|
|
"""
|
|
return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE
|
|
|
|
@property
|
|
def hub_api_base_url(self) -> str:
|
|
"""
|
|
Base URL para endpoints /v1 del Workspace/Hub.
|
|
Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api.
|
|
"""
|
|
custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/")
|
|
if custom:
|
|
return custom
|
|
return f"{self.HUB_URL.rstrip('/')}/api"
|
|
|
|
|
|
# Instancia global de configuración
|
|
settings = Settings()
|