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:
6
backend/api/v1/modules/a76/auth/__init__.py
Normal file
6
backend/api/v1/modules/a76/auth/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Authentication
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
71
backend/api/v1/modules/a76/auth/dto.py
Normal file
71
backend/api/v1/modules/a76/auth/dto.py
Normal 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")
|
||||
86
backend/api/v1/modules/a76/auth/routes.py
Normal file
86
backend/api/v1/modules/a76/auth/routes.py
Normal 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"
|
||||
}
|
||||
175
backend/api/v1/modules/a76/auth/service.py
Normal file
175
backend/api/v1/modules/a76/auth/service.py
Normal 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")
|
||||
6
backend/api/v1/modules/a76/licenses/__init__.py
Normal file
6
backend/api/v1/modules/a76/licenses/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Licenses
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
143
backend/api/v1/modules/a76/licenses/dto.py
Normal file
143
backend/api/v1/modules/a76/licenses/dto.py
Normal 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
|
||||
89
backend/api/v1/modules/a76/licenses/models.py
Normal file
89
backend/api/v1/modules/a76/licenses/models.py
Normal 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})>"
|
||||
118
backend/api/v1/modules/a76/licenses/routes.py
Normal file
118
backend/api/v1/modules/a76/licenses/routes.py
Normal 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
|
||||
243
backend/api/v1/modules/a76/licenses/service.py
Normal file
243
backend/api/v1/modules/a76/licenses/service.py
Normal 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)
|
||||
)
|
||||
6
backend/api/v1/modules/a76/tenants/__init__.py
Normal file
6
backend/api/v1/modules/a76/tenants/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
97
backend/api/v1/modules/a76/tenants/dto.py
Normal file
97
backend/api/v1/modules/a76/tenants/dto.py
Normal 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
|
||||
50
backend/api/v1/modules/a76/tenants/models.py
Normal file
50
backend/api/v1/modules/a76/tenants/models.py
Normal 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})>"
|
||||
128
backend/api/v1/modules/a76/tenants/routes.py
Normal file
128
backend/api/v1/modules/a76/tenants/routes.py
Normal 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
|
||||
197
backend/api/v1/modules/a76/tenants/service.py
Normal file
197
backend/api/v1/modules/a76/tenants/service.py
Normal 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")
|
||||
Reference in New Issue
Block a user