feat: Add frontend and backend initialization scripts, implement Keycloak and PostgreSQL setup

- Implemented SvelteKit frontend with authentication callback handling.
- Created demo routes and paraglide localization functionality.
- Added health check and entrypoint scripts for backend services.
- Established PostgreSQL and Keycloak initialization scripts with health checks.
- Introduced models for database schema using SQLAlchemy.
- Configured Vite and SvelteKit for development and testing environments.
- Added health check script to verify service statuses and resource usage.
- Created Docker entrypoint scripts for seamless service startup.
This commit is contained in:
2025-10-19 00:14:06 -05:00
commit 2a10d7d267
99 changed files with 10478 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
"""
Módulo de Authentication
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,71 @@
"""
DTOs para módulo de autenticación
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class LoginRequestDTO(BaseModel):
"""DTO para solicitud de login"""
username: str = Field(..., description="Usuario o email")
password: str = Field(..., min_length=6, description="Contraseña")
tenant_slug: str = Field(..., description="Slug del tenant")
class Config:
json_schema_extra = {
"example": {
"username": "usuario@ejemplo.com",
"password": "password123",
"tenant_slug": "empresa-abc"
}
}
class TokenResponseDTO(BaseModel):
"""DTO para respuesta de token"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class Config:
json_schema_extra = {
"example": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600
}
}
class RefreshTokenRequestDTO(BaseModel):
"""DTO para solicitud de refresh token"""
refresh_token: str = Field(..., description="Refresh token")
class UserInfoResponseDTO(BaseModel):
"""DTO para información de usuario"""
sub: str
email: Optional[str] = None
name: Optional[str] = None
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
roles: list[str] = []
class Config:
json_schema_extra = {
"example": {
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "usuario@ejemplo.com",
"name": "Juan Pérez",
"preferred_username": "jperez",
"tenant_id": 1,
"roles": ["user", "admin"]
}
}
class LogoutRequestDTO(BaseModel):
"""DTO para solicitud de logout"""
refresh_token: str = Field(..., description="Refresh token para invalidar")

View File

@@ -0,0 +1,86 @@
"""
Endpoints API para autenticación
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from .dto import (
LoginRequestDTO,
TokenResponseDTO,
RefreshTokenRequestDTO,
UserInfoResponseDTO,
LogoutRequestDTO
)
from .service import AuthService
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@router.post("/login", response_model=TokenResponseDTO)
async def login(
login_data: LoginRequestDTO,
db: Session = Depends(get_core_db)
):
"""
Autentica usuario con Keycloak y retorna tokens JWT
El usuario debe proporcionar:
- username: Usuario o email
- password: Contraseña
- tenant_slug: Slug del tenant al que pertenece
"""
service = AuthService(db)
return service.login(login_data)
@router.post("/refresh", response_model=TokenResponseDTO)
async def refresh_token(
refresh_data: RefreshTokenRequestDTO,
db: Session = Depends(get_core_db)
):
"""
Refresca el access token usando el refresh token
"""
service = AuthService(db)
return service.refresh_token(refresh_data)
@router.get("/me", response_model=UserInfoResponseDTO)
async def get_current_user_info(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db)
):
"""
Obtiene información del usuario actual desde el token
"""
service = AuthService(db)
return service.get_user_info(credentials.credentials)
@router.post("/logout")
async def logout(
logout_data: LogoutRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Cierra sesión invalidando el refresh token
"""
service = AuthService(db)
return service.logout(logout_data)
@router.get("/health")
async def auth_health():
"""
Health check del módulo de autenticación
"""
return {
"status": "ok",
"module": "authentication",
"provider": "keycloak"
}

View File

@@ -0,0 +1,175 @@
"""
Servicio de autenticación con Keycloak
"""
from keycloak import KeycloakOpenID, KeycloakAdmin
from keycloak.exceptions import KeycloakError
from fastapi import HTTPException
from sqlalchemy.orm import Session
import logging
from core.config import settings
from .dto import (
LoginRequestDTO,
TokenResponseDTO,
RefreshTokenRequestDTO,
UserInfoResponseDTO,
LogoutRequestDTO
)
logger = logging.getLogger(__name__)
class AuthService:
"""Servicio de autenticación"""
def __init__(self, db: Session):
self.db = db
self.keycloak_openid = KeycloakOpenID(
server_url=settings.KEYCLOAK_SERVER_URL,
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET
)
def login(self, login_data: LoginRequestDTO) -> TokenResponseDTO:
"""
Autentica usuario y obtiene tokens
Args:
login_data: Credenciales de login
Returns:
TokenResponseDTO con access_token y refresh_token
Raises:
HTTPException: Si las credenciales son inválidas
"""
try:
# Verificar que el tenant existe
from api.v1.modules.a76.tenants.service import TenantService
tenant_service = TenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
# Cambiar realm al del tenant
self.keycloak_openid.realm_name = tenant.keycloak_realm
# Obtener token de Keycloak
token_response = self.keycloak_openid.token(
username=login_data.username,
password=login_data.password
)
logger.info(f"User logged in: {login_data.username} (tenant: {tenant.slug})")
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"]
)
except KeycloakError as e:
logger.warning(f"Keycloak authentication failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid credentials")
except HTTPException:
raise
except Exception as e:
logger.error(f"Login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando refresh token
Args:
refresh_data: Refresh token
Returns:
TokenResponseDTO con nuevos tokens
"""
try:
token_response = self.keycloak_openid.refresh_token(
refresh_data.refresh_token
)
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"]
)
except KeycloakError as e:
logger.warning(f"Token refresh failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""
Obtiene información del usuario desde el token
Args:
access_token: Access token JWT
Returns:
UserInfoResponseDTO con información del usuario
"""
try:
user_info = self.keycloak_openid.userinfo(access_token)
# Extraer roles
roles = []
if "realm_access" in user_info:
roles = user_info["realm_access"].get("roles", [])
# Extraer tenant_id si está presente
tenant_id = user_info.get("tenant_id")
if not tenant_id and "attributes" in user_info:
tenant_id = user_info["attributes"].get("tenant_id")
return UserInfoResponseDTO(
sub=user_info.get("sub"),
email=user_info.get("email"),
name=user_info.get("name"),
preferred_username=user_info.get("preferred_username"),
tenant_id=int(tenant_id) if tenant_id else None,
roles=roles
)
except KeycloakError as e:
logger.warning(f"Get user info failed: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid token")
except Exception as e:
logger.error(f"Get user info error: {str(e)}")
raise HTTPException(status_code=500, detail="Error retrieving user info")
def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión invalidando el refresh token
Args:
logout_data: Refresh token a invalidar
Returns:
Dict con mensaje de éxito
"""
try:
self.keycloak_openid.logout(logout_data.refresh_token)
logger.info("User logged out successfully")
return {"message": "Logged out successfully"}
except KeycloakError as e:
logger.warning(f"Logout failed: {str(e)}")
# No lanzamos error aquí, el logout puede fallar si el token ya expiró
return {"message": "Logged out"}
except Exception as e:
logger.error(f"Logout error: {str(e)}")
raise HTTPException(status_code=500, detail="Logout error")

View File

@@ -0,0 +1,6 @@
"""
Módulo de Licenses
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,143 @@
"""
DTOs para módulo de licencias
"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from enum import Enum
class LicensePlanDTO(str, Enum):
"""Planes de licencia"""
FREE = "free"
BASIC = "basic"
PROFESSIONAL = "professional"
ENTERPRISE = "enterprise"
class LicenseStatusDTO(str, Enum):
"""Estados de licencia"""
ACTIVE = "active"
EXPIRED = "expired"
SUSPENDED = "suspended"
PENDING = "pending"
CANCELLED = "cancelled"
class LicenseCreateDTO(BaseModel):
"""DTO para crear una nueva licencia"""
tenant_id: int = Field(..., description="ID del tenant")
plan: LicensePlanDTO = Field(..., description="Plan de licencia")
max_users: int = Field(default=5, ge=1, description="Número máximo de usuarios")
max_storage_gb: int = Field(default=10, ge=1, description="Almacenamiento máximo en GB")
max_monthly_operations: int = Field(default=1000, ge=1, description="Operaciones mensuales máximas")
feature_api_access: bool = Field(default=True)
feature_advanced_reports: bool = Field(default=False)
feature_integrations: bool = Field(default=False)
feature_dedicated_support: bool = Field(default=False)
starts_at: datetime = Field(..., description="Fecha de inicio de vigencia")
expires_at: datetime = Field(..., description="Fecha de expiración")
class Config:
json_schema_extra = {
"example": {
"tenant_id": 1,
"plan": "professional",
"max_users": 20,
"max_storage_gb": 100,
"max_monthly_operations": 10000,
"feature_api_access": True,
"feature_advanced_reports": True,
"feature_integrations": True,
"feature_dedicated_support": False,
"starts_at": "2025-01-01T00:00:00Z",
"expires_at": "2025-12-31T23:59:59Z"
}
}
class LicenseUpdateDTO(BaseModel):
"""DTO para actualizar una licencia"""
plan: Optional[LicensePlanDTO] = None
status: Optional[LicenseStatusDTO] = None
max_users: Optional[int] = Field(None, ge=1)
max_storage_gb: Optional[int] = Field(None, ge=1)
max_monthly_operations: Optional[int] = Field(None, ge=1)
feature_api_access: Optional[bool] = None
feature_advanced_reports: Optional[bool] = None
feature_integrations: Optional[bool] = None
feature_dedicated_support: Optional[bool] = None
expires_at: Optional[datetime] = None
class LicenseResponseDTO(BaseModel):
"""DTO para respuesta de licencia"""
id: int
tenant_id: int
plan: LicensePlanDTO
status: LicenseStatusDTO
max_users: int
max_storage_gb: int
max_monthly_operations: int
feature_api_access: bool
feature_advanced_reports: bool
feature_integrations: bool
feature_dedicated_support: bool
starts_at: datetime
expires_at: datetime
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class LicenseValidationResponseDTO(BaseModel):
"""DTO para respuesta de validación de licencia"""
is_valid: bool
status: LicenseStatusDTO
plan: LicensePlanDTO
expires_at: datetime
reason: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"is_valid": True,
"status": "active",
"plan": "professional",
"expires_at": "2025-12-31T23:59:59Z",
"reason": None
}
}
class LicenseUsageResponseDTO(BaseModel):
"""DTO para respuesta de uso de licencia"""
tenant_id: int
period_start: datetime
period_end: datetime
active_users: int
storage_used_gb: int
operations_count: int
api_calls_count: int
# Límites actuales
max_users: int
max_storage_gb: int
max_monthly_operations: int
# Porcentajes de uso
users_usage_percent: float
storage_usage_percent: float
operations_usage_percent: float
class Config:
from_attributes = True

