fix/AS-487-merge-main-development (#490)
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped

Co-authored-by: Galindo97 <agalindo@aduanasoft.com.mx>
Reviewed-on: ADUANASOFT/anexo76#490
Co-authored-by: AlexeerCT <acazares@aduanasoft.com.mx>
Co-committed-by: AlexeerCT <acazares@aduanasoft.com.mx>
This commit is contained in:
2026-06-03 15:02:02 +00:00
committed by acazares
parent d5643ec0e9
commit 9fae110c11
12 changed files with 1143 additions and 2 deletions

View File

@@ -0,0 +1,51 @@
"""DTOs para el módulo de códigos de invitación."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class CreateInviteCodeDTO(BaseModel):
company_id: Optional[int] = Field(
None, description="Empresa destino (None = cualquier empresa del tenant)"
)
role: str = Field("user", description="Rol asignado al canjear el código")
max_uses: Optional[int] = Field(None, description="Usos máximos (None = ilimitado)")
expires_at: Optional[datetime] = Field(None, description="Expiración (None = sin expiración)")
class InviteCodeResponseDTO(BaseModel):
id: int
code: str
tenant_slug: str
company_id: Optional[int]
role: str
max_uses: Optional[int]
uses_count: int
expires_at: Optional[datetime]
is_active: bool
created_by: str
created_at: datetime
class Config:
from_attributes = True
class ValidateInviteCodeResponseDTO(BaseModel):
code: str
tenant_slug: str
company_id: Optional[int]
role: str
remaining_uses: Optional[int] = Field(
None, description="Usos restantes; None = ilimitado"
)
expires_at: Optional[datetime]
class ConsumeInviteCodeResponseDTO(BaseModel):
success: bool
message: str
tenant_slug: str
company_id: Optional[int]
role: str

View File

@@ -0,0 +1,49 @@
"""Modelo de código de invitación reutilizable para registro en Anexo76."""
from datetime import datetime
from typing import Optional
from api.v1.common.base_models import BaseTimestampMixin
from core.database import Base
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
class InviteCode(Base, BaseTimestampMixin):
"""
Código corto multiuso para invitar usuarios a un tenant/empresa.
A diferencia de InviteToken (único por email), un InviteCode es
compartible: se distribuye como cadena de 8 chars y puede
ser canjeado por múltiples usuarios hasta agotar max_uses.
"""
__tablename__ = "invite_codes"
__table_args__ = {"schema": "core", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Código legible generado automáticamente (8 chars, sin ambigüedad 0/O/I/l)
code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False, index=True)
# Tenant destino — el usuario debe unirse a este workspace en el Hub
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
# Empresa destino específica (None = cualquier empresa del tenant)
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
# Rol con el que se provisiona el usuario al canjear
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
# Control de uso
max_uses: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
uses_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
# Expiración (None = sin expiración)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# keycloak_user_id del admin que generó el código
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="true", default=True
)

View File

@@ -0,0 +1,149 @@
"""Rutas para gestión de códigos de invitación."""
import logging
from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, Query
from fastapi.security import HTTPBearer
from sqlalchemy.orm import Session
from .dto import (
ConsumeInviteCodeResponseDTO,
CreateInviteCodeDTO,
InviteCodeResponseDTO,
ValidateInviteCodeResponseDTO,
)
from .service import InviteCodeService
router = APIRouter(prefix="/invite-codes", tags=["Invite Codes"])
_bearer = HTTPBearer()
logger = logging.getLogger(__name__)
@router.post("", response_model=InviteCodeResponseDTO, status_code=201)
async def create_invite_code(
data: CreateInviteCodeDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
credentials=Depends(_bearer),
):
"""
Genera un código de invitación reutilizable.
Requiere permiso user.create sobre la empresa (o ser admin del tenant).
"""
company_id = data.company_id
if company_id is not None:
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["user.create"],
)
else:
# Invitación a nivel tenant: solo roles admin del tenant
roles = set(current_user.get("roles") or [])
if "admin" not in roles and "hub_admin" not in roles:
from fastapi import HTTPException
raise HTTPException(
status_code=403,
detail="Se requiere rol admin para crear invitaciones de nivel tenant",
)
tenant_slug: str = current_user.get("tenant_slug") or ""
created_by: str = current_user.get("sub") or ""
service = InviteCodeService(db)
return await service.create_code(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
user_access_token=credentials.credentials,
)
@router.get("", response_model=List[InviteCodeResponseDTO])
def list_invite_codes(
company_id: Optional[int] = Query(None, description="Filtrar por empresa"),
include_inactive: bool = Query(False, description="Incluir códigos inactivos/agotados"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Lista los códigos de invitación del tenant.
Filtra opcionalmente por empresa.
"""
if company_id is not None:
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["user.create"],
)
else:
roles = set(current_user.get("roles") or [])
if "admin" not in roles and "hub_admin" not in roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Se requiere rol admin")
tenant_slug: str = current_user.get("tenant_slug") or ""
service = InviteCodeService(db)
return service.list_codes(
tenant_slug=tenant_slug,
company_id=company_id,
include_inactive=include_inactive,
)
@router.delete("/{code}", status_code=204)
def revoke_invite_code(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Revoca (desactiva) un código de invitación."""
roles = set(current_user.get("roles") or [])
if "admin" not in roles and "hub_admin" not in roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Se requiere rol admin")
tenant_slug: str = current_user.get("tenant_slug") or ""
service = InviteCodeService(db)
service.revoke_code(code=code, tenant_slug=tenant_slug)
@router.get("/validate/{code}", response_model=ValidateInviteCodeResponseDTO)
def validate_invite_code(
code: str,
db: Session = Depends(get_core_db),
):
"""
Valida un código de invitación sin consumirlo.
Endpoint público — no requiere autenticación.
"""
service = InviteCodeService(db)
return service.validate(code=code)
@router.post("/consume/{code}", response_model=ConsumeInviteCodeResponseDTO)
def consume_invite_code(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Canjea el código: incrementa el contador de usos y crea la relación
UserTenant (usuario ↔ empresa) si el código tiene company_id definido.
Requiere autenticación.
"""
keycloak_user_id: str = current_user.get("sub") or ""
tenant_id: int = current_user.get("tenant_id") or 0
service = InviteCodeService(db)
return service.consume(
code=code,
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
)

View File

@@ -0,0 +1,321 @@
"""Servicio de códigos de invitación reutilizables para Anexo76."""
import logging
import random
import string
from datetime import datetime, timezone
from typing import List, Optional
import httpx
from fastapi import HTTPException
from sqlalchemy.orm import Session
from core.config import settings
from .dto import (
ConsumeInviteCodeResponseDTO,
CreateInviteCodeDTO,
InviteCodeResponseDTO,
ValidateInviteCodeResponseDTO,
)
from .models import InviteCode
logger = logging.getLogger(__name__)
# Charset sin caracteres ambiguos (0/O/I/l/1)
_CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 8
def _generate_code() -> str:
return "".join(random.choices(_CODE_CHARSET, k=_CODE_LENGTH))
def _is_valid(invite: InviteCode) -> bool:
"""True si el código es canjeable en este momento."""
if not invite.is_active:
return False
if invite.max_uses is not None and invite.uses_count >= invite.max_uses:
return False
if invite.expires_at and invite.expires_at < datetime.now(timezone.utc):
return False
return True
class InviteCodeService:
def __init__(self, db: Session):
self.db = db
async def create_code(
self,
data: CreateInviteCodeDTO,
created_by: str,
tenant_slug: str,
user_access_token: str = "",
) -> InviteCodeResponseDTO:
from api.v1.modules.core.tenants.models import Tenant
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == tenant_slug, Tenant.is_active == True)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
if data.company_id is not None:
from api.v1.modules.a76.general_catalogs.company.models import Company
company = (
self.db.query(Company)
.filter(
Company.id == data.company_id,
Company.tenant_id == tenant.id,
)
.first()
)
if not company:
raise HTTPException(
status_code=404,
detail="Empresa no encontrada o no pertenece al tenant",
)
# Genera código único; reintenta si hay colisión (improbable)
for _ in range(5):
code = _generate_code()
if not self.db.query(InviteCode).filter(InviteCode.code == code).first():
break
else:
raise HTTPException(
status_code=500,
detail="No se pudo generar un código único, intenta de nuevo",
)
invite = InviteCode(
code=code,
tenant_slug=tenant_slug,
company_id=data.company_id,
role=data.role,
max_uses=data.max_uses,
uses_count=0,
expires_at=data.expires_at,
created_by=created_by,
is_active=True,
)
self.db.add(invite)
self.db.commit()
self.db.refresh(invite)
# Registrar el mismo código en el Hub para que funcione en workspace /join
await self._sync_code_to_hub(
code=code,
tenant_slug=tenant_slug,
data=data,
user_access_token=user_access_token,
)
return InviteCodeResponseDTO.model_validate(invite)
async def _sync_code_to_hub(
self,
code: str,
tenant_slug: str,
data: CreateInviteCodeDTO,
user_access_token: str,
) -> None:
"""
Crea el mismo código en Hub's workspace_invite_codes con allowed_systems=['a76'].
Best-effort: si falla, el código sigue válido en A76 pero no en workspace.
"""
if not user_access_token:
logger.warning(
"[invite_code] Sin token para sincronizar '%s' al Hub — "
"el código NO funcionará en workspace /join",
code,
)
return
hub_url = getattr(settings, "HUB_URL", "").rstrip("/")
if not hub_url:
logger.warning("[invite_code] HUB_URL no configurado — código '%s' no sincronizado", code)
return
payload: dict = {
"code": code,
"allowed_systems": ["anexo76"],
"role": data.role,
}
if data.max_uses is not None:
payload["max_uses"] = data.max_uses
if data.expires_at is not None:
payload["expires_at"] = data.expires_at.isoformat()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{hub_url}/api/v1/hub/invite-codes/{tenant_slug}",
json=payload,
headers={"Authorization": f"Bearer {user_access_token}"},
)
if resp.status_code in (200, 201):
logger.info(
"[invite_code] Código '%s' sincronizado al Hub (tenant=%s)", code, tenant_slug
)
elif resp.status_code == 409:
logger.info(
"[invite_code] Código '%s' ya existe en Hub (tenant=%s) — OK", code, tenant_slug
)
else:
logger.error(
"[invite_code] Hub sync falló code='%s' status=%s body=%s",
code, resp.status_code, resp.text[:300],
)
except Exception as exc:
logger.error("[invite_code] Hub sync excepción code='%s': %s", code, exc)
def list_codes(
self,
tenant_slug: str,
company_id: Optional[int] = None,
include_inactive: bool = False,
) -> List[InviteCodeResponseDTO]:
q = self.db.query(InviteCode).filter(InviteCode.tenant_slug == tenant_slug)
if company_id is not None:
q = q.filter(InviteCode.company_id == company_id)
if not include_inactive:
q = q.filter(InviteCode.is_active == True)
invites = q.order_by(InviteCode.created_at.desc()).all()
return [InviteCodeResponseDTO.model_validate(i) for i in invites]
def revoke_code(self, code: str, tenant_slug: str) -> None:
invite = (
self.db.query(InviteCode)
.filter(InviteCode.code == code, InviteCode.tenant_slug == tenant_slug)
.first()
)
if not invite:
raise HTTPException(status_code=404, detail="Código de invitación no encontrado")
invite.is_active = False
self.db.commit()
def validate(self, code: str) -> ValidateInviteCodeResponseDTO:
"""Valida el código sin consumirlo. Devuelve 403 genérico si no es válido."""
invite = self.db.query(InviteCode).filter(InviteCode.code == code).first()
if not invite or not _is_valid(invite):
raise HTTPException(
status_code=403,
detail="Código de invitación inválido o expirado",
)
remaining: Optional[int] = None
if invite.max_uses is not None:
remaining = invite.max_uses - invite.uses_count
return ValidateInviteCodeResponseDTO(
code=invite.code,
tenant_slug=invite.tenant_slug,
company_id=invite.company_id,
role=invite.role,
remaining_uses=remaining,
expires_at=invite.expires_at,
)
def consume(
self,
code: str,
keycloak_user_id: str,
tenant_id: int,
) -> ConsumeInviteCodeResponseDTO:
"""
Canjea el código:
- Incrementa uses_count.
- Si company_id está definido, crea UserTenant (usuario ↔ empresa).
- Desactiva el código si se agotaron los usos.
"""
invite = self.db.query(InviteCode).filter(InviteCode.code == code).first()
if not invite or not _is_valid(invite):
raise HTTPException(
status_code=403,
detail="Código de invitación inválido o expirado",
)
if invite.company_id is not None:
self._ensure_user_tenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
company_id=invite.company_id,
role=invite.role,
)
invite.uses_count += 1
if invite.max_uses is not None and invite.uses_count >= invite.max_uses:
invite.is_active = False
self.db.commit()
logger.info(
"[invite_code] canjeado code=%s user=%s company_id=%s uses=%d/%s",
invite.code,
keycloak_user_id,
invite.company_id,
invite.uses_count,
invite.max_uses or "",
)
return ConsumeInviteCodeResponseDTO(
success=True,
message="Código canjeado correctamente",
tenant_slug=invite.tenant_slug,
company_id=invite.company_id,
role=invite.role,
)
def _ensure_user_tenant(
self,
keycloak_user_id: str,
tenant_id: int,
company_id: int,
role: str,
) -> None:
"""Crea la fila UserTenant si el usuario aún no tiene acceso a la empresa."""
from sqlalchemy.exc import IntegrityError
from api.v1.modules.core.user_tenant.models import UserTenant
existing = (
self.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
existing.role = role
self.db.commit()
return
user_tenant = UserTenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
company_id=company_id,
role=role,
is_active=True,
)
self.db.add(user_tenant)
try:
self.db.commit()
except IntegrityError:
self.db.rollback()
logger.warning(
"[invite_code] race: UserTenant ya existe user=%s company=%d",
keycloak_user_id,
company_id,
)

View File

@@ -1,4 +1,5 @@
from .auth.routes import router as auth_router
from .invite_codes.routes import router as invite_codes_router
from .invites.routes import router as invites_router
from .licenses.routes import router as licenses_router
from .permissions.routes import router as permissions_router
@@ -14,6 +15,7 @@ router = APIRouter()
router.include_router(auth_router)
router.include_router(invites_router, prefix="/core", tags=["core / invites"])
router.include_router(invite_codes_router, prefix="/core", tags=["core / invite-codes"])
router.include_router(tenants_router, prefix="/core", tags=["core / tenants"])
router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"])
router.include_router(users_router, prefix="/core", tags=["core / users"])