Merge branch 'development' into feature/alembic-daf

This commit is contained in:
2026-05-12 09:23:19 -06:00
57 changed files with 2063 additions and 944 deletions

View File

@@ -0,0 +1,56 @@
"""add workspace profile fields to user_tenants
Revision ID: c3d4e5f6a7b
Revises: b2c3d4e5f6a7
Create Date: 2026-05-08 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c3d4e5f6a7b"
down_revision: Union[str, None] = "ca7d3c4e8b2a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"user_tenants",
sa.Column(
"workspace_user_id",
sa.String(length=255),
nullable=True,
comment="User ID (sub) proveniente de Workspace",
),
schema="core",
)
op.add_column(
"user_tenants",
sa.Column(
"workspace_avatar_url",
sa.String(length=500),
nullable=True,
comment="Avatar URL sincronizado desde Workspace",
),
schema="core",
)
op.add_column(
"user_tenants",
sa.Column(
"workspace_profile_synced_at",
sa.DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
),
schema="core",
)
def downgrade() -> None:
op.drop_column("user_tenants", "workspace_profile_synced_at", schema="core")
op.drop_column("user_tenants", "workspace_avatar_url", schema="core")
op.drop_column("user_tenants", "workspace_user_id", schema="core")

View File

@@ -138,7 +138,7 @@ def review_classes(
3a. If no class errors: validate fractions for ALL lines.
3b. If class errors exist: add a CLASE error per invalid line and validate
fractions only for those lines.
4. If the invoice has not been reviewed by the company, flag lines whose
4. If invoice logistics does not mark equipment_reviewed, flag lines whose
class requires physical review (Class.physical_review == 1).
"""
# 1. Lines whose class is not assigned / not found in the catalog
@@ -178,7 +178,7 @@ def review_classes(
_validate_line_fraction(db, line, errors)
# 4. Physical review check
if not invoice.compliance_mx.was_reviewed_by_company:
if not (invoice.logistics and invoice.logistics.equipment_reviewed):
review_required_lines = (
db.query(LineItem)
.join(Class, LineItem.class_id == Class.id)
@@ -201,7 +201,7 @@ def review_classes(
),
solution=[
f"Revisar el Equipo de la Partida: {line.line_number} "
"y Asignar Como Revisado a Nivel Factura."
"y marcar Equipo revisado en Continuación (logística) a nivel factura."
],
code="CLASE",
)

View File

@@ -3,6 +3,7 @@ Carga de conjuntos FK para validación de import CSV de transportistas.
Clarion: GTransportista (ClaveTrans), GPaises (Pais_Ame), GEstados (Descripcion), relación Estado-País.
"""
from typing import Set, Tuple, Optional
from sqlalchemy.orm import Session
import logging
from core.database import CoreSessionLocal
@@ -13,6 +14,7 @@ logger = logging.getLogger(__name__)
def load_transportistas_fk_sets(
tenant_id: Optional[int] = None,
company_id: Optional[int] = None,
db: Optional[Session] = None,
) -> Tuple[
Set[str],
Set[str],
@@ -32,50 +34,56 @@ def load_transportistas_fk_sets(
state_descriptions_upper: Set[str] = set()
state_country_set: Set[Tuple[str, str]] = set()
try:
with CoreSessionLocal() as session:
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.states.models import State
def _load(session: Session):
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.states.models import State
if tenant_id is not None and company_id is not None:
for row in (
session.query(Transporter.transporter_key)
.filter(
Transporter.tenant_id == tenant_id,
Transporter.company_id == company_id,
)
.all()
):
if row[0] and (row[0] or "").strip():
existing_transporter_keys.add((row[0] or "").strip().upper())
for row in session.query(Country.ame_key).all():
if row[0]:
valid_country_ame.add((row[0] or "").strip().upper())
for state in session.query(State).all():
country = (
session.query(Country)
.filter(Country.m3_key == state.m3_key)
.first()
if tenant_id is not None and company_id is not None:
for row in (
session.query(Transporter.transporter_key)
.filter(
Transporter.tenant_id == tenant_id,
Transporter.company_id == company_id,
)
ame = None
if country and (country.ame_key or "").strip():
ame = (country.ame_key or "").strip().upper()
.all()
):
if row[0] and (row[0] or "").strip():
existing_transporter_keys.add((row[0] or "").strip().upper())
desc = (state.description or "").strip()
if desc:
state_descriptions_upper.add(desc.upper())
if ame:
state_country_set.add((ame, desc.upper()))
for row in session.query(Country.ame_key).all():
if row[0]:
valid_country_ame.add((row[0] or "").strip().upper())
mex_key = (state.mex_key or "").strip()
if mex_key:
mk = mex_key.upper()
state_descriptions_upper.add(mk)
if ame:
state_country_set.add((ame, mk))
for state in session.query(State).all():
country = (
session.query(Country)
.filter(Country.m3_key == state.m3_key)
.first()
)
ame = None
if country and (country.ame_key or "").strip():
ame = (country.ame_key or "").strip().upper()
desc = (state.description or "").strip()
if desc:
state_descriptions_upper.add(desc.upper())
if ame:
state_country_set.add((ame, desc.upper()))
mex_key = (state.mex_key or "").strip()
if mex_key:
mk = mex_key.upper()
state_descriptions_upper.add(mk)
if ame:
state_country_set.add((ame, mk))
try:
if db:
_load(db)
else:
with CoreSessionLocal() as session:
_load(session)
except Exception as e:
logger.warning("Transportistas import: could not load FK sets: %s", e)

View File

@@ -37,24 +37,31 @@ def validate_row_transporter(
clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper()
use_partial = actualizar and bool(clave and clave in existing)
if not use_partial and clave and clave in existing:
errors.append({
"line": line_num,
"col": "CLAVE TRANSPORTISTA",
"msg": f"La clave '{row.get('CLAVE TRANSPORTISTA')}' ya existe en el catálogo."
})
if use_partial:
errors.extend(
valida_parcial_transportistas(
row,
line_num,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
row,
line_num,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
)
else:
errors.extend(
valida_toda_transportistas(
row,
line_num,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
row,
line_num,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
)
return errors

View File

@@ -277,6 +277,7 @@ def transporter_model_to_row(t) -> Dict[str, Any]:
def validate_transporter_row_for_api(
db: Session,
tenant_id: int,
company_id: int,
row: Dict[str, Any],
@@ -292,7 +293,7 @@ def validate_transporter_row_for_api(
valid_country_ame,
state_descriptions_upper,
state_country_set,
) = load_transportistas_fk_sets(tenant_id, company_id)
) = load_transportistas_fk_sets(tenant_id, company_id, db=db)
clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper()
# existing set from loader is uppercased keys for this company

View File

@@ -104,6 +104,7 @@ class TransporterService:
"""Create a new transporter"""
data = transporter_data.model_dump()
validate_transporter_row_for_api(
db,
tenant_id,
company_id,
transporter_fields_to_csv_row(data),
@@ -163,6 +164,7 @@ class TransporterService:
}
merged.update(update_data)
validate_transporter_row_for_api(
db,
tenant_id,
company_id,
transporter_fields_to_csv_row(merged),

View File

@@ -63,6 +63,7 @@ class UserInfoResponseDTO(BaseModel):
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
avatar_url: Optional[str] = None
roles: list[str] = []
permissions: list[str] = []

View File

@@ -1,6 +1,7 @@
import logging
import httpx
from typing import Any, Dict
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
from fastapi import HTTPException
@@ -23,6 +24,96 @@ class AuthService:
def __init__(self, db: Session):
self.db = db
@staticmethod
def _clean_text(value: Any) -> Optional[str]:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return None
def _pick_text(self, *candidates: Any) -> Optional[str]:
for candidate in candidates:
value = self._clean_text(candidate)
if value:
return value
return None
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
try:
claims = jwt.get_unverified_claims(access_token)
return claims if isinstance(claims, dict) else {}
except JWTError:
return {}
except Exception:
return {}
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Fallback de datos de usuario consultando el Hub admin API.
Es opcional y no debe romper /me si falla.
"""
if not keycloak_user_id:
return None
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
return None
admin_token = login_resp.json().get("access_token")
if not admin_token:
return None
user_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
if user_resp.status_code == 200:
payload = user_resp.json()
return payload if isinstance(payload, dict) else None
except Exception as exc:
logger.debug("kc_admin_user_lookup_failed: %s", exc)
return None
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
for source in sources:
if not isinstance(source, dict):
continue
direct = self._pick_text(
source.get("avatar_url"),
source.get("avatarUrl"),
source.get("picture"),
source.get("photo"),
)
if direct:
return direct
attrs = source.get("attributes")
if isinstance(attrs, dict):
attr_candidate = attrs.get("avatar_url")
if isinstance(attr_candidate, list) and attr_candidate:
value = self._clean_text(attr_candidate[0])
if value:
return value
if isinstance(attr_candidate, str):
value = self._clean_text(attr_candidate)
if value:
return value
return None
async def login(
self,
login_data: LoginRequestDTO,
@@ -55,6 +146,38 @@ class AuthService:
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "login",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
@@ -102,7 +225,37 @@ class AuthService:
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
@@ -115,9 +268,78 @@ class AuthService:
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
from core.workspace_profile_sync import sync_workspace_profile_for_user
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
return UserInfoResponseDTO(**user_info)
kc_user = self._decode_kc_user_from_token(access_token)
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
needs_admin_fallback = any(
not self._clean_text(user_info.get(field))
for field in ("email", "preferred_username")
) or self._extract_avatar_url(user_info) is None
kc_admin_user = None
if needs_admin_fallback:
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
first_name = self._pick_text(
user_info.get("first_name"),
user_info.get("given_name"),
kc_user.get("given_name"),
kc_user.get("first_name"),
(kc_admin_user or {}).get("firstName"),
(kc_admin_user or {}).get("first_name"),
)
last_name = self._pick_text(
user_info.get("last_name"),
user_info.get("family_name"),
kc_user.get("family_name"),
kc_user.get("last_name"),
(kc_admin_user or {}).get("lastName"),
(kc_admin_user or {}).get("last_name"),
)
full_name = self._pick_text(
f"{first_name} {last_name}" if first_name and last_name else None,
first_name,
last_name,
)
enriched_user_info = dict(user_info)
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
enriched_user_info["email"] = self._pick_text(
user_info.get("email"),
(kc_admin_user or {}).get("email"),
kc_user.get("email"),
)
enriched_user_info["preferred_username"] = self._pick_text(
user_info.get("preferred_username"),
user_info.get("username"),
kc_user.get("preferred_username"),
kc_user.get("username"),
(kc_admin_user or {}).get("username"),
)
enriched_user_info["avatar_url"] = self._extract_avatar_url(
user_info,
kc_user,
kc_admin_user or {},
)
enriched_user_info["name"] = self._pick_text(
user_info.get("name"),
full_name,
kc_user.get("name"),
enriched_user_info.get("preferred_username"),
)
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=enriched_user_info.get("sub"),
tenant_id=enriched_user_info.get("tenant_id"),
workspace_profile=enriched_user_info,
)
return UserInfoResponseDTO(**enriched_user_info)
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""

View File

@@ -2,6 +2,7 @@
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -13,6 +14,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
DateTime,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -56,6 +58,17 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="URL de la imagen de perfil"
)
workspace_user_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
)
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
)
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
)
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
# se sincroniza al editar perfil desde Anexo76)
first_name: Mapped[Optional[str]] = mapped_column(

View File

@@ -154,6 +154,7 @@ def get_user_avatar_image(
@router.get("/me/profile", response_model=UserResponseDTO)
async def get_my_profile(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
@@ -180,7 +181,17 @@ async def get_my_profile(
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return await service.get_current_user_profile(keycloak_user_id, current_user=current_user)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
return await service.get_current_user_profile(
keycloak_user_id,
current_user=current_user,
access_token=access_token,
)
@router.put("/me/profile", response_model=UserResponseDTO)
@@ -214,6 +225,14 @@ async def update_my_profile(
status_code=400, detail="User does not belong to any tenant"
)
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
# y no debe mutarse localmente en Anexo76.
if current_user.get("sub"):
raise HTTPException(
status_code=409,
detail="Avatar is managed by Workspace for this user",
)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()

View File

@@ -2,6 +2,7 @@ import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import and_, func
@@ -15,6 +16,30 @@ from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
if not user_tenant or not user_tenant.avatar_url:
return None
avatar_out = str(user_tenant.avatar_url)
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
return avatar_out if _is_valid_http_url(avatar_out) else None
from core.s3_keys import public_user_avatar_api_path
# Entregamos siempre el endpoint público del backend para assets locales/S3.
return public_user_avatar_api_path(
user_tenant.tenant_id,
user_tenant.keycloak_user_id,
)
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
@@ -43,18 +68,20 @@ def _normalize_user(
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
avatar_out = user_tenant.avatar_url
if avatar_out:
from core.s3_keys import public_user_avatar_api_path
workspace_avatar = (
user_tenant.workspace_avatar_url
if _is_valid_http_url(user_tenant.workspace_avatar_url)
else None
)
legacy_avatar = _legacy_avatar_public_url(user_tenant)
avatar_out = workspace_avatar or legacy_avatar
# Siempre devolver la URL pública del endpoint de servicio de imágenes,
# independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...).
avatar_out = public_user_avatar_api_path(
user_tenant.tenant_id, user_tenant.keycloak_user_id
)
normalized.update(
{
"avatar_url": avatar_out,
"workspace_avatar_url": workspace_avatar,
"legacy_avatar_url": legacy_avatar,
"workspace_user_id": user_tenant.workspace_user_id,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
@@ -629,11 +656,27 @@ class UserService:
"usage_percentage": round(usage_percentage, 2),
}
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
async def get_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
user_info = current_user or {"id": keycloak_user_id}
# Perfil "me": sincronización con cache corto (5 min).
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=keycloak_user_id,
tenant_id=self.tenant_id,
)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()

View File

@@ -22,12 +22,16 @@ async def list_states(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
search: str = Query(None, description="Término de búsqueda"),
country_id: str = Query(None, description="Filtrar por país (m3_key)"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
skip = (page - 1) * page_size
query = db.query(State)
if country_id:
query = query.filter(State.m3_key == country_id)
if search:
search_filter = f"%{search}%"
query = query.filter(

View File

@@ -41,6 +41,9 @@ class Settings(BaseSettings):
# 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 = ""
@@ -48,7 +51,7 @@ class Settings(BaseSettings):
# 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", mode="before")
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
@classmethod
def strip_quotes(cls, v: str) -> str:
if v and isinstance(v, str):
@@ -119,6 +122,17 @@ class Settings(BaseSettings):
"""
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()

View File

@@ -344,6 +344,21 @@ async def get_current_user(
)
user_info["tenant_id"] = effective_tenant_id
# Rehidratación de sesión: sincronización no bloqueante de avatar/perfil
# con cache corto para evitar llamadas excesivas al Hub.
try:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
db,
access_token=token,
keycloak_user_id=user_info.get("sub"),
tenant_id=user_info.get("tenant_id"),
workspace_profile=user_info,
)
except Exception as exc:
logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc)
return user_info

View File

@@ -0,0 +1,80 @@
import asyncio
import logging
from typing import Any, Optional
import httpx
from core.config import settings
logger = logging.getLogger(__name__)
class WorkspaceProfileClient:
"""Cliente para consultar perfil del usuario en Workspace Hub (/v1/auth/me)."""
def __init__(
self,
base_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
retries: int = 2,
transport: Optional[httpx.BaseTransport] = None,
):
self.base_url = (base_url or settings.hub_api_base_url).rstrip("/")
self.timeout_s = max(0.1, float(timeout_ms or settings.HUB_PROFILE_SYNC_TIMEOUT_MS) / 1000.0)
self.retries = max(0, int(retries))
self.transport = transport
async def get_me(self, access_token: str) -> dict[str, Any]:
if not access_token:
raise ValueError("access_token is required")
headers = {"Authorization": f"Bearer {access_token}"}
url = f"{self.base_url}/v1/auth/me"
last_error: Optional[Exception] = None
for attempt in range(self.retries + 1):
try:
async with httpx.AsyncClient(
timeout=self.timeout_s,
transport=self.transport,
) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid workspace profile payload")
return payload
if response.status_code in (401, 403, 404):
# Errores de autenticación/autorización o endpoint no disponible:
# no vale la pena reintentar.
raise httpx.HTTPStatusError(
f"Workspace profile request failed with status {response.status_code}",
request=response.request,
response=response,
)
# Reintentar solo para errores transitorios 5xx.
if response.status_code >= 500 and attempt < self.retries:
await asyncio.sleep(0.15 * (attempt + 1))
continue
raise httpx.HTTPStatusError(
f"Workspace profile request failed with status {response.status_code}",
request=response.request,
response=response,
)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_error = exc
if attempt >= self.retries:
break
await asyncio.sleep(0.15 * (attempt + 1))
except Exception as exc:
last_error = exc
break
if last_error:
raise last_error
raise RuntimeError("Workspace profile request failed")

View File

@@ -0,0 +1,114 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from api.v1.modules.core.user_tenant.models import UserTenant
from core.workspace_profile_client import WorkspaceProfileClient
logger = logging.getLogger(__name__)
SYNC_TTL_SECONDS = 300
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _is_fresh(ts: Optional[datetime], ttl_seconds: int = SYNC_TTL_SECONDS) -> bool:
if not ts:
return False
now = datetime.now(timezone.utc)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts >= (now - timedelta(seconds=ttl_seconds))
async def sync_workspace_profile_for_user(
db: Session,
*,
access_token: Optional[str],
keycloak_user_id: Optional[str],
tenant_id: Optional[int] = None,
company_id: Optional[int] = None,
workspace_profile: Optional[dict[str, Any]] = None,
force: bool = False,
) -> None:
"""
Sincroniza sub/avatar_url desde Workspace hacia core.user_tenants.
Nunca lanza excepción para no bloquear login ni requests autenticados.
"""
if not access_token or not keycloak_user_id:
return
try:
query = db.query(UserTenant).filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
if tenant_id is not None:
query = query.filter(UserTenant.tenant_id == int(tenant_id))
if company_id is not None:
query = query.filter(UserTenant.company_id == int(company_id))
target = query.first()
if not target:
return
if not force and _is_fresh(target.workspace_profile_synced_at):
return
payload = workspace_profile
if payload is None:
client = WorkspaceProfileClient()
payload = await client.get_me(access_token)
workspace_sub = payload.get("sub")
if not workspace_sub:
logger.warning(
"workspace_profile_sync_warning",
extra={
"event": "workspace_profile_sync_warning",
"reason": "missing_sub",
"keycloak_user_id": keycloak_user_id,
"tenant_id": target.tenant_id,
},
)
return
avatar_url = payload.get("avatar_url")
sanitized_avatar = avatar_url.strip() if isinstance(avatar_url, str) else None
if sanitized_avatar and not _is_valid_http_url(sanitized_avatar):
logger.warning(
"workspace_profile_sync_warning",
extra={
"event": "workspace_profile_sync_warning",
"reason": "invalid_avatar_url",
"keycloak_user_id": keycloak_user_id,
"tenant_id": target.tenant_id,
},
)
sanitized_avatar = None
target.workspace_user_id = str(workspace_sub)
target.workspace_avatar_url = sanitized_avatar
target.workspace_profile_synced_at = datetime.now(timezone.utc)
db.add(target)
db.commit()
except Exception as exc:
db.rollback()
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"error": str(exc),
"keycloak_user_id": keycloak_user_id,
"tenant_id": tenant_id,
"company_id": company_id,
},
)

View File

@@ -343,6 +343,7 @@ def create_import_invoice_with_line(
tenant_id=tenant_id,
company_id=company_id,
weight_type="kgs",
equipment_reviewed=True,
)
db.add(invoice)
db.flush()
@@ -413,6 +414,7 @@ def create_export_invoice_with_line(
tenant_id=tenant_id,
company_id=company_id,
weight_type="kgs",
equipment_reviewed=True,
)
db.add(invoice)
db.flush()