fix/AS-487-merge-main-development (#490)
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:
51
backend/api/v1/modules/core/invite_codes/dto.py
Normal file
51
backend/api/v1/modules/core/invite_codes/dto.py
Normal 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
|
||||
49
backend/api/v1/modules/core/invite_codes/models.py
Normal file
49
backend/api/v1/modules/core/invite_codes/models.py
Normal 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
|
||||
)
|
||||
149
backend/api/v1/modules/core/invite_codes/routes.py
Normal file
149
backend/api/v1/modules/core/invite_codes/routes.py
Normal 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,
|
||||
)
|
||||
321
backend/api/v1/modules/core/invite_codes/service.py
Normal file
321
backend/api/v1/modules/core/invite_codes/service.py
Normal 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,
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user