Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.
La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:
- crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
- crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
expediente_ref -> crm.cases.id;
- core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
clonados del gateway Anexo22 -> EFC que ya corre en produccion;
- las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.
Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.
Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.
El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.
PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
7.3 KiB
Python
182 lines
7.3 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
|
|
|
|
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
|
|
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
|
|
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
|
|
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
|
|
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
|
|
#
|
|
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
|
|
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
|
|
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
|
|
SESSION_STORE_ENABLED: bool = False
|
|
SESSION_IDLE_MINUTES: int = 30
|
|
SESSION_MAX_HOURS: int = 10
|
|
|
|
# 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()
|