View File

@@ -0,0 +1,89 @@
"""
Modelos ORM para gestión de licencias
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum as SQLEnum
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from core.database import Base
import enum
class LicensePlan(enum.Enum):
"""Planes de licencia disponibles"""
FREE = "free"
BASIC = "basic"
PROFESSIONAL = "professional"
ENTERPRISE = "enterprise"
class LicenseStatus(enum.Enum):
"""Estados de licencia"""
ACTIVE = "active"
EXPIRED = "expired"
SUSPENDED = "suspended"
PENDING = "pending"
CANCELLED = "cancelled"
class License(Base):
"""
Modelo de Licencia - Control de planes y límites por tenant
"""
__tablename__ = "licenses"
__table_args__ = {"schema": "a76"}
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("a76.tenants.id"), nullable=False, unique=True, index=True)
# Plan y características
plan = Column(SQLEnum(LicensePlan), default=LicensePlan.FREE, nullable=False)
status = Column(SQLEnum(LicenseStatus), default=LicenseStatus.PENDING, nullable=False)
# Límites del plan
max_users = Column(Integer, default=5, nullable=False)
max_storage_gb = Column(Integer, default=10, nullable=False)
max_monthly_operations = Column(Integer, default=1000, nullable=False)
# Features habilitadas (booleans)
feature_api_access = Column(Boolean, default=True)
feature_advanced_reports = Column(Boolean, default=False)
feature_integrations = Column(Boolean, default=False)
feature_dedicated_support = Column(Boolean, default=False)
# Vigencia
starts_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<License(tenant_id={self.tenant_id}, plan={self.plan.value}, status={self.status.value})>"
class LicenseUsage(Base):
"""
Modelo para tracking de uso de licencia
"""
__tablename__ = "license_usage"
__table_args__ = {"schema": "a76"}
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("a76.tenants.id"), nullable=False, index=True)
# Métricas de uso
period_start = Column(DateTime(timezone=True), nullable=False)
period_end = Column(DateTime(timezone=True), nullable=False)
active_users = Column(Integer, default=0)
storage_used_gb = Column(Integer, default=0)
operations_count = Column(Integer, default=0)
api_calls_count = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<LicenseUsage(tenant_id={self.tenant_id}, operations={self.operations_count})>"

View File

@@ -0,0 +1,118 @@
"""
Endpoints API para gestión de licencias
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, has_role
from .dto import (
LicenseCreateDTO,
LicenseUpdateDTO,
LicenseResponseDTO,
LicenseValidationResponseDTO,
LicenseUsageResponseDTO
)
from .service import LicenseService
router = APIRouter(prefix="/licenses", tags=["Licenses"])
@router.post("/", response_model=LicenseResponseDTO, status_code=201)
async def create_license(
license_data: LicenseCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
):
"""
Crea una nueva licencia para un tenant
Requiere rol: admin
"""
service = LicenseService(db)
return service.create_license(license_data)
@router.get("/tenant/{tenant_id}", response_model=LicenseResponseDTO)
async def get_license_by_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene la licencia de un tenant específico
"""
service = LicenseService(db)
license = service.get_license_by_tenant(tenant_id)
if not license:
raise HTTPException(status_code=404, detail="License not found")
return license
@router.put("/tenant/{tenant_id}", response_model=LicenseResponseDTO)
async def update_license(
tenant_id: int,
license_data: LicenseUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
):
"""
Actualiza la licencia de un tenant
Requiere rol: admin
"""
service = LicenseService(db)
license = service.update_license(tenant_id, license_data)
if not license:
raise HTTPException(status_code=404, detail="License not found")
return license
@router.get("/validate/{tenant_id}", response_model=LicenseValidationResponseDTO)
async def validate_license(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Valida si la licencia de un tenant está activa y vigente
"""
service = LicenseService(db)
validation = service.validate_license(tenant_id)
return LicenseValidationResponseDTO(**validation)
@router.get("/usage/{tenant_id}", response_model=LicenseUsageResponseDTO)
async def get_license_usage(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene el uso actual de la licencia de un tenant
"""
service = LicenseService(db)
usage = service.get_usage(tenant_id)
if not usage:
raise HTTPException(status_code=404, detail="License not found")
return usage
@router.get("/my-license", response_model=LicenseResponseDTO)
async def get_my_license(
request: Request,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene la licencia del tenant del usuario actual
"""
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in request")
service = LicenseService(db)
license = service.get_license_by_tenant(tenant_id)
if not license:
raise HTTPException(status_code=404, detail="License not found")
return license

View File

@@ -0,0 +1,243 @@
"""
Servicio de lógica de negocio para licencias
"""
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import Optional
from datetime import datetime, timezone
import logging
from .models import License, LicenseUsage, LicensePlan, LicenseStatus
from .dto import (
LicenseCreateDTO,
LicenseUpdateDTO,
LicenseResponseDTO,
LicenseValidationResponseDTO,
LicenseUsageResponseDTO
)
logger = logging.getLogger(__name__)
class LicenseService:
"""Servicio para gestión de licencias"""
def __init__(self, db: Session):
self.db = db
def create_license(self, license_data: LicenseCreateDTO) -> LicenseResponseDTO:
"""
Crea una nueva licencia para un tenant
Args:
license_data: Datos de la licencia
Returns:
LicenseResponseDTO
Raises:
HTTPException: Si el tenant ya tiene licencia o hay error
"""
try:
# Verificar que el tenant no tenga ya una licencia
existing = self.db.query(License).filter(
License.tenant_id == license_data.tenant_id
).first()
if existing:
raise HTTPException(
status_code=400,
detail=f"Tenant {license_data.tenant_id} already has a license"
)
# Crear licencia
db_license = License(
tenant_id=license_data.tenant_id,
plan=LicensePlan(license_data.plan.value),
status=LicenseStatus.ACTIVE,
max_users=license_data.max_users,
max_storage_gb=license_data.max_storage_gb,
max_monthly_operations=license_data.max_monthly_operations,
feature_api_access=license_data.feature_api_access,
feature_advanced_reports=license_data.feature_advanced_reports,
feature_integrations=license_data.feature_integrations,
feature_dedicated_support=license_data.feature_dedicated_support,
starts_at=license_data.starts_at,
expires_at=license_data.expires_at
)
self.db.add(db_license)
self.db.commit()
self.db.refresh(db_license)
logger.info(f"License created for tenant {license_data.tenant_id}")
return LicenseResponseDTO.model_validate(db_license)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating license: {str(e)}")
raise HTTPException(status_code=400, detail="Database integrity error")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating license: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating license")
def get_license_by_tenant(self, tenant_id: int) -> Optional[LicenseResponseDTO]:
"""
Obtiene la licencia de un tenant
Args:
tenant_id: ID del tenant
Returns:
LicenseResponseDTO o None si no existe
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return None
return LicenseResponseDTO.model_validate(license)
def update_license(self, tenant_id: int, license_data: LicenseUpdateDTO) -> Optional[LicenseResponseDTO]:
"""
Actualiza una licencia
Args:
tenant_id: ID del tenant
license_data: Datos a actualizar
Returns:
LicenseResponseDTO actualizado o None si no existe
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return None
# Actualizar campos proporcionados
update_data = license_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in ["plan", "status"]:
# Convertir enums
value = LicensePlan(value) if field == "plan" else LicenseStatus(value)
setattr(license, field, value)
try:
self.db.commit()
self.db.refresh(license)
logger.info(f"License updated for tenant {tenant_id}")
return LicenseResponseDTO.model_validate(license)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating license for tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating license")
def validate_license(self, tenant_id: int) -> dict:
"""
Valida si la licencia de un tenant está activa y vigente
Args:
tenant_id: ID del tenant
Returns:
Dict con información de validación
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return {
"is_valid": False,
"status": "not_found",
"plan": None,
"expires_at": None,
"reason": "License not found"
}
now = datetime.now(timezone.utc)
# Verificar estado
if license.status != LicenseStatus.ACTIVE:
return {
"is_valid": False,
"status": license.status.value,
"plan": license.plan.value,
"expires_at": license.expires_at,
"reason": f"License status is {license.status.value}"
}
# Verificar vigencia
if license.expires_at < now:
# Auto-actualizar a expirada
license.status = LicenseStatus.EXPIRED
self.db.commit()
return {
"is_valid": False,
"status": "expired",
"plan": license.plan.value,
"expires_at": license.expires_at,
"reason": "License has expired"
}
# Licencia válida
return {
"is_valid": True,
"status": license.status.value,
"plan": license.plan.value,
"expires_at": license.expires_at,
"reason": None
}
def get_usage(self, tenant_id: int) -> Optional[LicenseUsageResponseDTO]:
"""
Obtiene el uso actual de la licencia de un tenant
Args:
tenant_id: ID del tenant
Returns:
LicenseUsageResponseDTO o None
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return None
# Obtener último registro de uso
usage = self.db.query(LicenseUsage).filter(
LicenseUsage.tenant_id == tenant_id
).order_by(LicenseUsage.created_at.desc()).first()
if not usage:
# Crear registro inicial si no existe
usage = LicenseUsage(
tenant_id=tenant_id,
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
active_users=0,
storage_used_gb=0,
operations_count=0,
api_calls_count=0
)
# Calcular porcentajes
users_usage = (usage.active_users / license.max_users * 100) if license.max_users > 0 else 0
storage_usage = (usage.storage_used_gb / license.max_storage_gb * 100) if license.max_storage_gb > 0 else 0
operations_usage = (usage.operations_count / license.max_monthly_operations * 100) if license.max_monthly_operations > 0 else 0
return LicenseUsageResponseDTO(
tenant_id=tenant_id,
period_start=usage.period_start,
period_end=usage.period_end,
active_users=usage.active_users,
storage_used_gb=usage.storage_used_gb,
operations_count=usage.operations_count,
api_calls_count=usage.api_calls_count,
max_users=license.max_users,
max_storage_gb=license.max_storage_gb,
max_monthly_operations=license.max_monthly_operations,
users_usage_percent=round(users_usage, 2),
storage_usage_percent=round(storage_usage, 2),
operations_usage_percent=round(operations_usage, 2)
)

View File

@@ -0,0 +1,6 @@
"""
Módulo de Tenants
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,97 @@
"""
DTOs (Data Transfer Objects) para módulo de tenants
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
from enum import Enum
class TenantTypeDTO(str, Enum):
"""Tipo de tenant"""
SHARED = "shared"
DEDICATED = "dedicated"
class TenantCreateDTO(BaseModel):
"""DTO para crear un nuevo tenant"""
name: str = Field(..., min_length=3, max_length=255, description="Nombre del tenant")
slug: str = Field(..., min_length=3, max_length=100, description="Identificador único del tenant")
keycloak_realm: str = Field(..., min_length=3, max_length=255, description="Nombre del realm en Keycloak")
type: TenantTypeDTO = Field(default=TenantTypeDTO.SHARED, description="Tipo de tenant")
contact_name: Optional[str] = Field(None, max_length=255, description="Nombre de contacto")
contact_email: Optional[EmailStr] = Field(None, description="Email de contacto")
contact_phone: Optional[str] = Field(None, max_length=50, description="Teléfono de contacto")
class Config:
json_schema_extra = {
"example": {
"name": "Empresa ABC S.A. de C.V.",
"slug": "empresa-abc",
"keycloak_realm": "empresa-abc-realm",
"type": "shared",
"contact_name": "Juan Pérez",
"contact_email": "juan.perez@empresa-abc.com",
"contact_phone": "+52 55 1234 5678"
}
}
class TenantUpdateDTO(BaseModel):
"""DTO para actualizar un tenant"""
name: Optional[str] = Field(None, min_length=3, max_length=255)
contact_name: Optional[str] = Field(None, max_length=255)
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = Field(None, max_length=50)
is_active: Optional[bool] = None
class Config:
json_schema_extra = {
"example": {
"name": "Empresa ABC S.A. de C.V. - Actualizado",
"contact_email": "nuevo@empresa-abc.com"
}
}
class TenantResponseDTO(BaseModel):
"""DTO para respuesta de tenant"""
id: int
name: str
slug: str
type: TenantTypeDTO
keycloak_realm: str
contact_name: Optional[str]
contact_email: Optional[str]
contact_phone: Optional[str]
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
json_schema_extra = {
"example": {
"id": 1,
"name": "Empresa ABC S.A. de C.V.",
"slug": "empresa-abc",
"type": "shared",
"keycloak_realm": "empresa-abc-realm",
"contact_name": "Juan Pérez",
"contact_email": "juan.perez@empresa-abc.com",
"contact_phone": "+52 55 1234 5678",
"is_active": True,
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
class TenantListResponseDTO(BaseModel):
"""DTO para lista de tenants"""
tenants: list[TenantResponseDTO]
total: int
page: int
page_size: int

View File

@@ -0,0 +1,50 @@
"""
Modelos ORM para gestión de tenants
"""
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Enum as SQLEnum
from sqlalchemy.sql import func
from core.database import Base
import enum
class TenantType(enum.Enum):
"""Tipo de tenant según tamaño y necesidades"""
SHARED = "shared" # BD compartida
DEDICATED = "dedicated" # BD dedicada
class Tenant(Base):
"""
Modelo de Tenant - Cliente/Organización en el sistema
Cada tenant puede tener BD compartida o dedicada
"""
__tablename__ = "tenants"
__table_args__ = {"schema": "a76"}
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False, index=True)
slug = Column(String(100), unique=True, nullable=False, index=True)
# Tipo de tenant (compartido o dedicado)
type = Column(SQLEnum(TenantType), default=TenantType.SHARED, nullable=False)
# Keycloak realm asociado
keycloak_realm = Column(String(255), unique=True, nullable=False)
# Configuración de BD dedicada (JSON string o NULL si usa BD compartida)
db_config = Column(Text, nullable=True) # JSON: {host, port, name, user, password}
# Información de contacto
contact_name = Column(String(255))
contact_email = Column(String(255))
contact_phone = Column(String(50))
# Estado
is_active = Column(Boolean, default=True, nullable=False)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name}, type={self.type.value})>"

