Merge branch 'development' into feature/alembic-daf
This commit is contained in:
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
80
backend/core/workspace_profile_client.py
Normal file
80
backend/core/workspace_profile_client.py
Normal 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")
|
||||
114
backend/core/workspace_profile_sync.py
Normal file
114
backend/core/workspace_profile_sync.py
Normal 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,
|
||||
},
|
||||
)
|
||||
2
backend/tests/fixtures/builders.py
vendored
2
backend/tests/fixtures/builders.py
vendored
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user