Merge pull request 'feature/pre-liberation' (#362) from feature/pre-liberation into development
Reviewed-on: ADUANASOFT/anexo76#362
This commit is contained in:
@@ -13,8 +13,12 @@ from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import public_user_avatar_api_path, user_avatar_key
|
||||
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from core.security import (
|
||||
get_current_user,
|
||||
resolve_hub_tenant_id_for_api,
|
||||
validate_access_to_resource,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -38,6 +42,7 @@ _AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
@router.get("/stats", response_model=UserStatsDTO)
|
||||
async def get_user_statistics(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -47,12 +52,25 @@ async def get_user_statistics(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
return service.get_user_stats() # Este no es async en service.py
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
return service.get_user_stats(
|
||||
access_token=token or None,
|
||||
hub_tenant_id=hub_tid,
|
||||
x_tenant_override=request.headers.get("X-Tenant-Override"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponseDTO)
|
||||
async def list_users(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Número de página"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
|
||||
@@ -65,7 +83,23 @@ async def list_users(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
result = await service.get_tenant_users(page=page, page_size=page_size, search=search)
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
result = await service.get_tenant_users(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
access_token=token,
|
||||
hub_tenant_id=hub_tid,
|
||||
x_tenant_override=request.headers.get("X-Tenant-Override"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -180,85 +180,170 @@ class UserService:
|
||||
raise e
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
async def _fetch_hub_tenant_users_with_info(
|
||||
self,
|
||||
access_token: str,
|
||||
hub_tenant_id: int,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants)."""
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info"
|
||||
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
|
||||
if x_tenant_override and str(x_tenant_override).strip():
|
||||
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="No autorizado en el Hub")
|
||||
if response.status_code == 403:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub"
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Hub users-with-info error status=%s body=%s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="No se pudo obtener el catálogo de usuarios desde el Hub",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
if not isinstance(data, list):
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Respuesta inválida del Hub al listar usuarios"
|
||||
)
|
||||
return data
|
||||
|
||||
async def get_tenant_users(
|
||||
self, page: int = 1, page_size: int = 20, search: Optional[str] = None
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: Optional[str] = None,
|
||||
*,
|
||||
access_token: str,
|
||||
hub_tenant_id: int,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Obtiene todos los usuarios del tenant con paginación
|
||||
|
||||
Args:
|
||||
page: Número de página (1-indexed)
|
||||
page_size: Tamaño de página
|
||||
search: Término de búsqueda (busca en username, email, nombre, teléfono y bio)
|
||||
|
||||
Returns:
|
||||
Dict con usuarios y metadatos de paginación
|
||||
Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y
|
||||
perfil extendido desde BD local (user_tenants / user_company_roles).
|
||||
"""
|
||||
try:
|
||||
from ..permissions.models import UserCompanyRole, CompanyRole
|
||||
from ..permissions.models import UserCompanyRole
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
# Obtener relaciones usuario-tenant
|
||||
query = self.db.query(UserTenant).filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
|
||||
if not access_token or not hub_tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token o tenant Hub requerido para listar usuarios",
|
||||
)
|
||||
|
||||
hub_rows = await self._fetch_hub_tenant_users_with_info(
|
||||
access_token, hub_tenant_id, x_tenant_override
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
|
||||
# Calcular offset
|
||||
offset = (page - 1) * page_size
|
||||
user_tenants = query.offset(offset).limit(page_size).all()
|
||||
|
||||
# Obtener roles de usuarios en la compañía actual
|
||||
user_roles_query = self.db.query(UserCompanyRole).options(
|
||||
joinedload(UserCompanyRole.company_role)
|
||||
).filter(
|
||||
and_(
|
||||
UserCompanyRole.company_id == self.company_id,
|
||||
UserCompanyRole.tenant_id == self.tenant_id,
|
||||
UserCompanyRole.is_active == True
|
||||
user_roles_query = (
|
||||
self.db.query(UserCompanyRole)
|
||||
.options(joinedload(UserCompanyRole.company_role))
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyRole.company_id == self.company_id,
|
||||
UserCompanyRole.tenant_id == self.tenant_id,
|
||||
UserCompanyRole.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Crear un mapa de user_id -> lista de roles
|
||||
user_roles_map = {}
|
||||
user_roles_map: Dict[str, List[str]] = {}
|
||||
for user_role in user_roles_query.all():
|
||||
if user_role.user_id not in user_roles_map:
|
||||
user_roles_map[user_role.user_id] = []
|
||||
user_roles_map[user_role.user_id].append(user_role.company_role.name)
|
||||
uid = user_role.user_id
|
||||
if uid not in user_roles_map:
|
||||
user_roles_map[uid] = []
|
||||
user_roles_map[uid].append(user_role.company_role.name)
|
||||
|
||||
try:
|
||||
# En lugar de consultar Keycloak uno a uno (lento y sin API directa ahora),
|
||||
# devolvemos la info local mínima o consultamos un endpoint de "buscar varios" en el Hub si existiera.
|
||||
# Por ahora, minimizamos el impacto devolviendo lo que tenemos local.
|
||||
normalized_user = _normalize_user({
|
||||
"id": ut.keycloak_user_id,
|
||||
"username": "User", # Placeholder si no tenemos el dato local
|
||||
}, role_str, ut)
|
||||
local_by_kc = {
|
||||
ut.keycloak_user_id: ut
|
||||
for ut in self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.company_id == self.company_id,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
}
|
||||
|
||||
users.append(normalized_user)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing user {ut.keycloak_user_id}: {e}")
|
||||
needle = (search or "").strip().lower()
|
||||
filtered: List[Dict[str, Any]] = []
|
||||
for u in hub_rows:
|
||||
if not u.get("is_active", True):
|
||||
continue
|
||||
kc = u.get("keycloak_user_id")
|
||||
if not kc:
|
||||
continue
|
||||
if needle:
|
||||
blob = " ".join(
|
||||
[
|
||||
str(u.get("email") or ""),
|
||||
str(u.get("username") or ""),
|
||||
str(u.get("first_name") or ""),
|
||||
str(u.get("last_name") or ""),
|
||||
]
|
||||
).lower()
|
||||
ut_loc = local_by_kc.get(kc)
|
||||
if ut_loc:
|
||||
blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower()
|
||||
if needle not in blob:
|
||||
continue
|
||||
filtered.append(u)
|
||||
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
total = len(filtered)
|
||||
offset = (page - 1) * page_size
|
||||
page_rows = filtered[offset : offset + page_size]
|
||||
|
||||
users: List[Dict[str, Any]] = []
|
||||
for u in page_rows:
|
||||
kc = u["keycloak_user_id"]
|
||||
role_names = user_roles_map.get(kc, [])
|
||||
role_str = ", ".join(role_names) if role_names else u.get("role")
|
||||
local_ut = local_by_kc.get(kc)
|
||||
normalized_user = _normalize_user(
|
||||
{
|
||||
"id": kc,
|
||||
"username": u.get("username") or "",
|
||||
"email": u.get("email") or "",
|
||||
"firstName": u.get("first_name") or "",
|
||||
"lastName": u.get("last_name") or "",
|
||||
"enabled": u.get("is_active", True),
|
||||
"emailVerified": False,
|
||||
},
|
||||
role_str,
|
||||
local_ut,
|
||||
)
|
||||
users.append(normalized_user)
|
||||
|
||||
total_pages = max(1, (total + page_size - 1) // page_size) if total else 1
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"total": len(users) if search else total,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tenant users: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error getting users: {str(e)}"
|
||||
)
|
||||
) from e
|
||||
|
||||
async def get_user(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Obtiene un usuario específico"""
|
||||
@@ -338,12 +423,8 @@ class UserService:
|
||||
logger.error(f"Error changing password: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error changing password")
|
||||
|
||||
def get_user_stats(self) -> Dict[str, Any]:
|
||||
"""Obtiene estadísticas de usuarios del tenant"""
|
||||
license = self._get_license()
|
||||
|
||||
# Contar usuarios activos e inactivos
|
||||
active_users = (
|
||||
def _count_active_user_tenants_local(self) -> int:
|
||||
return (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
and_(
|
||||
@@ -352,8 +433,72 @@ class UserService:
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
def get_user_stats(
|
||||
self,
|
||||
access_token: Optional[str] = None,
|
||||
hub_tenant_id: Optional[int] = None,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license)
|
||||
con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token;
|
||||
inactivos y fallback de conteos en BD local.
|
||||
"""
|
||||
max_users_allowed = 0
|
||||
hub_max_ok = False
|
||||
active_users = 0
|
||||
active_from_hub = False
|
||||
|
||||
if access_token and hub_tenant_id:
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
|
||||
if x_tenant_override and str(x_tenant_override).strip():
|
||||
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
lic_resp = client.get(
|
||||
f"{base}/api/v1/auth/verify-license",
|
||||
headers=headers,
|
||||
)
|
||||
if lic_resp.status_code == 200:
|
||||
lic_body = lic_resp.json()
|
||||
if lic_body.get("valid") and lic_body.get("max_users") is not None:
|
||||
max_users_allowed = int(lic_body["max_users"])
|
||||
hub_max_ok = True
|
||||
|
||||
users_resp = client.get(
|
||||
f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info",
|
||||
headers=headers,
|
||||
)
|
||||
if users_resp.status_code == 200:
|
||||
payload = users_resp.json()
|
||||
if isinstance(payload, list):
|
||||
active_users = sum(
|
||||
1 for row in payload if row.get("is_active", True)
|
||||
)
|
||||
active_from_hub = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Hub users-with-info stats: respuesta no lista"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Hub users-with-info stats status=%s",
|
||||
users_resp.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Hub stats (verify-license / users-with-info): %s", e)
|
||||
|
||||
if not hub_max_ok:
|
||||
license = self._get_license()
|
||||
max_users_allowed = license.max_users
|
||||
|
||||
if not active_from_hub:
|
||||
active_users = self._count_active_user_tenants_local()
|
||||
|
||||
inactive_users = (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
@@ -363,19 +508,20 @@ class UserService:
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
total_users = active_users + inactive_users
|
||||
users_available = max(0, license.max_users - active_users)
|
||||
users_available = max(0, max_users_allowed - active_users)
|
||||
usage_percentage = (
|
||||
(active_users / license.max_users * 100) if license.max_users > 0 else 0
|
||||
(active_users / max_users_allowed * 100) if max_users_allowed > 0 else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"inactive_users": inactive_users,
|
||||
"max_users_allowed": license.max_users,
|
||||
"max_users_allowed": max_users_allowed,
|
||||
"users_available": users_available,
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ _synced_tenant_ids: Set[int] = set()
|
||||
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
||||
# (mismo slug, diferente id).
|
||||
_tenant_id_aliases: Dict[int, int] = {}
|
||||
# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub.
|
||||
_tenant_id_hub_by_local: Dict[int, int] = {}
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
@@ -68,36 +70,173 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
|
||||
raise HTTPException(status_code=401, detail="Authentication error")
|
||||
|
||||
|
||||
def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> None:
|
||||
def _ensure_user_tenant_for_company(
|
||||
db: Session, keycloak_user_id: str, tenant_id: int, company_id: int
|
||||
) -> None:
|
||||
"""Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant)."""
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
existing = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
db.commit()
|
||||
return
|
||||
db.add(
|
||||
UserTenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_company_exists(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_name: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Garantiza que exista al menos una empresa en a76.company para el tenant.
|
||||
El tenant IS la empresa — se crea automáticamente al primer login.
|
||||
Garantiza al menos una empresa en a76.company para el tenant.
|
||||
|
||||
Primera vez: CompanyService.create_company_manually (seed catálogos por compañía),
|
||||
relación user_tenants y bootstrap_super_admin para el usuario del token.
|
||||
"""
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.general_catalogs.company.dto import CompanyCreateDTO
|
||||
from api.v1.modules.a76.general_catalogs.company.service import CompanyService
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
kc = hub_user.get("sub") if hub_user else None
|
||||
|
||||
try:
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
exists = db.query(Company).filter(Company.tenant_id == tenant_id).first()
|
||||
if not exists:
|
||||
company = Company(tenant_id=tenant_id, name=tenant_name)
|
||||
db.add(company)
|
||||
company = db.query(Company).filter(Company.tenant_id == tenant_id).first()
|
||||
|
||||
if not company:
|
||||
svc = CompanyService(db)
|
||||
username = "System"
|
||||
if hub_user:
|
||||
username = (
|
||||
hub_user.get("preferred_username")
|
||||
or hub_user.get("email")
|
||||
or hub_user.get("name")
|
||||
or "System"
|
||||
)
|
||||
company = svc.create_company_manually(
|
||||
CompanyCreateDTO(name=tenant_name),
|
||||
tenant_id=tenant_id,
|
||||
username=username,
|
||||
)
|
||||
logger.info(
|
||||
"Empresa creada vía CompanyService para tenant id=%s name=%r company_id=%s",
|
||||
tenant_id,
|
||||
tenant_name,
|
||||
company.id,
|
||||
)
|
||||
|
||||
if not company:
|
||||
logger.warning(
|
||||
"No hay empresa para tenant %s tras intento de creación automática", tenant_id
|
||||
)
|
||||
return
|
||||
|
||||
if company.name != tenant_name:
|
||||
company.name = tenant_name
|
||||
db.commit()
|
||||
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
|
||||
elif exists.name != tenant_name:
|
||||
exists.name = tenant_name
|
||||
db.commit()
|
||||
logger.info(f"Empresa actualizada para tenant id={tenant_id}: '{tenant_name}'")
|
||||
logger.info("Empresa actualizada para tenant id=%s: '%s'", tenant_id, tenant_name)
|
||||
|
||||
if kc:
|
||||
ut = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == kc,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.company_id == company.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ut:
|
||||
_ensure_user_tenant_for_company(db, kc, tenant_id, company.id)
|
||||
PermissionService(db).bootstrap_super_admin(kc, company.id)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
|
||||
logger.warning(
|
||||
"No se pudo asegurar empresa/usuario para tenant %s: %s", tenant_id, e
|
||||
)
|
||||
|
||||
|
||||
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
def _repair_user_company_link_if_needed(
|
||||
db: Session,
|
||||
tenant_id_effective: int,
|
||||
hub_user: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""Si ya hay empresa pero el usuario no tiene user_tenants, enlaza y hace bootstrap."""
|
||||
if not hub_user or not hub_user.get("sub"):
|
||||
return
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
kc = hub_user["sub"]
|
||||
company = (
|
||||
db.query(Company)
|
||||
.filter(Company.tenant_id == tenant_id_effective)
|
||||
.first()
|
||||
)
|
||||
if not company:
|
||||
return
|
||||
ut = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == kc,
|
||||
UserTenant.tenant_id == tenant_id_effective,
|
||||
UserTenant.company_id == company.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if ut:
|
||||
return
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, kc, tenant_id_effective, company.id)
|
||||
PermissionService(db).bootstrap_super_admin(kc, company.id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"No se pudo reparar enlace usuario-compañía tenant=%s: %s",
|
||||
tenant_id_effective,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_tenant_synced(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_slug: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Garantiza que el tenant del Hub exista en core.tenants local.
|
||||
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
|
||||
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
||||
"""
|
||||
if tenant_id in _synced_tenant_ids:
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
effective = int(_tenant_id_aliases.get(tenant_id, tenant_id))
|
||||
_repair_user_company_link_if_needed(db, effective, hub_user)
|
||||
return effective
|
||||
|
||||
try:
|
||||
# Importación local para evitar imports circulares
|
||||
@@ -116,7 +255,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
# Garantizar empresa aunque el tenant ya existiera
|
||||
_ensure_company_exists(db, tenant_id, name)
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
# Crear el tenant local con los datos disponibles del token.
|
||||
@@ -134,7 +273,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
||||
# Crear la empresa correspondiente al tenant recién sincronizado
|
||||
_ensure_company_exists(db, tenant_id, name)
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
except IntegrityError:
|
||||
@@ -154,8 +293,9 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
|
||||
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
||||
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
||||
_tenant_id_aliases[tenant_id] = int(stale.id)
|
||||
_tenant_id_hub_by_local[int(stale.id)] = int(tenant_id)
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user)
|
||||
return int(stale.id)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
@@ -195,7 +335,9 @@ async def get_current_user(
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if tenant_id and tenant_slug:
|
||||
effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
|
||||
effective_tenant_id = _ensure_tenant_synced(
|
||||
db, int(tenant_id), str(tenant_slug), hub_user=user_info
|
||||
)
|
||||
if effective_tenant_id != int(tenant_id):
|
||||
logger.warning(
|
||||
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
||||
@@ -268,6 +410,23 @@ def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_hub_tenant_id_for_api(
|
||||
local_tenant_id: Optional[int], x_tenant_override: Optional[str]
|
||||
) -> int:
|
||||
"""
|
||||
ID de tenant en Hub (client_tenants) para llamadas a la API del Hub.
|
||||
|
||||
Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local,
|
||||
usa el mapeo inverso registrado en _ensure_tenant_synced.
|
||||
"""
|
||||
if x_tenant_override and str(x_tenant_override).strip().isdigit():
|
||||
return int(str(x_tenant_override).strip())
|
||||
if local_tenant_id is None:
|
||||
return 0
|
||||
lid = int(local_tenant_id)
|
||||
return int(_tenant_id_hub_by_local.get(lid, lid))
|
||||
|
||||
|
||||
def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]:
|
||||
"""
|
||||
Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
defaultInvoiceType = undefined,
|
||||
invoiceType = undefined,
|
||||
isSettings = false,
|
||||
isCreate = false,
|
||||
highlightFieldId = null,
|
||||
onDismissHighlightForField = undefined
|
||||
}: {
|
||||
@@ -29,6 +30,7 @@
|
||||
defaultInvoiceType?: string | null;
|
||||
invoiceType?: string;
|
||||
isSettings?: boolean;
|
||||
isCreate?: boolean;
|
||||
highlightFieldId?: string | null;
|
||||
onDismissHighlightForField?: (fieldKey: string) => void;
|
||||
} = $props();
|
||||
@@ -233,16 +235,38 @@
|
||||
id="invoice-field-invoice_type"
|
||||
tabindex="-1"
|
||||
class={cn(
|
||||
'min-w-[100px] flex-1 space-y-1 rounded-md outline-none',
|
||||
'min-w-[140px] flex-[1.2] space-y-1 rounded-md outline-none',
|
||||
hl('invoice_type')
|
||||
)}
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground"
|
||||
>{m.invoice_edit_form_invoice_type_label()}</Label
|
||||
>
|
||||
<p class="flex h-8 items-center text-sm font-medium">
|
||||
{formData.invoice_type ? `${formData.invoice_type}` : '...'}
|
||||
</p>
|
||||
{#if isCreate && !isSettings && formData.operation_type === 'exp'}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={formData.invoice_type || ''}
|
||||
onValueChange={(v) => {
|
||||
formData.invoice_type = v || '';
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class={cn('h-8 w-full text-sm', hl('invoice_type'))}>
|
||||
<span class="truncate">
|
||||
{filteredInvoiceTypes.find((t) => t.key === formData.invoice_type)?.description ||
|
||||
m.invoice_edit_form_invoice_type_placeholder()}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[min(60vh,320px)]">
|
||||
{#each filteredInvoiceTypes as t}
|
||||
<Select.Item value={t.key}>{t.description}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<p class="flex h-8 items-center text-sm font-medium">
|
||||
{formData.invoice_type ? `${formData.invoice_type}` : '...'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ export function getSidebarData(): SidebarData {
|
||||
items: [
|
||||
{
|
||||
title: m["sidebar.export_invoices.exportation"](),
|
||||
url: "/dashboard/invoices?operation_type=exp&invoice_type=EXDEF",
|
||||
url: "/dashboard/invoices?operation_type=exp",
|
||||
permission: "invoice.exp.view"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,32 +73,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(value: string | undefined | null): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
let tenantIdentitySet = $derived.by(() => {
|
||||
const set = new Set<string>();
|
||||
for (const tenant of userTenants) {
|
||||
set.add(normalizeIdentity(tenant.name));
|
||||
set.add(normalizeIdentity(tenant.slug));
|
||||
}
|
||||
set.delete('');
|
||||
return set;
|
||||
});
|
||||
|
||||
// Excluir del listado de companias cualquier registro que realmente represente al tenant.
|
||||
let myCompanies = $derived(
|
||||
companyStore.companies.filter((company) => !tenantIdentitySet.has(normalizeIdentity(company.name)))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const active = companyStore.activeCompany;
|
||||
if (!active) return;
|
||||
if (!tenantIdentitySet.has(normalizeIdentity(active.name))) return;
|
||||
if (myCompanies.length === 0) return;
|
||||
void companyStore.setActiveCompany(myCompanies[0], true);
|
||||
});
|
||||
// Misma lista que devuelve my-companies; no filtrar por tenant (nombre/slug equivalentes
|
||||
// ocultaban la empresa creada al primer login).
|
||||
let myCompanies = $derived(companyStore.companies);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
|
||||
@@ -24,11 +24,12 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
const redirectOnFail = `/login?redirect=${encodeURIComponent(url.pathname)}`;
|
||||
|
||||
try {
|
||||
const userData = await validateAuth(cookies, fetch, redirectOnFail);
|
||||
|
||||
// Cargar las compañías del usuario en el servidor (SSR)
|
||||
// Primero my-companies: ejecuta get_current_user y puede crear tenant/empresa/usuario
|
||||
// antes de /me/profile en validateAuth (evita SSR sin la empresa recién provisionada).
|
||||
const companies = await getUserCompanies(cookies, fetch);
|
||||
|
||||
const userData = await validateAuth(cookies, fetch, redirectOnFail);
|
||||
|
||||
// Si la cookie active_company_id apunta a una compañía que ya no existe, limpiarla
|
||||
const cookieCompanyId = cookies.get('active_company_id');
|
||||
if (cookieCompanyId) {
|
||||
|
||||
@@ -110,7 +110,12 @@
|
||||
|
||||
// ── Inicializar compañías ─────────────────────────────────────────────
|
||||
if (data.companies) {
|
||||
companyStore.initialize(data.companies, data.activeCompanyId);
|
||||
const activeCompanyId = (data as { activeCompanyId?: number }).activeCompanyId;
|
||||
void (async () => {
|
||||
await companyStore.initialize(data.companies, activeCompanyId);
|
||||
// Releer en cliente: el SSR puede haber quedado desfasado respecto al provisionamiento.
|
||||
await companyStore.loadCompanies(undefined, activeCompanyId);
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
|
||||
|
||||
@@ -6,6 +6,7 @@ export const load: LayoutLoad = async ({ data }) => {
|
||||
user: data.user,
|
||||
companies: data.companies,
|
||||
authenticated: data.authenticated,
|
||||
userTenants: data.userTenants ?? []
|
||||
userTenants: data.userTenants ?? [],
|
||||
activeCompanyId: data.activeCompanyId
|
||||
};
|
||||
};
|
||||
|
||||
@@ -275,26 +275,39 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Título dinámico según el tipo de operación y factura
|
||||
// Título dinámico según el tipo de operación y tipo de factura (i18n para casos clásicos; catálogo API para el resto)
|
||||
const viewTitle = $derived.by(() => {
|
||||
const op = filters.operation_type;
|
||||
const type = filters.invoice_type;
|
||||
|
||||
const type = (filters.invoice_type || '').trim();
|
||||
|
||||
const base = m['invoice_list.titles.base']();
|
||||
|
||||
|
||||
const titleFromInvoiceTypesCatalog = (): string | null => {
|
||||
if (!type || !data.invoiceTypes?.length) return null;
|
||||
const hit = data.invoiceTypes.find((t: { key: string }) => t.key === type);
|
||||
if (!hit?.description) return null;
|
||||
return `${base} DE ${String(hit.description).trim().toUpperCase()}`;
|
||||
};
|
||||
|
||||
if (op === 'imp') {
|
||||
if (type === 'TEM') return `${base} ${m['invoice_list.titles.import_temporal']()}`;
|
||||
if (type === 'DEF') return `${base} ${m['invoice_list.titles.import_definitive']()}`;
|
||||
if (type === 'MEX') return `${base} ${m['invoice_list.titles.import_mexican']()}`;
|
||||
if (type === 'CR') return `${base} ${m['invoice_list.titles.import_regime_change']()}`;
|
||||
if (type === 'REP') return `${base} ${m['invoice_list.titles.import_repair']()}`;
|
||||
const catalogTitle = titleFromInvoiceTypesCatalog();
|
||||
if (catalogTitle) return catalogTitle;
|
||||
return `${base} ${m['invoice_list.titles.import']()}`;
|
||||
} else if (op === 'exp') {
|
||||
}
|
||||
|
||||
if (op === 'exp') {
|
||||
if (type === 'EXDEF') return `${base} ${m['invoice_list.titles.export_definitive']()}`;
|
||||
if (type === 'REPAR') return `${base} ${m['invoice_list.titles.export_repair']()}`;
|
||||
const catalogTitle = titleFromInvoiceTypesCatalog();
|
||||
if (catalogTitle) return catalogTitle;
|
||||
return `${base} ${m['invoice_list.titles.export']()}`;
|
||||
}
|
||||
|
||||
|
||||
return m['invoice_list.header.title']();
|
||||
});
|
||||
|
||||
@@ -1355,13 +1368,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Opciones de tipo de operación para el filtro
|
||||
const operationTypeOptions = $derived.by(() => [
|
||||
{ value: '', label: m.invoice_list_operation_types_all() },
|
||||
{ value: 'imp', label: m.invoice_list_operation_types_import() },
|
||||
{ value: 'exp', label: m.invoice_list_operation_types_export() }
|
||||
]);
|
||||
|
||||
// Todas las opciones de tipo de factura con su operación correspondiente
|
||||
const allInvoiceTypeOptions = $derived(() => {
|
||||
const options = [{ value: '', label: m.invoice_list_operation_types_all(), operation: 'both' }];
|
||||
@@ -1445,35 +1451,6 @@
|
||||
<p class="text-muted-foreground">{m.invoice_list_header_description()}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Filtros ocultos por requerimiento -->
|
||||
<div class="hidden">
|
||||
<select
|
||||
id="filter-operation-type"
|
||||
bind:value={filters.operation_type}
|
||||
class="flex h-9 w-[180px] rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
title={m.invoice_list_filters_operation_label()}
|
||||
>
|
||||
{#each operationTypeOptions as option}
|
||||
<option value={option.value}>
|
||||
{option.value === '' ? m.invoice_list_filters_operation_all_option() : option.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
id="filter-invoice-type"
|
||||
bind:value={filters.invoice_type}
|
||||
class="flex h-9 w-[220px] rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
title={m.invoice_list_filters_invoice_type_label()}
|
||||
>
|
||||
{#each invoiceTypeOptions() as option}
|
||||
<option value={option.value}>
|
||||
{option.value === '' ? m.invoice_list_filters_invoice_type_all_option() : option.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" class="h-9" onclick={() => goto('/dashboard/invoices/settings')}>
|
||||
<Settings class="mr-2" size={16} />
|
||||
{m.invoice_list_actions_parameters()}
|
||||
@@ -1503,6 +1480,20 @@
|
||||
<Card.Title>{m.invoice_list_card_invoice_list_title()}</Card.Title>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if filters.operation_type === 'exp'}
|
||||
<select
|
||||
id="filter-invoice-type"
|
||||
bind:value={filters.invoice_type}
|
||||
class="flex h-9 w-[min(100%,260px)] shrink-0 rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
title={m.invoice_list_filters_invoice_type_label()}
|
||||
>
|
||||
{#each invoiceTypeOptions() as option}
|
||||
<option value={option.value}>
|
||||
{option.value === '' ? m.invoice_list_filters_invoice_type_all_option() : option.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<Input
|
||||
bind:value={filters.invoice_number}
|
||||
placeholder={m.invoice_list_filters_invoice_number_placeholder()}
|
||||
|
||||
@@ -26,8 +26,11 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
parsedOperationType = operationTypeParam;
|
||||
}
|
||||
|
||||
// Para creación ('new'), es obligatorio tener operation_type e invoice_type
|
||||
if (params.id === 'new' && (!parsedOperationType || !invoiceTypeParam)) {
|
||||
// Para creación ('new'): siempre operation_type; invoice_type obligatorio solo en importación.
|
||||
if (params.id === 'new' && !parsedOperationType) {
|
||||
throw redirect(302, '/dashboard');
|
||||
}
|
||||
if (params.id === 'new' && parsedOperationType === 'imp' && !invoiceTypeParam) {
|
||||
throw redirect(302, '/dashboard');
|
||||
}
|
||||
|
||||
|
||||
@@ -498,14 +498,20 @@
|
||||
let lastFetchedDate = $state('');
|
||||
let originalInvoiceDate = $state(data.invoice?.invoice_date || '');
|
||||
|
||||
// Derivados reactivos para el tipo de factura y operación
|
||||
// Tipo de factura efectivo (cabecera + tabs): prioridad campos superiores, luego URL/factura.
|
||||
// En importación sin tipo aún, se asume TEM como antes; en exportación sin tipo, cadena vacía (sin forzar TEM).
|
||||
let invoiceType = $derived.by(() => {
|
||||
return (
|
||||
InvoiceTopFieldsFormData?.invoice_type ||
|
||||
data.filters?.invoice_type ||
|
||||
data.invoice?.invoice_type ||
|
||||
'TEM'
|
||||
); // Default to TEM if not found
|
||||
const top = InvoiceTopFieldsFormData?.invoice_type;
|
||||
if (top !== undefined && top !== null && String(top).trim() !== '') {
|
||||
return String(top).trim();
|
||||
}
|
||||
if (data.filters?.invoice_type) return String(data.filters.invoice_type).trim();
|
||||
if (data.invoice?.invoice_type) return String(data.invoice.invoice_type).trim();
|
||||
const op =
|
||||
InvoiceTopFieldsFormData?.operation_type ||
|
||||
data.invoice?.operation_type ||
|
||||
data.filters?.operation_type;
|
||||
return op === 'imp' ? 'TEM' : '';
|
||||
});
|
||||
|
||||
let operationTypeText = $derived.by(() => {
|
||||
@@ -1044,19 +1050,18 @@
|
||||
</Button>
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{#if data.isCreate}
|
||||
{@const invoiceType = generalFormData?.invoice_type || data.filters?.invoice_type}
|
||||
{@const invoiceTypeInfo = invoiceType
|
||||
? data.invoiceTypes?.find((t) => t.key === invoiceType)
|
||||
: null}
|
||||
{m.invoice_edit_new_title()}
|
||||
{#if invoiceTypeInfo}
|
||||
<span class="text-2xl font-normal text-muted-foreground">
|
||||
- {invoiceTypeInfo.description}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
{m.invoice_edit_page_invoice_prefix()}{data.invoice.id}
|
||||
{/if}
|
||||
{#if invoiceType}
|
||||
{@const headerInvoiceTypeInfo = data.invoiceTypes?.find((t) => t.key === invoiceType)}
|
||||
{#if headerInvoiceTypeInfo}
|
||||
<span class="text-2xl font-normal text-muted-foreground">
|
||||
- {headerInvoiceTypeInfo.description}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</h1>
|
||||
<Badge variant="outline" class="px-3 py-1 text-sm font-bold {operationColorClass}">
|
||||
{operationTypeText}
|
||||
@@ -1088,6 +1093,7 @@
|
||||
defaultOperationType={data.filters?.operation_type ?? undefined}
|
||||
defaultInvoiceType={data.filters?.invoice_type ?? undefined}
|
||||
{invoiceType}
|
||||
isCreate={data.isCreate === true}
|
||||
highlightFieldId={validationHighlightField}
|
||||
onDismissHighlightForField={dismissValidationHighlight}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user