View File

@@ -0,0 +1,128 @@
"""
Endpoints API para gestión de tenants
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from core.database import get_core_db
from core.security import get_current_user, has_role
from .dto import TenantCreateDTO, TenantUpdateDTO, TenantResponseDTO, TenantListResponseDTO
from .service import TenantService
router = APIRouter(prefix="/tenants", tags=["Tenants"])
@router.post("/", response_model=TenantResponseDTO, status_code=201)
async def create_tenant(
tenant_data: TenantCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
):
"""
Crea un nuevo tenant en el sistema
Requiere rol: admin
"""
service = TenantService(db)
return service.create_tenant(tenant_data)
@router.get("/", response_model=TenantListResponseDTO)
async def list_tenants(
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"),
active_only: bool = Query(False, description="Solo tenants activos"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
):
"""
Lista todos los tenants
Requiere rol: admin
"""
service = TenantService(db)
skip = (page - 1) * page_size
tenants = service.list_tenants(skip=skip, limit=page_size, active_only=active_only)
# Contar total
from .models import Tenant
query = db.query(Tenant)
if active_only:
query = query.filter(Tenant.is_active == True)
total = query.count()
return TenantListResponseDTO(
tenants=tenants,
total=total,
page=page,
page_size=page_size
)
@router.get("/{tenant_id}", response_model=TenantResponseDTO)
async def get_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene información de un tenant por ID
"""
service = TenantService(db)
tenant = service.get_tenant(tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant
@router.put("/{tenant_id}", response_model=TenantResponseDTO)
async def update_tenant(
tenant_id: int,
tenant_data: TenantUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
):
"""
Actualiza un tenant
Requiere rol: admin
"""
service = TenantService(db)
tenant = service.update_tenant(tenant_id, tenant_data)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant
@router.delete("/{tenant_id}", status_code=204)
async def delete_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
):
"""
Elimina (desactiva) un tenant
Requiere rol: admin
"""
service = TenantService(db)
if not service.delete_tenant(tenant_id):
raise HTTPException(status_code=404, detail="Tenant not found")
return None
@router.get("/slug/{slug}", response_model=TenantResponseDTO)
async def get_tenant_by_slug(
slug: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
):
"""
Obtiene un tenant por su slug
"""
service = TenantService(db)
tenant = service.get_tenant_by_slug(slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant

View File

@@ -0,0 +1,197 @@
"""
Capa de servicio para lógica de negocio de tenants
"""
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
from typing import List, Optional
import json
import logging
from .models import Tenant, TenantType
from .dto import TenantCreateDTO, TenantUpdateDTO, TenantResponseDTO
logger = logging.getLogger(__name__)
class TenantService:
"""Servicio para gestión de tenants"""
def __init__(self, db: Session):
self.db = db
def create_tenant(self, tenant_data: TenantCreateDTO) -> TenantResponseDTO:
"""
Crea un nuevo tenant en el sistema
Args:
tenant_data: Datos del tenant a crear
Returns:
TenantResponseDTO con información del tenant creado
Raises:
HTTPException: Si el slug o realm ya existen
"""
try:
# Verificar que no exista el slug
existing = self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first()
if existing:
raise HTTPException(status_code=400, detail=f"Tenant with slug '{tenant_data.slug}' already exists")
# Crear tenant
db_tenant = Tenant(
name=tenant_data.name,
slug=tenant_data.slug,
keycloak_realm=tenant_data.keycloak_realm,
type=TenantType(tenant_data.type.value),
contact_name=tenant_data.contact_name,
contact_email=tenant_data.contact_email,
contact_phone=tenant_data.contact_phone,
is_active=True
)
self.db.add(db_tenant)
self.db.commit()
self.db.refresh(db_tenant)
logger.info(f"Tenant created: {db_tenant.id} - {db_tenant.name}")
return TenantResponseDTO.model_validate(db_tenant)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating tenant: {str(e)}")
raise HTTPException(status_code=400, detail="Tenant with this slug or realm already exists")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating tenant: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating tenant")
def get_tenant(self, tenant_id: int) -> Optional[TenantResponseDTO]:
"""
Obtiene un tenant por ID
Args:
tenant_id: ID del tenant
Returns:
TenantResponseDTO o None si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
return TenantResponseDTO.model_validate(tenant)
def get_tenant_by_slug(self, slug: str) -> Optional[TenantResponseDTO]:
"""Obtiene un tenant por slug"""
tenant = self.db.query(Tenant).filter(Tenant.slug == slug).first()
if not tenant:
return None
return TenantResponseDTO.model_validate(tenant)
def list_tenants(self, skip: int = 0, limit: int = 100, active_only: bool = False) -> List[TenantResponseDTO]:
"""
Lista todos los tenants
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
active_only: Si True, solo retorna tenants activos
Returns:
Lista de TenantResponseDTO
"""
query = self.db.query(Tenant)
if active_only:
query = query.filter(Tenant.is_active == True)
tenants = query.offset(skip).limit(limit).all()
return [TenantResponseDTO.model_validate(t) for t in tenants]
def update_tenant(self, tenant_id: int, tenant_data: TenantUpdateDTO) -> Optional[TenantResponseDTO]:
"""
Actualiza un tenant
Args:
tenant_id: ID del tenant a actualizar
tenant_data: Datos a actualizar
Returns:
TenantResponseDTO actualizado o None si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
# Actualizar solo campos proporcionados
update_data = tenant_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tenant, field, value)
try:
self.db.commit()
self.db.refresh(tenant)
logger.info(f"Tenant updated: {tenant_id}")
return TenantResponseDTO.model_validate(tenant)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating tenant")
def delete_tenant(self, tenant_id: int) -> bool:
"""
Elimina (desactiva) un tenant
Args:
tenant_id: ID del tenant a eliminar
Returns:
True si se eliminó, False si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return False
# Soft delete: marcar como inactivo
tenant.is_active = False
try:
self.db.commit()
logger.info(f"Tenant deleted (soft): {tenant_id}")
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting tenant")
def upgrade_to_dedicated(self, tenant_id: int, db_config: dict) -> Optional[TenantResponseDTO]:
"""
Actualiza un tenant de BD compartida a BD dedicada
Args:
tenant_id: ID del tenant
db_config: Configuración de BD dedicada
Returns:
TenantResponseDTO actualizado
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
tenant.type = TenantType.DEDICATED
tenant.db_config = json.dumps(db_config)
try:
self.db.commit()
self.db.refresh(tenant)
logger.info(f"Tenant upgraded to dedicated DB: {tenant_id}")
return TenantResponseDTO.model_validate(tenant)
except Exception as e:
self.db.rollback()
logger.error(f"Error upgrading tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error upgrading tenant")