chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/v1/__init__.py
Normal file
0
backend/api/v1/__init__.py
Normal file
33
backend/api/v1/common/base_models.py
Normal file
33
backend/api/v1/common/base_models.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
class BaseTimestampMixin:
|
||||
"""Mixin for basic timestamp fields (no soft delete)"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin(BaseTimestampMixin):
|
||||
"""Mixin for common timestamp fields including soft delete"""
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class TenantScopedMixin:
|
||||
"""Mixin para entidades multi-tenant.
|
||||
|
||||
company_id no tiene FK declarada aquí — agrégala en cada modelo
|
||||
apuntando a la tabla de compañías de tu proyecto.
|
||||
"""
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
|
||||
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
12
backend/api/v1/common/catalog_validation_errors.py
Normal file
12
backend/api/v1/common/catalog_validation_errors.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Errores de validación alineados a reglas CSV / catálogos (HTTP 422)."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class CatalogValidationError(Exception):
|
||||
"""Lista de errores tipo {line, col, msg} como en import CSV."""
|
||||
|
||||
def __init__(self, errors: List[Dict[str, Any]]):
|
||||
self.errors = errors or []
|
||||
first = self.errors[0].get("msg", "Validación de catálogo") if self.errors else "Validación de catálogo"
|
||||
super().__init__(first)
|
||||
121
backend/api/v1/common/crud_routes.py
Normal file
121
backend/api/v1/common/crud_routes.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from typing import Any, Callable, Generic, Type, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
ModelType = TypeVar("ModelType")
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
|
||||
|
||||
|
||||
class CRUDRouterFactory(
|
||||
Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]
|
||||
):
|
||||
"""Factory to create standard CRUD routes"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Type[ModelType],
|
||||
create_schema: Type[CreateSchemaType],
|
||||
update_schema: Type[UpdateSchemaType],
|
||||
response_schema: Type[ResponseSchemaType],
|
||||
db_dependency: Callable,
|
||||
auth_dependency: Callable,
|
||||
prefix: str,
|
||||
tags: list[str],
|
||||
id_field: str = "key",
|
||||
):
|
||||
self.model = model
|
||||
self.create_schema = create_schema
|
||||
self.update_schema = update_schema
|
||||
self.response_schema = response_schema
|
||||
self.db_dependency = db_dependency
|
||||
self.auth_dependency = auth_dependency
|
||||
self.id_field = id_field
|
||||
self.router = APIRouter(prefix=prefix, tags=tags)
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""Register all CRUD routes"""
|
||||
|
||||
@self.router.get("/", response_model=list[self.response_schema])
|
||||
def list_items(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
):
|
||||
items = db.query(self.model).offset(skip).limit(limit).all()
|
||||
return items
|
||||
|
||||
@self.router.get(f"/{{{self.id_field}}}", response_model=self.response_schema)
|
||||
def get_item(
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
**kwargs,
|
||||
):
|
||||
item_id = kwargs.get(self.id_field)
|
||||
obj = (
|
||||
db.query(self.model)
|
||||
.filter(getattr(self.model, self.id_field) == item_id)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return obj
|
||||
|
||||
@self.router.post("/", response_model=self.response_schema)
|
||||
def create_item(
|
||||
data: Any,
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
):
|
||||
obj = self.model(**data.dict())
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
@self.router.put(f"/{{{self.id_field}}}", response_model=self.response_schema)
|
||||
def update_item(
|
||||
data: Any,
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
**kwargs,
|
||||
):
|
||||
item_id = kwargs.get(self.id_field)
|
||||
obj = (
|
||||
db.query(self.model)
|
||||
.filter(getattr(self.model, self.id_field) == item_id)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
for field, value in data.dict(exclude_unset=True).items():
|
||||
setattr(obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
@self.router.delete(f"/{{{self.id_field}}}", status_code=204)
|
||||
def delete_item(
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: dict = Depends(self.auth_dependency),
|
||||
**kwargs,
|
||||
):
|
||||
item_id = kwargs.get(self.id_field)
|
||||
obj = (
|
||||
db.query(self.model)
|
||||
.filter(getattr(self.model, self.id_field) == item_id)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return None
|
||||
32
backend/api/v1/common/dto_mixins.py
Normal file
32
backend/api/v1/common/dto_mixins.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class CurrencyMixin:
|
||||
"""Mixin for currency-related fields"""
|
||||
|
||||
currency: Optional[str] = Field(None, max_length=3, description="Currency")
|
||||
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
|
||||
|
||||
|
||||
class AffectValueMixin:
|
||||
"""Mixin for value affect flags"""
|
||||
|
||||
not_affect_usd_value: Optional[bool] = Field(
|
||||
None, description="Not affect USD value"
|
||||
)
|
||||
not_affect_customs_value: Optional[bool] = Field(
|
||||
None, description="Not affect customs value"
|
||||
)
|
||||
|
||||
|
||||
class UpdateFlagsMixin:
|
||||
"""Mixin for update flags"""
|
||||
|
||||
update_vat: Optional[bool] = Field(None, description="Update VAT")
|
||||
update_advalorem: Optional[bool] = Field(None, description="Update advalorem")
|
||||
update_dta: Optional[bool] = Field(None, description="Update DTA")
|
||||
update_cc: Optional[bool] = Field(None, description="Update CC")
|
||||
update_ieps: Optional[bool] = Field(None, description="Update IEPS")
|
||||
651
backend/api/v1/common/tenant_crud_routes.py
Normal file
651
backend/api/v1/common/tenant_crud_routes.py
Normal file
@@ -0,0 +1,651 @@
|
||||
from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union
|
||||
import logging
|
||||
import inspect
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource, get_active_system
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
|
||||
|
||||
from api.v1.common.catalog_validation_errors import CatalogValidationError
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type variables for generic types
|
||||
ModelType = TypeVar("ModelType")
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
|
||||
ServiceType = TypeVar("ServiceType")
|
||||
|
||||
|
||||
class TenantCRUDRoutes(
|
||||
Generic[CreateSchemaType, UpdateSchemaType, ResponseSchemaType, ServiceType]
|
||||
):
|
||||
"""
|
||||
Generic CRUD routes factory for tenant-scoped resources
|
||||
|
||||
Supports both parent resources (with list/pagination) and child resources (nested under parent).
|
||||
|
||||
Usage examples:
|
||||
|
||||
1. Parent resource with list (e.g., /pedimentos):
|
||||
router = TenantCRUDRoutes(
|
||||
service=PedimentosService,
|
||||
create_schema=PedimentosCreate,
|
||||
update_schema=PedimentosUpdate,
|
||||
response_schema=PedimentosResponse,
|
||||
prefix="/pedimentos",
|
||||
tags=["Pedimentos"],
|
||||
resource_name="Pedimento",
|
||||
id_name="pedimento_id",
|
||||
enable_list=True,
|
||||
).router
|
||||
|
||||
2. Child resource (e.g., /pedimentos/{pedimento_id}/config-additional):
|
||||
router = TenantCRUDRoutes(
|
||||
service=PedimentoConfigAdditionalService,
|
||||
create_schema=PedimentoConfigAdditionalCreate,
|
||||
update_schema=PedimentoConfigAdditionalUpdate,
|
||||
response_schema=PedimentoConfigAdditionalResponse,
|
||||
prefix="/{pedimento_id}/config-additional",
|
||||
tags=["Pedimento Config Additional"],
|
||||
resource_name="Config additional",
|
||||
parent_id_name="pedimento_id",
|
||||
enable_list=False,
|
||||
).router
|
||||
|
||||
3. Parent resource with string ID (e.g., /vehicles with vehicle_key):
|
||||
router = TenantCRUDRoutes(
|
||||
service=VehicleService,
|
||||
create_schema=VehicleCreate,
|
||||
update_schema=VehicleUpdate,
|
||||
response_schema=VehicleResponse,
|
||||
prefix="/vehicles",
|
||||
tags=["Vehicles"],
|
||||
resource_name="Vehicle",
|
||||
id_name="vehicle_key",
|
||||
id_type=str, # Specify string type for vehicle_key
|
||||
enable_list=True,
|
||||
).router
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service: Type[ServiceType],
|
||||
create_schema: Type[CreateSchemaType],
|
||||
update_schema: Type[UpdateSchemaType],
|
||||
response_schema: Type[ResponseSchemaType],
|
||||
prefix: str,
|
||||
tags: list[str],
|
||||
resource_name: str = "Resource",
|
||||
# For parent resources (e.g., "pedimento_id")
|
||||
id_name: Optional[str] = None,
|
||||
id_type: Type = int, # Type of the ID (int, str, etc.)
|
||||
parent_id_name: Optional[
|
||||
str
|
||||
] = None, # For child resources (e.g., "pedimento_id")
|
||||
db_dependency: Callable = get_core_db,
|
||||
auth_dependency: Callable = get_current_user,
|
||||
validate_parent_match: bool = True, # Validate parent_id matches in create
|
||||
enable_list: bool = False, # Enable GET list endpoint with pagination
|
||||
enable_filters: bool = False, # Enable custom filters in list endpoint
|
||||
default_page_size: int = 50,
|
||||
max_page_size: int = 2000,
|
||||
# Permissions for each operation
|
||||
list_permissions: Optional[list[str]] = None,
|
||||
get_permissions: Optional[list[str]] = None,
|
||||
create_permissions: Optional[list[str]] = None,
|
||||
update_permissions: Optional[list[str]] = None,
|
||||
delete_permissions: Optional[list[str]] = None,
|
||||
require_all: bool = True, # If True, requires ALL permissions; if False, requires ANY
|
||||
):
|
||||
self.service = service
|
||||
self.create_schema = create_schema
|
||||
self.update_schema = update_schema
|
||||
self.response_schema = response_schema
|
||||
self.resource_name = resource_name
|
||||
self.id_name = id_name or parent_id_name or "id"
|
||||
self.id_type = id_type
|
||||
self.parent_id_name = parent_id_name
|
||||
self.db_dependency = db_dependency
|
||||
self.auth_dependency = auth_dependency
|
||||
self.validate_parent_match = validate_parent_match
|
||||
self.enable_list = enable_list
|
||||
self.enable_filters = enable_filters
|
||||
self.default_page_size = default_page_size
|
||||
self.max_page_size = max_page_size
|
||||
self.list_permissions = list_permissions
|
||||
self.get_permissions = get_permissions
|
||||
self.create_permissions = create_permissions
|
||||
self.update_permissions = update_permissions
|
||||
self.delete_permissions = delete_permissions
|
||||
self.require_all = require_all
|
||||
|
||||
self.router = APIRouter(prefix=prefix, tags=tags)
|
||||
self._register_routes()
|
||||
|
||||
def _register_routes(self):
|
||||
"""Register all CRUD routes"""
|
||||
|
||||
# LIST route (optional, for parent resources)
|
||||
if self.enable_list:
|
||||
if self.enable_filters:
|
||||
|
||||
@self.router.get(
|
||||
"/",
|
||||
response_model=Dict[str, Any],
|
||||
summary=f"List {self.resource_name}s",
|
||||
description=f"Get paginated list of {self.resource_name}s with optional filters",
|
||||
)
|
||||
async def list_resources(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(
|
||||
self.default_page_size,
|
||||
ge=1,
|
||||
le=self.max_page_size,
|
||||
description="Page size",
|
||||
),
|
||||
sort_by: Optional[str] = Query(None, description="Column to sort by"),
|
||||
sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
if all_companies:
|
||||
# Hub admin: tenant_id=None → el servicio devuelve todas las empresas
|
||||
tenant_id = resolve_tenant_id_required(current_user, db=db)
|
||||
target_company_id = None
|
||||
else:
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.list_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
target_company_id = company_id
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Extraer todos los parámetros de búsqueda dinámicamente
|
||||
# Excluimos los parámetros estándar de paginación y control
|
||||
standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"}
|
||||
filters = {
|
||||
k: v
|
||||
for k, v in request.query_params.items()
|
||||
if k not in standard_params and v is not None and v != ""
|
||||
}
|
||||
|
||||
# Inyectar active_system (header/cookie) si no viene por query param
|
||||
active_system = get_active_system(request)
|
||||
if active_system and "system" not in filters:
|
||||
filters["system"] = active_system
|
||||
|
||||
# Determine what parameters the service method accepts
|
||||
sig = inspect.signature(self.service.get_all)
|
||||
kwargs = {}
|
||||
if "sort_by" in sig.parameters:
|
||||
kwargs["sort_by"] = sort_by
|
||||
if "sort_order" in sig.parameters:
|
||||
kwargs["sort_order"] = sort_order
|
||||
|
||||
try:
|
||||
items, total = self.service.get_all(
|
||||
db, tenant_id, target_company_id, skip, page_size, filters, **kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {self.resource_name} list service: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error listing {self.resource_name}s: {str(e)}"
|
||||
)
|
||||
|
||||
try:
|
||||
return {
|
||||
"items": [
|
||||
self.response_schema.model_validate(item) for item in items
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating {self.resource_name} response schema: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Data validation error in {self.resource_name}"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@self.router.get(
|
||||
"/",
|
||||
response_model=Dict[str, Any],
|
||||
summary=f"List {self.resource_name}s",
|
||||
description=f"Get paginated list of {self.resource_name}s",
|
||||
)
|
||||
async def list_resources(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(
|
||||
self.default_page_size,
|
||||
ge=1,
|
||||
le=self.max_page_size,
|
||||
description="Page size",
|
||||
),
|
||||
sort_by: Optional[str] = Query(None, description="Column to sort by"),
|
||||
sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
if all_companies:
|
||||
# Hub admin: tenant_id=None → el servicio devuelve todas las empresas
|
||||
tenant_id = resolve_tenant_id_required(current_user, db=db)
|
||||
target_company_id = None
|
||||
else:
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.list_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
target_company_id = company_id
|
||||
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Determine what parameters the service method accepts
|
||||
sig = inspect.signature(self.service.get_all)
|
||||
kwargs = {}
|
||||
if "sort_by" in sig.parameters:
|
||||
kwargs["sort_by"] = sort_by
|
||||
if "sort_order" in sig.parameters:
|
||||
kwargs["sort_order"] = sort_order
|
||||
|
||||
try:
|
||||
items, total = self.service.get_all(
|
||||
db, tenant_id, target_company_id, skip, page_size, None, **kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {self.resource_name} list service: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error listing {self.resource_name}s: {str(e)}"
|
||||
)
|
||||
|
||||
try:
|
||||
return {
|
||||
"items": [
|
||||
self.response_schema.model_validate(item) for item in items
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating {self.resource_name} response schema: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Data validation error in {self.resource_name}"
|
||||
)
|
||||
|
||||
# GET single resource route
|
||||
# For parent resources: GET /{id}
|
||||
# For child resources: GET / (parent_id comes from path)
|
||||
if self.parent_id_name:
|
||||
# Child resource - single GET without ID in path
|
||||
@self.router.get(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Get {self.resource_name}",
|
||||
description=f"Get {self.resource_name} by {self.parent_id_name}",
|
||||
)
|
||||
async def get_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
**path_params,
|
||||
):
|
||||
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user, self.get_permissions, self.require_all
|
||||
)
|
||||
parent_id = path_params.get(self.parent_id_name)
|
||||
|
||||
# Try method with 4 params (pedimento_id, tenant_id, company_id)
|
||||
if hasattr(self.service, "get_by_pedimento_id"):
|
||||
resource = self.service.get_by_pedimento_id(
|
||||
db, parent_id, tenant_id, company_id
|
||||
)
|
||||
# Fallback to method with 3 params
|
||||
elif hasattr(self.service, "get_by_id"):
|
||||
resource = self.service.get_by_id(
|
||||
db, parent_id, tenant_id, company_id
|
||||
)
|
||||
else:
|
||||
resource = self.service.get(db, parent_id, tenant_id, company_id)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
else:
|
||||
# Parent resource - GET by ID in path
|
||||
@self.router.get(
|
||||
f"/{{{self.id_name}}}",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Get {self.resource_name} by ID",
|
||||
description=f"Get a specific {self.resource_name} by {self.id_name}",
|
||||
)
|
||||
async def get_resource_by_id(
|
||||
resource_id: Union[int, str] = Path(
|
||||
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user, self.get_permissions, self.require_all
|
||||
)
|
||||
|
||||
try:
|
||||
resource = self.service.get_by_id(
|
||||
db, resource_id, tenant_id, company_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {self.resource_name} get service: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error retrieving {self.resource_name}: {str(e)}"
|
||||
)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
# POST route
|
||||
if self.parent_id_name:
|
||||
# Child resource - needs parent_id from path
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
create_schema = self.create_schema
|
||||
|
||||
@self.router.post(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
status_code=201,
|
||||
summary=f"Create {self.resource_name}",
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_child_resource(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.create_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||
if self.enable_filters:
|
||||
active_system = get_active_system(request)
|
||||
if active_system and hasattr(data, "system"):
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
# For child resources, parent_id validation would go here
|
||||
try:
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
return resource
|
||||
except CatalogValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": str(e),
|
||||
"errors": e.errors,
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
# Capturar errores de validación (como duplicados)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
# Re-lanzar otros errores
|
||||
raise
|
||||
|
||||
else:
|
||||
# Parent resource - no parent_id needed
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
create_schema = self.create_schema
|
||||
|
||||
@self.router.post(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
status_code=201,
|
||||
summary=f"Create {self.resource_name}",
|
||||
description=f"Create a new {self.resource_name}",
|
||||
)
|
||||
async def create_parent_resource(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: create_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.create_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||
if self.enable_filters:
|
||||
active_system = get_active_system(request)
|
||||
if active_system and hasattr(data, "system"):
|
||||
data = data.model_copy(update={"system": active_system})
|
||||
|
||||
try:
|
||||
resource = self.service.create(db, data, tenant_id, company_id)
|
||||
return resource
|
||||
except CatalogValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": str(e),
|
||||
"errors": e.errors,
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
# Capturar errores de validación (como duplicados)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
# Re-lanzar otros errores
|
||||
raise
|
||||
|
||||
# PUT route
|
||||
# For parent resources: PUT /{id}
|
||||
# For child resources: PUT / (parent_id comes from path)
|
||||
if self.parent_id_name:
|
||||
# Child resource
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
update_schema = self.update_schema
|
||||
|
||||
@self.router.put(
|
||||
"/",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Update {self.resource_name}",
|
||||
description=f"Update an existing {self.resource_name}",
|
||||
)
|
||||
async def update_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: update_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
**path_params,
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.update_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
parent_id = path_params.get(self.parent_id_name)
|
||||
|
||||
try:
|
||||
resource = self.service.update(
|
||||
db, parent_id, tenant_id, data, company_id
|
||||
)
|
||||
except CatalogValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": str(e),
|
||||
"errors": e.errors,
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
# Capturar errores de validación (como duplicados)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
else:
|
||||
# Parent resource
|
||||
|
||||
# Create a closure to capture the schema type
|
||||
update_schema = self.update_schema
|
||||
|
||||
@self.router.put(
|
||||
f"/{{{self.id_name}}}/",
|
||||
response_model=self.response_schema,
|
||||
summary=f"Update {self.resource_name}",
|
||||
description=f"Update an existing {self.resource_name} by {self.id_name}",
|
||||
)
|
||||
async def update_resource_by_id(
|
||||
resource_id: Union[int, str] = Path(
|
||||
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
data: update_schema = Body(...), # type: ignore
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
f"""Update {self.resource_name}"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.update_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
try:
|
||||
resource = self.service.update(
|
||||
db, resource_id, tenant_id, data, company_id
|
||||
)
|
||||
except CatalogValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": str(e),
|
||||
"errors": e.errors,
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
# Capturar errores de validación (como duplicados)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return resource
|
||||
|
||||
# DELETE route
|
||||
# For parent resources: DELETE /{id}
|
||||
# For child resources: DELETE / (parent_id comes from path)
|
||||
if self.parent_id_name:
|
||||
# Child resource
|
||||
@self.router.delete(
|
||||
"/",
|
||||
status_code=204,
|
||||
summary=f"Delete {self.resource_name}",
|
||||
description=f"Delete an existing {self.resource_name}",
|
||||
)
|
||||
async def delete_resource(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
**path_params,
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.delete_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
parent_id = path_params.get(self.parent_id_name)
|
||||
|
||||
success = self.service.delete(db, parent_id, tenant_id, company_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return None
|
||||
|
||||
else:
|
||||
# Parent resource
|
||||
@self.router.delete(
|
||||
f"/{{{self.id_name}}}",
|
||||
status_code=204,
|
||||
summary=f"Delete {self.resource_name}",
|
||||
description=f"Delete an existing {self.resource_name} by {self.id_name}",
|
||||
)
|
||||
async def delete_resource_by_id(
|
||||
resource_id: Union[int, str] = Path(
|
||||
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||
),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(self.db_dependency),
|
||||
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
self.delete_permissions,
|
||||
self.require_all,
|
||||
)
|
||||
|
||||
success = self.service.delete(db, resource_id, tenant_id, company_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"{self.resource_name} not found"
|
||||
)
|
||||
return None
|
||||
0
backend/api/v1/modules/__init__.py
Normal file
0
backend/api/v1/modules/__init__.py
Normal file
7
backend/api/v1/modules/core/auth/__init__.py
Normal file
7
backend/api/v1/modules/core/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Authentication
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
210
backend/api/v1/modules/core/auth/dto.py
Normal file
210
backend/api/v1/modules/core/auth/dto.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
DTOs para módulo de autenticación
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class LoginRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de login"""
|
||||
|
||||
username: str = Field(..., description="Usuario o email")
|
||||
password: str = Field(..., min_length=6, description="Contraseña")
|
||||
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
|
||||
# y devuelve la lista de tenants disponibles en lugar de tokens.
|
||||
tenant_slug: Optional[str] = Field(None, 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
|
||||
tenant: Optional["TenantInfoDTO"] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
|
||||
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
|
||||
tenant_slug: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
roles: list[str] = []
|
||||
permissions: 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"],
|
||||
"permissions": ["cat_ports.view", "cat_ports.create"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LogoutRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de logout"""
|
||||
|
||||
refresh_token: str = Field(..., description="Refresh token para invalidar")
|
||||
username: Optional[str] = Field(None, description="Nombre de usuario para auditoría")
|
||||
|
||||
|
||||
class RegisterRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de registro"""
|
||||
|
||||
username: str = Field(
|
||||
..., min_length=3, max_length=50, description="Nombre de usuario"
|
||||
)
|
||||
email: EmailStr = Field(..., description="Email del usuario")
|
||||
password: str = Field(..., min_length=8, description="Contraseña")
|
||||
first_name: str = Field(..., min_length=2, max_length=50, description="Nombre")
|
||||
last_name: str = Field(..., min_length=2, max_length=50, description="Apellido")
|
||||
tenant_slug: str = Field(..., description="Slug del tenant")
|
||||
invite_token: Optional[str] = Field(None, description="Token de invitación local (opcional)")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"username": "jperez",
|
||||
"email": "jperez@ejemplo.com",
|
||||
"password": "MiPassword123!",
|
||||
"first_name": "Juan",
|
||||
"last_name": "Pérez",
|
||||
"tenant_slug": "empresa-abc",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RegisterResponseDTO(BaseModel):
|
||||
"""DTO para respuesta de registro"""
|
||||
|
||||
user_id: str
|
||||
username: str
|
||||
email: str
|
||||
message: str
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"username": "jperez",
|
||||
"email": "jperez@ejemplo.com",
|
||||
"message": "User registered successfully",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExchangeCodeRequestDTO(BaseModel):
|
||||
"""DTO para intercambiar authorization code por tokens (OAuth2 flow)"""
|
||||
|
||||
code: str = Field(..., description="Authorization code de OAuth2")
|
||||
redirect_uri: str = Field(..., description="Redirect URI usado en la autorización")
|
||||
tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...",
|
||||
"redirect_uri": "http://localhost:5173/auth/callback",
|
||||
"tenant_slug": "empresa-abc",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SetCookieRequestDTO(BaseModel):
|
||||
"""DTO para establecer cookies de autenticación"""
|
||||
|
||||
access_token: str = Field(..., description="Access token JWT")
|
||||
refresh_token: str = Field(..., description="Refresh token JWT")
|
||||
|
||||
|
||||
class SwitchTenantRequestDTO(BaseModel):
|
||||
"""DTO para cambiar de tenant estando autenticado"""
|
||||
|
||||
tenant_slug: str = Field(..., description="Slug del tenant destino")
|
||||
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
|
||||
|
||||
|
||||
class DiscoverTenantsRequestDTO(BaseModel):
|
||||
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
|
||||
|
||||
username: str = Field(..., description="Nombre de usuario o email")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"username": "jperez",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TenantInfoDTO(BaseModel):
|
||||
"""Información básica de un tenant para mostrar en el selector de login"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DiscoverTenantsResponseDTO(BaseModel):
|
||||
"""Respuesta con los tenants disponibles para un usuario"""
|
||||
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
|
||||
class LoginChoiceResponseDTO(BaseModel):
|
||||
"""
|
||||
Respuesta del login cuando el usuario pertenece a varios tenants.
|
||||
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
|
||||
"""
|
||||
|
||||
status: str = "choose_tenant"
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
|
||||
class SSOExchangeRequestDTO(BaseModel):
|
||||
"""DTO para canjear el relay token por KC tokens."""
|
||||
|
||||
relay_token: str = Field(..., description="Relay token recibido en la URL")
|
||||
422
backend/api/v1/modules/core/auth/routes.py
Normal file
422
backend/api/v1/modules/core/auth/routes.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
Endpoints API para autenticación
|
||||
"""
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
ExchangeCodeRequestDTO,
|
||||
LoginChoiceResponseDTO,
|
||||
LoginRequestDTO,
|
||||
LogoutRequestDTO,
|
||||
RefreshTokenRequestDTO,
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
SetCookieRequestDTO,
|
||||
SSOExchangeRequestDTO,
|
||||
SwitchTenantRequestDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
@router.get("/register/check")
|
||||
async def check_register(
|
||||
invite_token: str = Query(..., description="Token de invitación"),
|
||||
tenant_slug: str = Query(..., description="Slug del tenant"),
|
||||
email: str = Query(..., description="Email del usuario invitado"),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Valida un token de invitación y verifica si el email ya existe en Keycloak.
|
||||
No consume el token. Responde con user_exists y datos básicos del usuario si ya existe.
|
||||
"""
|
||||
from api.v1.modules.core.invites.service import InviteService
|
||||
import httpx
|
||||
from core.config import settings
|
||||
|
||||
invite_service = InviteService(db)
|
||||
# Valida token (lanza 403 si es inválido)
|
||||
invite_result = invite_service.validate(invite_token, tenant_slug, email)
|
||||
|
||||
# Intentar verificar si el email ya existe en el Hub usando service account
|
||||
user_exists = False
|
||||
user_info: dict = {}
|
||||
if settings.HUB_ADMIN_EMAIL and settings.HUB_ADMIN_PASSWORD:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Login con service account
|
||||
login_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||
)
|
||||
if login_resp.status_code == 200:
|
||||
svc_token = login_resp.json().get("access_token", "")
|
||||
if svc_token:
|
||||
# Buscar admin por email
|
||||
admins_resp = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||
params={"email": email},
|
||||
headers={"Authorization": f"Bearer {svc_token}"},
|
||||
)
|
||||
if admins_resp.status_code == 200:
|
||||
admins = admins_resp.json()
|
||||
if isinstance(admins, list):
|
||||
matches = [a for a in admins if a.get("email", "").lower() == email.lower()]
|
||||
elif isinstance(admins, dict) and "items" in admins:
|
||||
matches = [a for a in admins["items"] if a.get("email", "").lower() == email.lower()]
|
||||
else:
|
||||
matches = []
|
||||
if matches:
|
||||
user_exists = True
|
||||
a = matches[0]
|
||||
user_info = {
|
||||
"username": a.get("username", ""),
|
||||
"first_name": a.get("first_name", ""),
|
||||
"last_name": a.get("last_name", ""),
|
||||
}
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("register/check Hub lookup failed: %s", exc)
|
||||
|
||||
return {
|
||||
"email": invite_result.email,
|
||||
"role": invite_result.role,
|
||||
"user_exists": user_exists,
|
||||
**user_info,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponseDTO, status_code=201)
|
||||
async def register(
|
||||
register_data: RegisterRequestDTO, db: Session = Depends(get_core_db)
|
||||
):
|
||||
"""
|
||||
Registra un nuevo usuario en Keycloak
|
||||
|
||||
El usuario debe proporcionar:
|
||||
- username: Nombre de usuario único
|
||||
- email: Email único
|
||||
- password: Contraseña (mínimo 8 caracteres)
|
||||
- first_name: Nombre
|
||||
- last_name: Apellido
|
||||
- tenant_slug: Slug del tenant al que pertenece
|
||||
|
||||
El usuario se crea automáticamente en Keycloak con:
|
||||
- Cuenta habilitada
|
||||
- Rol 'user' asignado por defecto
|
||||
- Atributos de tenant
|
||||
"""
|
||||
service = AuthService(db)
|
||||
return await service.register(register_data)
|
||||
|
||||
|
||||
@router.post("/login", response_model=None)
|
||||
async def login(
|
||||
login_data: LoginRequestDTO,
|
||||
request: Request, # Inject Request
|
||||
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)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
return await service.login(
|
||||
login_data=login_data,
|
||||
ip_address=request.client.host,
|
||||
user_agent=request.headers.get("user-agent")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/switch-tenant", response_model=TokenResponseDTO)
|
||||
async def switch_tenant(
|
||||
data: SwitchTenantRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""
|
||||
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
|
||||
|
||||
Requiere:
|
||||
- Authorization: Bearer <access_token> (para identificar al usuario)
|
||||
- Body: { tenant_slug, refresh_token }
|
||||
"""
|
||||
service = AuthService(db)
|
||||
# Obtener info del usuario desde el access token actual
|
||||
user_info = await service.get_user_info(credentials.credentials)
|
||||
|
||||
keycloak_user_id = user_info.sub
|
||||
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
|
||||
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
|
||||
# Todos los tenants comparten el mismo realm en esta arquitectura.
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.database import get_core_db as _gcdb
|
||||
# Obtener el realm del tenant destino (o default)
|
||||
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return await service.switch_tenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
keycloak_realm=tenant.keycloak_realm,
|
||||
tenant_slug=data.tenant_slug,
|
||||
refresh_token=data.refresh_token,
|
||||
)
|
||||
|
||||
|
||||
@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 await 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 await service.get_user_info(credentials.credentials)
|
||||
|
||||
|
||||
@router.post("/lazy-link", status_code=200)
|
||||
async def lazy_link(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Vincula un invite pendiente al usuario autenticado (lazy-link).
|
||||
Se llama después de un SSO login desde el workspace para crear el UserTenant
|
||||
si hay un invite_token pendiente para el email del usuario.
|
||||
"""
|
||||
service = AuthService(db)
|
||||
try:
|
||||
await service._link_pending_invite(
|
||||
credentials.credentials, # username_or_email = token (fallback)
|
||||
access_token=credentials.credentials,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
claims = service._decode_kc_user_from_token(credentials.credentials)
|
||||
service._backfill_company_roles(claims.get("sub", ""))
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
logout_data: LogoutRequestDTO,
|
||||
request: Request, # Inject request for IP/User-Agent
|
||||
db: Session = Depends(get_core_db),
|
||||
# Make current_user optional to avoid 401 on expired tokens
|
||||
# We will try to use it if available, otherwise use DTO
|
||||
# Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency.
|
||||
# Instead, we will rely on DTO username since user explicitly asked for this simplified flow.
|
||||
# But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens.
|
||||
# So we remove the strict dependency for now as per "simplified" request.
|
||||
):
|
||||
"""
|
||||
Cierra sesión invalidando el refresh token
|
||||
"""
|
||||
# Extract info for logging (optional, but harmless to keep providing context if needed,
|
||||
# but strictly speaking we can revert to just calling service)
|
||||
# The original file likely didn't have IP extraction here unless I added it.
|
||||
# I'll keep it simple.
|
||||
|
||||
service = AuthService(db)
|
||||
return await service.logout(logout_data)
|
||||
|
||||
|
||||
@router.post("/exchange-code", response_model=TokenResponseDTO)
|
||||
async def exchange_code(
|
||||
exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db)
|
||||
):
|
||||
"""
|
||||
Intercambia un authorization code de OAuth2 por tokens
|
||||
|
||||
Este endpoint es útil cuando el frontend usa el flujo de autorización
|
||||
con proveedores externos (Microsoft, Google, etc.) a través de Keycloak.
|
||||
|
||||
El código se obtiene después de que el usuario se autentica con el proveedor
|
||||
externo y Keycloak lo redirige al frontend con el código en los query params.
|
||||
"""
|
||||
service = AuthService(db)
|
||||
return await service.exchange_code(exchange_data)
|
||||
|
||||
|
||||
@router.post("/set-cookie")
|
||||
async def set_cookie(
|
||||
cookie_data: SetCookieRequestDTO,
|
||||
response: Response,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Establece cookies HttpOnly con los tokens de autenticación
|
||||
|
||||
Este endpoint se llama desde el frontend después de una autenticación
|
||||
SSO exitosa para establecer las cookies de sesión necesarias para
|
||||
la validación server-side en los layouts protegidos.
|
||||
|
||||
Las cookies se configuran como:
|
||||
- HttpOnly: No accesibles desde JavaScript (mayor seguridad)
|
||||
- Secure: Solo se envían por HTTPS (en producción)
|
||||
- SameSite=Lax: Protección contra CSRF
|
||||
- Max-Age: Tiempo de vida del token
|
||||
"""
|
||||
# Validar que los tokens sean válidos decodificándolos
|
||||
service = AuthService(db)
|
||||
try:
|
||||
# Validar el access token
|
||||
user_info = await service.get_user_info(cookie_data.access_token)
|
||||
|
||||
# Establecer las cookies
|
||||
# Access token cookie
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=cookie_data.access_token,
|
||||
httponly=True, # No accesible desde JavaScript
|
||||
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||
samesite="lax", # Protección CSRF
|
||||
max_age=3600, # 1 hora (ajustar según configuración del token)
|
||||
path="/",
|
||||
)
|
||||
|
||||
# Refresh token cookie
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=cookie_data.refresh_token,
|
||||
httponly=True,
|
||||
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||
samesite="lax",
|
||||
max_age=86400, # 24 horas (ajustar según configuración del token)
|
||||
path="/",
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Cookies establecidas correctamente",
|
||||
"user": user_info,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/sso-exchange", response_model=TokenResponseDTO)
|
||||
async def sso_exchange(
|
||||
body: SSOExchangeRequestDTO,
|
||||
response: Response,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
|
||||
Llamado server-side desde la página /auth/sso del frontend.
|
||||
Establece cookies HttpOnly con los tokens y devuelve el resultado.
|
||||
"""
|
||||
service = AuthService(db)
|
||||
tokens = await service.sso_exchange(body.relay_token)
|
||||
|
||||
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=tokens.access_token,
|
||||
httponly=True,
|
||||
secure=_is_prod,
|
||||
samesite="lax",
|
||||
max_age=3600,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=tokens.refresh_token,
|
||||
httponly=True,
|
||||
secure=_is_prod,
|
||||
samesite="lax",
|
||||
max_age=86400,
|
||||
path="/",
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dev-only local auth — solo disponible cuando DEV_LOCAL_AUTH=True
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/dev-login")
|
||||
async def dev_login():
|
||||
"""
|
||||
Genera un token local firmado con SECRET_KEY para desarrollo sin Keycloak/Hub.
|
||||
Disponible únicamente cuando DEV_LOCAL_AUTH=True en el entorno.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from jose import jwt as jose_jwt
|
||||
from core.config import settings
|
||||
|
||||
if not settings.DEV_LOCAL_AUTH:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": "dev-local-user",
|
||||
"email": settings.DEV_LOCAL_AUTH_EMAIL,
|
||||
"preferred_username": settings.DEV_LOCAL_AUTH_EMAIL.split("@")[0],
|
||||
"name": settings.DEV_LOCAL_AUTH_NAME,
|
||||
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
|
||||
"tenant_slug": "dev",
|
||||
"company_id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
|
||||
"roles": ["super_admin"],
|
||||
"permissions": [],
|
||||
"allowed_systems": ["fixed_asset", "inventory"],
|
||||
"dev_local": True,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=8),
|
||||
}
|
||||
token = jose_jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/my-companies")
|
||||
async def get_my_companies(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Retorna las compañías accesibles para el usuario actual.
|
||||
STUB: implementa con tu modelo de compañías.
|
||||
En dev-local retorna una compañía ficticia para que el dashboard funcione.
|
||||
"""
|
||||
from core.config import settings
|
||||
|
||||
if settings.DEV_LOCAL_AUTH and current_user.get("dev_local"):
|
||||
return [{
|
||||
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
|
||||
"name": "Empresa Dev Local",
|
||||
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
|
||||
"is_active": True,
|
||||
}]
|
||||
|
||||
# Implementa aquí la consulta real a tu tabla de compañías.
|
||||
return []
|
||||
847
backend/api/v1/modules/core/auth/service.py
Normal file
847
backend/api/v1/modules/core/auth/service.py
Normal file
@@ -0,0 +1,847 @@
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Any, Dict, Optional
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from core.config import settings
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
LoginRequestDTO,
|
||||
LogoutRequestDTO,
|
||||
RefreshTokenRequestDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Servicio de autenticación centralizado vía Hub"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str):
|
||||
cleaned = value.strip()
|
||||
if cleaned:
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
def _pick_text(self, *candidates: Any) -> Optional[str]:
|
||||
for candidate in candidates:
|
||||
value = self._clean_text(candidate)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
|
||||
try:
|
||||
claims = jwt.get_unverified_claims(access_token)
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
except JWTError:
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fallback de datos de usuario consultando el Hub admin API.
|
||||
Es opcional y no debe romper /me si falla.
|
||||
"""
|
||||
if not keycloak_user_id:
|
||||
return None
|
||||
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
login_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json={
|
||||
"username": settings.HUB_ADMIN_EMAIL,
|
||||
"password": settings.HUB_ADMIN_PASSWORD,
|
||||
},
|
||||
)
|
||||
if login_resp.status_code != 200:
|
||||
return None
|
||||
|
||||
admin_token = login_resp.json().get("access_token")
|
||||
if not admin_token:
|
||||
return None
|
||||
|
||||
user_resp = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
if user_resp.status_code == 200:
|
||||
payload = user_resp.json()
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception as exc:
|
||||
logger.debug("kc_admin_user_lookup_failed: %s", exc)
|
||||
|
||||
return None
|
||||
|
||||
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
|
||||
for source in sources:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
|
||||
direct = self._pick_text(
|
||||
source.get("avatar_url"),
|
||||
source.get("avatarUrl"),
|
||||
source.get("picture"),
|
||||
source.get("photo"),
|
||||
)
|
||||
if direct:
|
||||
return direct
|
||||
|
||||
attrs = source.get("attributes")
|
||||
if isinstance(attrs, dict):
|
||||
attr_candidate = attrs.get("avatar_url")
|
||||
if isinstance(attr_candidate, list) and attr_candidate:
|
||||
value = self._clean_text(attr_candidate[0])
|
||||
if value:
|
||||
return value
|
||||
if isinstance(attr_candidate, str):
|
||||
value = self._clean_text(attr_candidate)
|
||||
if value:
|
||||
return value
|
||||
|
||||
return None
|
||||
|
||||
async def login(
|
||||
self,
|
||||
login_data: LoginRequestDTO,
|
||||
ip_address: str = None,
|
||||
user_agent: str = None
|
||||
):
|
||||
"""
|
||||
Autentica usuario a través del Hub y obtiene tokens.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json=login_data.model_dump()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
|
||||
if "tenants" in data:
|
||||
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
|
||||
return LoginChoiceResponseDTO(
|
||||
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
|
||||
)
|
||||
|
||||
# Si devolvió tokens — lazy-link: verificar si hay invite pendiente
|
||||
try:
|
||||
await self._link_pending_invite(login_data.username)
|
||||
except Exception as exc:
|
||||
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
|
||||
|
||||
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
|
||||
try:
|
||||
login_sub = data.get("sub") or data.get("user_id")
|
||||
if not login_sub:
|
||||
claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||
login_sub = claims.get("sub")
|
||||
self._backfill_company_roles(login_sub)
|
||||
except Exception as exc:
|
||||
logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)
|
||||
|
||||
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
|
||||
# No bloquea login si Workspace no responde.
|
||||
access_token = data.get("access_token")
|
||||
if access_token:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"phase": "login",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
workspace_profile = None
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||
or data.get("sub")
|
||||
or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
|
||||
# AUDIT LOG: implementa tu servicio de auditoría aquí si lo necesitas.
|
||||
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
# Pasar el mensaje de error real del Hub al cliente
|
||||
try:
|
||||
hub_detail = response.json().get("detail", None)
|
||||
except Exception:
|
||||
hub_detail = None
|
||||
|
||||
if response.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
|
||||
|
||||
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
||||
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Hub unreachable during login: {str(e)}")
|
||||
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected login error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Authentication error")
|
||||
|
||||
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||
"""
|
||||
Refresca el access token usando el Hub
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json=refresh_data.model_dump()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(
|
||||
data.get("access_token", "")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"phase": "refresh",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
workspace_profile = None
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=data.get("access_token"),
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||
or data.get("sub")
|
||||
or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
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")
|
||||
|
||||
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
|
||||
"""
|
||||
Obtiene información del usuario desde el Hub
|
||||
"""
|
||||
from core.security import verify_token
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
# Aprovechamos la verificación (y cache) de security.py
|
||||
user_info = await verify_token(access_token)
|
||||
|
||||
kc_user = self._decode_kc_user_from_token(access_token)
|
||||
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
|
||||
|
||||
needs_admin_fallback = any(
|
||||
not self._clean_text(user_info.get(field))
|
||||
for field in ("email", "preferred_username")
|
||||
) or self._extract_avatar_url(user_info) is None
|
||||
|
||||
kc_admin_user = None
|
||||
if needs_admin_fallback:
|
||||
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
|
||||
|
||||
first_name = self._pick_text(
|
||||
user_info.get("first_name"),
|
||||
user_info.get("given_name"),
|
||||
kc_user.get("given_name"),
|
||||
kc_user.get("first_name"),
|
||||
(kc_admin_user or {}).get("firstName"),
|
||||
(kc_admin_user or {}).get("first_name"),
|
||||
)
|
||||
last_name = self._pick_text(
|
||||
user_info.get("last_name"),
|
||||
user_info.get("family_name"),
|
||||
kc_user.get("family_name"),
|
||||
kc_user.get("last_name"),
|
||||
(kc_admin_user or {}).get("lastName"),
|
||||
(kc_admin_user or {}).get("last_name"),
|
||||
)
|
||||
full_name = self._pick_text(
|
||||
f"{first_name} {last_name}" if first_name and last_name else None,
|
||||
first_name,
|
||||
last_name,
|
||||
)
|
||||
|
||||
enriched_user_info = dict(user_info)
|
||||
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
|
||||
enriched_user_info["email"] = self._pick_text(
|
||||
user_info.get("email"),
|
||||
(kc_admin_user or {}).get("email"),
|
||||
kc_user.get("email"),
|
||||
)
|
||||
enriched_user_info["preferred_username"] = self._pick_text(
|
||||
user_info.get("preferred_username"),
|
||||
user_info.get("username"),
|
||||
kc_user.get("preferred_username"),
|
||||
kc_user.get("username"),
|
||||
(kc_admin_user or {}).get("username"),
|
||||
)
|
||||
enriched_user_info["avatar_url"] = self._extract_avatar_url(
|
||||
user_info,
|
||||
kc_user,
|
||||
kc_admin_user or {},
|
||||
)
|
||||
enriched_user_info["name"] = self._pick_text(
|
||||
user_info.get("name"),
|
||||
full_name,
|
||||
kc_user.get("name"),
|
||||
enriched_user_info.get("preferred_username"),
|
||||
)
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=enriched_user_info.get("sub"),
|
||||
tenant_id=enriched_user_info.get("tenant_id"),
|
||||
workspace_profile=enriched_user_info,
|
||||
)
|
||||
return UserInfoResponseDTO(**enriched_user_info)
|
||||
|
||||
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
|
||||
"""
|
||||
Cierra sesión a través del Hub
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/logout",
|
||||
json=logout_data.model_dump()
|
||||
)
|
||||
return {"message": "Logged out successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Logout error: {str(e)}")
|
||||
return {"message": "Logged out"}
|
||||
|
||||
async def register(self, register_data: Any) -> Any:
|
||||
"""
|
||||
Registra un usuario.
|
||||
- Si trae invite_token: valida el token local, crea usuario en Hub y
|
||||
genera la fila UserTenant local, luego consume el token.
|
||||
- Si no trae invite_token: reenvía directamente al Hub (flujo original).
|
||||
"""
|
||||
if getattr(register_data, "invite_token", None):
|
||||
return await self._register_with_invite(register_data)
|
||||
|
||||
# Flujo original — reenviar al Hub sin invite_token
|
||||
try:
|
||||
payload = register_data.model_dump(exclude={"invite_token"})
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/register",
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code == 201:
|
||||
return response.json()
|
||||
raise HTTPException(status_code=response.status_code, detail=response.text)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Registration error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Registration error")
|
||||
|
||||
async def _register_with_invite(self, register_data: Any) -> Any:
|
||||
"""Flujo de registro con token de invitación local."""
|
||||
from api.v1.modules.core.invites.service import InviteService
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
invite_service = InviteService(self.db)
|
||||
|
||||
# 1. Validar invite token (sin consumir)
|
||||
invite_result = invite_service.validate(
|
||||
register_data.invite_token,
|
||||
register_data.tenant_slug,
|
||||
str(register_data.email),
|
||||
)
|
||||
|
||||
# 2. Buscar tenant local
|
||||
tenant = (
|
||||
self.db.query(Tenant)
|
||||
.filter(Tenant.slug == register_data.tenant_slug)
|
||||
.first()
|
||||
)
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant no encontrado")
|
||||
|
||||
# 3. Obtener token de service account y gestionar usuario en Hub
|
||||
hub_user_id = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
# Login con service account
|
||||
login_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json={
|
||||
"username": settings.HUB_ADMIN_EMAIL,
|
||||
"password": settings.HUB_ADMIN_PASSWORD,
|
||||
},
|
||||
)
|
||||
if login_resp.status_code != 200:
|
||||
raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación")
|
||||
svc_token = login_resp.json().get("access_token", "")
|
||||
|
||||
# Verificar si el usuario ya existe en el Hub
|
||||
search_resp = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||
params={"email": str(register_data.email)},
|
||||
headers={"Authorization": f"Bearer {svc_token}"},
|
||||
)
|
||||
existing_user = None
|
||||
if search_resp.status_code == 200:
|
||||
admins = search_resp.json()
|
||||
items = admins if isinstance(admins, list) else admins.get("items", [])
|
||||
matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()]
|
||||
if matches:
|
||||
existing_user = matches[0]
|
||||
|
||||
if existing_user:
|
||||
# Usuario ya existe — solo vinculamos (no creamos nuevo)
|
||||
hub_user_id = existing_user.get("id")
|
||||
else:
|
||||
# Crear usuario via admin endpoint (no requiere invite_token)
|
||||
hub_payload = {
|
||||
"username": register_data.username,
|
||||
"email": str(register_data.email),
|
||||
"password": register_data.password,
|
||||
"first_name": register_data.first_name,
|
||||
"last_name": register_data.last_name,
|
||||
"tenant_slug": register_data.tenant_slug,
|
||||
}
|
||||
create_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||
json=hub_payload,
|
||||
headers={"Authorization": f"Bearer {svc_token}"},
|
||||
)
|
||||
if create_resp.status_code in (200, 201):
|
||||
hub_user_id = create_resp.json().get("id")
|
||||
else:
|
||||
try:
|
||||
detail = create_resp.json().get("detail", create_resp.text)
|
||||
except Exception:
|
||||
detail = create_resp.text
|
||||
raise HTTPException(status_code=create_resp.status_code, detail=detail)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Hub admin create error during invite flow: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
|
||||
|
||||
# 4. Crear fila UserTenant y UserCompanyRole local
|
||||
if hub_user_id and invite_result.company_id:
|
||||
try:
|
||||
ut = UserTenant(
|
||||
keycloak_user_id=hub_user_id,
|
||||
tenant_id=tenant.id,
|
||||
company_id=invite_result.company_id,
|
||||
role=invite_result.role,
|
||||
is_active=True,
|
||||
first_name=register_data.first_name,
|
||||
last_name=register_data.last_name,
|
||||
)
|
||||
self.db.add(ut)
|
||||
self.db.flush()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not create UserTenant (may already exist): %s", exc)
|
||||
self.db.rollback()
|
||||
|
||||
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
|
||||
company_role_obj = (
|
||||
self.db.query(CompanyRole)
|
||||
.filter(
|
||||
CompanyRole.code == invite_result.role,
|
||||
CompanyRole.company_id == invite_result.company_id,
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if company_role_obj:
|
||||
existing_ucr = (
|
||||
self.db.query(UserCompanyRole)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == hub_user_id,
|
||||
UserCompanyRole.company_role_id == company_role_obj.id,
|
||||
UserCompanyRole.company_id == invite_result.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing_ucr:
|
||||
ucr = UserCompanyRole(
|
||||
user_id=hub_user_id,
|
||||
company_role_id=company_role_obj.id,
|
||||
company_id=invite_result.company_id,
|
||||
tenant_id=tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(ucr)
|
||||
self.db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
|
||||
self.db.rollback()
|
||||
|
||||
# 5. Consumir invite token
|
||||
invite_service.consume_by_id(invite_result.invite_id)
|
||||
|
||||
return {
|
||||
"user_id": hub_user_id or "",
|
||||
"username": register_data.username,
|
||||
"email": str(register_data.email),
|
||||
"message": "Usuario registrado exitosamente",
|
||||
}
|
||||
|
||||
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
|
||||
"""
|
||||
Intercambia código por tokens a través del Hub
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/exchange-code",
|
||||
json=exchange_data.model_dump()
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# Lazy-link: crear UserTenant si hay invite pendiente
|
||||
try:
|
||||
await self._link_pending_invite("", access_token=data.get("access_token", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
|
||||
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
|
||||
try:
|
||||
ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||
self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
|
||||
except Exception as exc:
|
||||
logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
|
||||
return TokenResponseDTO(**data)
|
||||
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
|
||||
except Exception as e:
|
||||
logger.error(f"Exchange code error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Exchange code error")
|
||||
|
||||
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
|
||||
"""
|
||||
Cambia de tenant a través del Hub
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
|
||||
json=kwargs
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return TokenResponseDTO(**response.json())
|
||||
raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
|
||||
except Exception as e:
|
||||
logger.error(f"Switch tenant error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Switch tenant error")
|
||||
|
||||
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
|
||||
"""
|
||||
Canjea un relay token de un solo uso por KC tokens.
|
||||
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
|
||||
json={"relay_token": relay_token},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
|
||||
try:
|
||||
await self._link_pending_invite("", access_token=data.get("access_token", ""))
|
||||
except Exception as exc:
|
||||
logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
|
||||
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
|
||||
try:
|
||||
sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||
self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
|
||||
except Exception as exc:
|
||||
logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
|
||||
return TokenResponseDTO(
|
||||
access_token=data["access_token"],
|
||||
refresh_token=data["refresh_token"],
|
||||
token_type=data.get("token_type", "bearer"),
|
||||
expires_in=data.get("expires_in", 3600),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
tenant_slug=data.get("tenant_slug"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=response.json().get("detail", "SSO exchange failed"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"SSO exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="SSO exchange error")
|
||||
|
||||
async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
|
||||
"""
|
||||
Lazy-link: después de un login exitoso comprueba si existe un invite_token
|
||||
pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
|
||||
y consume el token.
|
||||
|
||||
Si se provee access_token, extrae hub_user_id y email directamente del JWT
|
||||
sin necesidad de un lookup extra al Hub.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from api.v1.modules.core.invites.models import InviteToken
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
hub_user_id = None
|
||||
user_email = username_or_email
|
||||
|
||||
# Si tenemos el access_token, extraer info del JWT directamente (sin red)
|
||||
if access_token:
|
||||
try:
|
||||
claims = self._decode_kc_user_from_token(access_token)
|
||||
hub_user_id = claims.get("sub")
|
||||
user_email = claims.get("email") or username_or_email
|
||||
except Exception as exc:
|
||||
logger.debug("_link_pending_invite: JWT decode failed: %s", exc)
|
||||
|
||||
# Sin access_token: buscar usuario en el Hub vía service account
|
||||
if not hub_user_id:
|
||||
if not user_email:
|
||||
return # Sin email ni hub_user_id no podemos buscar el invite
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
login_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/login",
|
||||
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||
)
|
||||
if login_resp.status_code != 200:
|
||||
return
|
||||
svc_token = login_resp.json().get("access_token", "")
|
||||
search_resp = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||
params={"email": username_or_email},
|
||||
headers={"Authorization": f"Bearer {svc_token}"},
|
||||
)
|
||||
if search_resp.status_code == 200:
|
||||
items = search_resp.json()
|
||||
items = items if isinstance(items, list) else items.get("items", [])
|
||||
matches = [
|
||||
u for u in items
|
||||
if u.get("email", "").lower() == username_or_email.lower()
|
||||
or u.get("username", "").lower() == username_or_email.lower()
|
||||
]
|
||||
if matches:
|
||||
hub_user_id = matches[0].get("id")
|
||||
user_email = matches[0].get("email", username_or_email)
|
||||
if not hub_user_id:
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
pending = (
|
||||
self.db.query(InviteToken)
|
||||
.filter(
|
||||
InviteToken.email == user_email,
|
||||
InviteToken.used_at.is_(None),
|
||||
InviteToken.expires_at > now,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not pending:
|
||||
return
|
||||
|
||||
tenant = (
|
||||
self.db.query(Tenant)
|
||||
.filter(Tenant.slug == pending.tenant_slug)
|
||||
.first()
|
||||
)
|
||||
if not tenant:
|
||||
logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
|
||||
return
|
||||
|
||||
# Evitar duplicados
|
||||
existing = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == hub_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
# Vincular existe, solo consumir el token
|
||||
pending.used_at = now
|
||||
self.db.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
ut = UserTenant(
|
||||
keycloak_user_id=hub_user_id,
|
||||
tenant_id=tenant.id,
|
||||
company_id=pending.company_id,
|
||||
role=pending.role,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(ut)
|
||||
self.db.flush()
|
||||
logger.info(
|
||||
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
|
||||
hub_user_id,
|
||||
tenant.slug,
|
||||
pending.role,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
|
||||
self.db.rollback()
|
||||
|
||||
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
|
||||
if pending.company_id:
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
|
||||
company_role_obj = (
|
||||
self.db.query(CompanyRole)
|
||||
.filter(
|
||||
CompanyRole.code == pending.role,
|
||||
CompanyRole.company_id == pending.company_id,
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if company_role_obj:
|
||||
existing_ucr = (
|
||||
self.db.query(UserCompanyRole)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == hub_user_id,
|
||||
UserCompanyRole.company_role_id == company_role_obj.id,
|
||||
UserCompanyRole.company_id == pending.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing_ucr:
|
||||
ucr = UserCompanyRole(
|
||||
user_id=hub_user_id,
|
||||
company_role_id=company_role_obj.id,
|
||||
company_id=pending.company_id,
|
||||
tenant_id=tenant.id,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(ucr)
|
||||
except Exception as exc:
|
||||
logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)
|
||||
|
||||
pending.used_at = now
|
||||
self.db.commit()
|
||||
|
||||
def _backfill_company_roles(self, hub_user_id: str) -> None:
|
||||
"""
|
||||
Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
|
||||
pero no UserCompanyRole (creados antes del fix del flujo de invitación).
|
||||
Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
|
||||
el UserCompanyRole si no existe. Non-blocking.
|
||||
"""
|
||||
if not hub_user_id:
|
||||
return
|
||||
try:
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
|
||||
|
||||
user_tenants = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == hub_user_id,
|
||||
UserTenant.is_active == True,
|
||||
UserTenant.company_id.isnot(None),
|
||||
UserTenant.role.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
changed = False
|
||||
for ut in user_tenants:
|
||||
company_role_obj = (
|
||||
self.db.query(CompanyRole)
|
||||
.filter(
|
||||
CompanyRole.code == ut.role,
|
||||
CompanyRole.company_id == ut.company_id,
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not company_role_obj:
|
||||
continue
|
||||
|
||||
existing = (
|
||||
self.db.query(UserCompanyRole)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == hub_user_id,
|
||||
UserCompanyRole.company_role_id == company_role_obj.id,
|
||||
UserCompanyRole.company_id == ut.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
self.db.add(UserCompanyRole(
|
||||
user_id=hub_user_id,
|
||||
company_role_id=company_role_obj.id,
|
||||
company_id=ut.company_id,
|
||||
tenant_id=ut.tenant_id,
|
||||
is_active=True,
|
||||
))
|
||||
changed = True
|
||||
logger.info(
|
||||
"backfill: UserCompanyRole created for user=%s company=%s role=%s",
|
||||
hub_user_id, ut.company_id, ut.role,
|
||||
)
|
||||
|
||||
if changed:
|
||||
self.db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
|
||||
self.db.rollback()
|
||||
|
||||
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de dashboard para estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
109
backend/api/v1/modules/core/dashboard/dto.py
Normal file
109
backend/api/v1/modules/core/dashboard/dto.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
DTOs para el dashboard de estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class KPIMetric(BaseModel):
|
||||
"""Métrica individual de KPI"""
|
||||
|
||||
label: str = Field(..., description="Nombre del indicador")
|
||||
value: int | float = Field(..., description="Valor actual")
|
||||
previous_value: Optional[int | float] = Field(
|
||||
None, description="Valor anterior para comparación"
|
||||
)
|
||||
percentage_change: Optional[float] = Field(None, description="Porcentaje de cambio")
|
||||
trend: Optional[str] = Field(None, description="up, down, stable")
|
||||
unit: Optional[str] = Field(None, description="Unidad de medida (%, USD, etc)")
|
||||
|
||||
|
||||
class ActivityItem(BaseModel):
|
||||
"""Item de actividad reciente"""
|
||||
|
||||
id: int
|
||||
type: str = Field(
|
||||
..., description="Tipo de actividad: invoice, pedimento, client, etc"
|
||||
)
|
||||
title: str = Field(..., description="Título descriptivo")
|
||||
description: Optional[str] = Field(None, description="Descripción adicional")
|
||||
timestamp: datetime
|
||||
status: Optional[str] = Field(None, description="Estado del item")
|
||||
icon: Optional[str] = Field(None, description="Icono a mostrar")
|
||||
|
||||
|
||||
class ChartDataPoint(BaseModel):
|
||||
"""Punto de datos para gráficas"""
|
||||
|
||||
label: str
|
||||
value: float
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Estadísticas generales del dashboard"""
|
||||
|
||||
# KPIs principales
|
||||
total_invoices: KPIMetric
|
||||
total_pedimentos: KPIMetric
|
||||
total_clients: KPIMetric
|
||||
total_providers: KPIMetric
|
||||
active_items: KPIMetric
|
||||
pending_approvals: KPIMetric
|
||||
|
||||
# Estadísticas financieras
|
||||
total_value_imports: Optional[float] = Field(
|
||||
None, description="Valor total de importaciones"
|
||||
)
|
||||
total_value_exports: Optional[float] = Field(
|
||||
None, description="Valor total de exportaciones"
|
||||
)
|
||||
|
||||
# Datos para gráficas
|
||||
invoices_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||
pedimentos_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||
operations_by_type: List[ChartDataPoint] = Field(default_factory=list)
|
||||
top_clients: List[ChartDataPoint] = Field(default_factory=list)
|
||||
top_providers: List[ChartDataPoint] = Field(default_factory=list)
|
||||
|
||||
# Actividad reciente
|
||||
recent_activity: List[ActivityItem] = Field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
company_id: int
|
||||
company_name: Optional[str] = None
|
||||
|
||||
|
||||
class OperationsOverview(BaseModel):
|
||||
"""Vista general de operaciones"""
|
||||
|
||||
total_operations: int
|
||||
by_type: Dict[str, int] = Field(default_factory=dict)
|
||||
by_status: Dict[str, int] = Field(default_factory=dict)
|
||||
avg_processing_time: Optional[float] = Field(
|
||||
None, description="Tiempo promedio en días"
|
||||
)
|
||||
|
||||
|
||||
class InventoryMetrics(BaseModel):
|
||||
"""Métricas de inventario"""
|
||||
|
||||
total_items: int
|
||||
items_in_stock: int
|
||||
items_low_stock: int
|
||||
total_value: Optional[float] = None
|
||||
by_category: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ComplianceMetrics(BaseModel):
|
||||
"""Métricas de cumplimiento normativo"""
|
||||
|
||||
pending_documents: int
|
||||
expired_permits: int
|
||||
upcoming_deadlines: int
|
||||
compliance_score: Optional[float] = Field(
|
||||
None, description="Score de cumplimiento 0-100"
|
||||
)
|
||||
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Endpoints del dashboard para estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from .dto import DashboardStats, OperationsOverview, InventoryMetrics
|
||||
from .service import DashboardService
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas completas del dashboard para la compañía especificada.
|
||||
|
||||
Incluye:
|
||||
- KPIs principales (facturas, pedimentos, clientes, proveedores, items)
|
||||
- Gráficas de tendencias (facturas por mes, operaciones por tipo)
|
||||
- Top clientes y proveedores
|
||||
- Actividad reciente
|
||||
"""
|
||||
# Validar acceso
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
# Generar estadísticas
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
stats = service.get_complete_dashboard_stats()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/operations-overview", response_model=OperationsOverview)
|
||||
async def get_operations_overview(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene una vista general de las operaciones
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
|
||||
# Implementación básica
|
||||
ops_by_type = service.get_operations_by_type()
|
||||
|
||||
return OperationsOverview(
|
||||
total_operations=sum(int(op.value) for op in ops_by_type),
|
||||
by_type={op.label: int(op.value) for op in ops_by_type},
|
||||
by_status={},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inventory-metrics", response_model=InventoryMetrics)
|
||||
async def get_inventory_metrics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene métricas de inventario
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
items_kpi = service.get_items_metrics()
|
||||
|
||||
return InventoryMetrics(
|
||||
total_items=int(items_kpi.value),
|
||||
items_in_stock=int(items_kpi.value), # Simplificado
|
||||
items_low_stock=0,
|
||||
by_category={},
|
||||
)
|
||||
43
backend/api/v1/modules/core/dashboard/service.py
Normal file
43
backend/api/v1/modules/core/dashboard/service.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Servicio del dashboard — STUB.
|
||||
Implementa las métricas de tu proyecto aquí.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import DashboardStats, KPIMetric, OperationsOverview, InventoryMetrics
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Stub — reemplaza con las consultas de tu proyecto."""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: int, company_id: int):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
self.company_id = company_id
|
||||
|
||||
def get_stats(self) -> DashboardStats:
|
||||
empty_kpi = KPIMetric(label="", value=0, trend="stable")
|
||||
return DashboardStats(
|
||||
company_id=self.company_id,
|
||||
generated_at="",
|
||||
total_invoices=empty_kpi,
|
||||
total_pedimentos=empty_kpi,
|
||||
total_clients=empty_kpi,
|
||||
total_providers=empty_kpi,
|
||||
active_items=empty_kpi,
|
||||
pending_approvals=empty_kpi,
|
||||
invoices_by_month=[],
|
||||
operations_by_type=[],
|
||||
top_clients=[],
|
||||
top_providers=[],
|
||||
recent_activity=[],
|
||||
)
|
||||
|
||||
def get_operations_overview(self) -> OperationsOverview:
|
||||
return OperationsOverview(total_operations=0, by_type={}, by_status={})
|
||||
|
||||
def get_inventory_metrics(self) -> InventoryMetrics:
|
||||
return InventoryMetrics(
|
||||
total_items=0, items_in_stock=0, items_low_stock=0, by_category={}
|
||||
)
|
||||
32
backend/api/v1/modules/core/help_center/models.py
Normal file
32
backend/api/v1/modules/core/help_center/models.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from core.database import Base
|
||||
|
||||
class HelpArticle(Base):
|
||||
"""
|
||||
Modelo para los artículos de ayuda (Base de Conocimientos).
|
||||
Sincronizado entre Servidor Central y Clientes.
|
||||
"""
|
||||
__tablename__ = "help_articles"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
slug = Column(String(255), unique=True, index=True, nullable=False)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
last_editor = Column(String(255), nullable=False)
|
||||
|
||||
# Library Mode Fields
|
||||
category = Column(String(255), nullable=True, default="General")
|
||||
order = Column(Integer, nullable=True, default=0)
|
||||
|
||||
# Removed missing fields to avoid 500 errors (No migration approach)
|
||||
# content_type = Column(String(50), nullable=False, default="article")
|
||||
# file_url = Column(String(512), nullable=True)
|
||||
# file_size = Column(Integer, nullable=True)
|
||||
# mime_type = Column(String(100), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||
273
backend/api/v1/modules/core/help_center/routes.py
Normal file
273
backend/api/v1/modules/core/help_center/routes.py
Normal file
@@ -0,0 +1,273 @@
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import (
|
||||
help_asset_key,
|
||||
help_public_api_path,
|
||||
help_s3_key_to_public_relative_path,
|
||||
system_help_object_key,
|
||||
)
|
||||
from core.storage_s3 import get_object_bytes, put_object_bytes
|
||||
from core.security import get_current_user, has_role
|
||||
from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||
from .services import HelpCenterService
|
||||
from .tasks import sync_single_article_task
|
||||
|
||||
router = APIRouter(prefix="/help-center", tags=["Help Center"])
|
||||
|
||||
def verify_sync_token(x_sync_token: str = Header(...)):
|
||||
if x_sync_token != settings.SYNC_SECRET_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Sync Token"
|
||||
)
|
||||
|
||||
def trigger_sync_or_broadcast(article_uuid: UUID):
|
||||
"""
|
||||
Helper function to handle synchronization logic.
|
||||
- If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync.
|
||||
- If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}")
|
||||
logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||
|
||||
# 1. Upstream Sync (Client -> Hub)
|
||||
if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""':
|
||||
logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}")
|
||||
sync_single_article_task.delay(str(article_uuid))
|
||||
|
||||
# 2. Downstream Broadcast (Hub -> Spokes)
|
||||
# Only if we are the Hub (no upstream) and have spokes configured.
|
||||
elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS:
|
||||
from .tasks import broadcast_help_update
|
||||
logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}")
|
||||
# origin_client_uuid is None because this change originated on the Hub itself
|
||||
broadcast_help_update.delay(str(article_uuid), None)
|
||||
else:
|
||||
logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True)
|
||||
# We don't re-raise here to avoid returning 500 to the user if the save was successful
|
||||
|
||||
@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)])
|
||||
def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)):
|
||||
"""
|
||||
Endpoint de sincronización inteligente para artículos de ayuda.
|
||||
Requiere X-Sync-Token en los headers.
|
||||
"""
|
||||
result = HelpCenterService.sync_article(db, sync_data)
|
||||
|
||||
# Broadcast to other spokes (Hub logic)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||
|
||||
if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS:
|
||||
# We are the Hub (no central server to push to) and have Spokes configured
|
||||
from .tasks import broadcast_help_update
|
||||
logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}")
|
||||
broadcast_help_update.delay(
|
||||
str(sync_data.article_uuid),
|
||||
str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None
|
||||
)
|
||||
else:
|
||||
logger.info("DEBUG: Broadcast skipped (Condition failed)")
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/files/{file_path:path}")
|
||||
def serve_help_file(file_path: str):
|
||||
"""Sirve un objeto bajo system/help/ (público vía middleware)."""
|
||||
if ".." in file_path or file_path.startswith("/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
try:
|
||||
key = system_help_object_key(file_path)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if not settings.use_s3_object_storage:
|
||||
legacy = os.path.join("uploads", "help", file_path)
|
||||
if not os.path.isfile(legacy):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
with open(legacy, "rb") as f:
|
||||
data = f.read()
|
||||
media = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||
return Response(content=data, media_type=media)
|
||||
try:
|
||||
data = get_object_bytes(key)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
media = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||
return Response(content=data, media_type=media)
|
||||
|
||||
|
||||
@router.post("/upload-image/")
|
||||
async def upload_help_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube una imagen para usar en los artículos."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename or "")[1] or ".png"
|
||||
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
body = await file.read()
|
||||
if settings.use_s3_object_storage:
|
||||
key = help_asset_key("", new_filename)
|
||||
ct = mimetypes.guess_type(new_filename)[0] or "image/png"
|
||||
put_object_bytes(key, body, content_type=ct)
|
||||
rel = help_s3_key_to_public_relative_path(key)
|
||||
return {"url": help_public_api_path(rel)}
|
||||
os.makedirs("uploads/help", exist_ok=True)
|
||||
file_location = f"uploads/help/{new_filename}"
|
||||
with open(file_location, "wb") as f:
|
||||
f.write(body)
|
||||
return {"url": f"/api/uploads/help/{new_filename}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/upload-asset/")
|
||||
async def upload_help_asset(
|
||||
file: UploadFile = File(...),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
|
||||
try:
|
||||
file_ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
subfolder = "assets"
|
||||
if file_ext == ".pdf":
|
||||
subfolder = "pdfs"
|
||||
elif file_ext in [".mp4", ".mov", ".avi"]:
|
||||
subfolder = "videos"
|
||||
|
||||
if settings.use_s3_object_storage:
|
||||
body = await file.read()
|
||||
key = help_asset_key(subfolder, new_filename)
|
||||
ct = file.content_type or mimetypes.guess_type(new_filename)[0] or "application/octet-stream"
|
||||
put_object_bytes(key, body, content_type=ct)
|
||||
rel = help_s3_key_to_public_relative_path(key)
|
||||
return {
|
||||
"url": help_public_api_path(rel),
|
||||
"filename": file.filename,
|
||||
"size": len(body),
|
||||
"mime_type": file.content_type,
|
||||
}
|
||||
|
||||
folder = f"uploads/help/{subfolder}"
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
file_location = f"{folder}/{new_filename}"
|
||||
body = await file.read()
|
||||
with open(file_location, "wb") as f:
|
||||
f.write(body)
|
||||
file_size = os.path.getsize(file_location)
|
||||
return {
|
||||
"url": f"/api/{file_location}",
|
||||
"filename": file.filename,
|
||||
"size": file_size,
|
||||
"mime_type": file.content_type,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/articles/", response_model=List[HelpArticleInDB])
|
||||
def list_articles(
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Lista todos los artículos de ayuda."""
|
||||
return HelpCenterService.get_all(db)
|
||||
|
||||
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
|
||||
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
|
||||
return HelpCenterService.get_modifications(db, since)
|
||||
|
||||
@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||
def get_article(
|
||||
article_uuid: UUID,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Obtiene un artículo por UUID."""
|
||||
article = HelpCenterService.get_by_uuid(db, article_uuid)
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
return article
|
||||
|
||||
@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED)
|
||||
def create_article(
|
||||
article: HelpArticleCreate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Crea un nuevo artículo."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
|
||||
|
||||
# Fill last_editor with admin username
|
||||
if current_user.get('preferred_username'):
|
||||
article.last_editor = current_user.get('preferred_username')
|
||||
|
||||
new_article = HelpCenterService.create(db, article)
|
||||
logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}")
|
||||
|
||||
trigger_sync_or_broadcast(new_article.uuid)
|
||||
|
||||
return new_article
|
||||
|
||||
@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||
def update_article(
|
||||
article_uuid: UUID,
|
||||
article_data: HelpArticleUpdate,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Actualiza un artículo."""
|
||||
if current_user.get('preferred_username'):
|
||||
article_data.last_editor = current_user.get('preferred_username')
|
||||
|
||||
article = HelpCenterService.update(db, article_uuid, article_data)
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
trigger_sync_or_broadcast(article.uuid)
|
||||
|
||||
return article
|
||||
|
||||
@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_article(
|
||||
article_uuid: UUID,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||
):
|
||||
"""Elimina un artículo."""
|
||||
if not HelpCenterService.delete(db, article_uuid):
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
# Broadcast or Sync the deletion?
|
||||
# Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone.
|
||||
# For now, let's at least trigger the logic.
|
||||
# WARNING: sync_single_article_task expects the article to exist to send it.
|
||||
# If we deleted it locally, sync_single_article_task will fail or send nothing.
|
||||
# We need a dedicated 'sync_deletion' task or similar.
|
||||
# Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync
|
||||
# logic right now to avoid breaking things, but I'll add the hook for completeness.
|
||||
# Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs.
|
||||
|
||||
return None
|
||||
74
backend/api/v1/modules/core/help_center/schemas.py
Normal file
74
backend/api/v1/modules/core/help_center/schemas.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class HelpArticleBase(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
content: str
|
||||
last_editor: str
|
||||
category: Optional[str] = "General"
|
||||
order: Optional[int] = 0
|
||||
content_type: str = "article"
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
context_path: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class HelpArticleCreate(HelpArticleBase):
|
||||
pass
|
||||
|
||||
class HelpArticleUpdate(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
last_editor: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
order: Optional[int] = None
|
||||
content_type: Optional[str] = None
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
context_path: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class HelpArticleInDB(HelpArticleBase):
|
||||
uuid: UUID
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class HelpSyncRequest(BaseModel):
|
||||
article_uuid: UUID
|
||||
client_updated_at: datetime
|
||||
client_content: str
|
||||
client_title: str
|
||||
client_slug: str
|
||||
last_editor: str
|
||||
client_category: Optional[str] = "General"
|
||||
client_order: Optional[int] = 0
|
||||
client_content_type: str = "article"
|
||||
client_file_url: Optional[str] = None
|
||||
client_file_size: Optional[int] = None
|
||||
client_mime_type: Optional[str] = None
|
||||
client_context_path: Optional[str] = None
|
||||
client_tags: Optional[str] = None
|
||||
|
||||
class HelpSyncResponse(BaseModel):
|
||||
status: str
|
||||
server_updated_at: Optional[datetime] = None
|
||||
server_content: Optional[str] = None
|
||||
server_title: Optional[str] = None
|
||||
server_slug: Optional[str] = None
|
||||
server_category: Optional[str] = None
|
||||
server_order: Optional[int] = None
|
||||
server_content_type: Optional[str] = None
|
||||
server_file_url: Optional[str] = None
|
||||
server_file_size: Optional[int] = None
|
||||
server_mime_type: Optional[str] = None
|
||||
server_context_path: Optional[str] = None
|
||||
server_tags: Optional[str] = None
|
||||
message: str
|
||||
257
backend/api/v1/modules/core/help_center/services.py
Normal file
257
backend/api/v1/modules/core/help_center/services.py
Normal file
@@ -0,0 +1,257 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
from .models import HelpArticle
|
||||
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||
|
||||
class HelpCenterService:
|
||||
@staticmethod
|
||||
def _inject_metadata(article: HelpArticle) -> HelpArticle:
|
||||
if not article or not article.content:
|
||||
return article
|
||||
|
||||
# Look for <!-- a76_metadata: { ... } -->
|
||||
match = re.search(r'<!-- a76_metadata: (.*?) -->', article.content, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
metadata = json.loads(match.group(1))
|
||||
article.content_type = metadata.get("content_type", "article")
|
||||
article.file_url = metadata.get("file_url")
|
||||
article.file_size = metadata.get("file_size")
|
||||
article.mime_type = metadata.get("mime_type")
|
||||
article.context_path = metadata.get("context_path")
|
||||
article.tags = metadata.get("tags")
|
||||
# Remove metadata from content for clean display if needed,
|
||||
# but usually better to leave it and let parser handle it or hide it here.
|
||||
# For now, we just set the attributes.
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
article.content_type = "article"
|
||||
article.file_url = None
|
||||
article.file_size = None
|
||||
article.mime_type = None
|
||||
article.context_path = None
|
||||
article.tags = None
|
||||
|
||||
return article
|
||||
|
||||
@staticmethod
|
||||
def _extract_metadata(content: str, data: dict) -> str:
|
||||
# Remove existing metadata block if any
|
||||
content = re.sub(r'\n\n<!-- a76_metadata: .*? -->', '', content, flags=re.DOTALL)
|
||||
|
||||
metadata = {
|
||||
"content_type": data.get("content_type", "article"),
|
||||
"file_url": data.get("file_url"),
|
||||
"file_size": data.get("file_size"),
|
||||
"mime_type": data.get("mime_type"),
|
||||
"context_path": data.get("context_path"),
|
||||
"tags": data.get("tags")
|
||||
}
|
||||
|
||||
# Only append if there's something meaningful beyond "article"
|
||||
if (metadata["content_type"] != "article" or
|
||||
metadata["file_url"] or
|
||||
metadata["context_path"] or
|
||||
metadata["tags"]):
|
||||
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
|
||||
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def get_all(db: Session) -> List[HelpArticle]:
|
||||
articles = db.query(HelpArticle).all()
|
||||
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||
|
||||
@staticmethod
|
||||
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
return HelpCenterService._inject_metadata(article)
|
||||
|
||||
@staticmethod
|
||||
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
|
||||
return HelpCenterService._inject_metadata(article)
|
||||
|
||||
@staticmethod
|
||||
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
|
||||
# Ensure timezone awareness
|
||||
if since.tzinfo is None:
|
||||
since = since.replace(tzinfo=timezone.utc)
|
||||
articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
|
||||
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
|
||||
data = article.model_dump()
|
||||
# Move metadata into content
|
||||
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
|
||||
# Remove virtual fields from data to avoid SQLAlchemy errors
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
|
||||
for f in virtual_fields:
|
||||
if f in data:
|
||||
del data[f]
|
||||
|
||||
db_article = HelpArticle(**data)
|
||||
db.add(db_article)
|
||||
db.commit()
|
||||
db.refresh(db_article)
|
||||
return HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
if not db_article:
|
||||
return None
|
||||
|
||||
# Inject metadata to existing article to get current virtual fields
|
||||
db_article = HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
update_data = article_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle metadata update
|
||||
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]):
|
||||
# Merge existing metadata with new updates
|
||||
current_meta = {
|
||||
"content_type": getattr(db_article, "content_type", "article"),
|
||||
"file_url": getattr(db_article, "file_url", None),
|
||||
"file_size": getattr(db_article, "file_size", None),
|
||||
"mime_type": getattr(db_article, "mime_type", None),
|
||||
"context_path": getattr(db_article, "context_path", None),
|
||||
"tags": getattr(db_article, "tags", None)
|
||||
}
|
||||
# Update with new data if present
|
||||
for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]:
|
||||
if f in update_data:
|
||||
current_meta[f] = update_data[f]
|
||||
|
||||
# Use current content or new content
|
||||
content = update_data.get("content", db_article.content)
|
||||
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
|
||||
|
||||
# Remove virtual fields from data
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
|
||||
for f in virtual_fields:
|
||||
if f in update_data:
|
||||
del update_data[f]
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_article, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_article)
|
||||
return HelpCenterService._inject_metadata(db_article)
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, article_uuid: UUID) -> bool:
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
if not db_article:
|
||||
return False
|
||||
db.delete(db_article)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse:
|
||||
"""
|
||||
Lógica de sincronización "Smart Sync" (Last Write Wins).
|
||||
"""
|
||||
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first()
|
||||
|
||||
client_updated_at = sync_data.client_updated_at
|
||||
if client_updated_at.tzinfo is None:
|
||||
client_updated_at = client_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if not db_article:
|
||||
# Caso A: Artículo nuevo desde el cliente
|
||||
# Store metadata in content
|
||||
client_meta = {
|
||||
"content_type": sync_data.client_content_type,
|
||||
"file_url": sync_data.client_file_url,
|
||||
"file_size": sync_data.client_file_size,
|
||||
"mime_type": sync_data.client_mime_type,
|
||||
"context_path": sync_data.client_context_path,
|
||||
"tags": sync_data.client_tags
|
||||
}
|
||||
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||
|
||||
new_article = HelpArticle(
|
||||
uuid=sync_data.article_uuid,
|
||||
slug=sync_data.client_slug,
|
||||
title=sync_data.client_title,
|
||||
content=content_with_meta,
|
||||
updated_at=client_updated_at,
|
||||
last_editor=sync_data.last_editor,
|
||||
category=sync_data.client_category,
|
||||
order=sync_data.client_order
|
||||
)
|
||||
db.add(new_article)
|
||||
db.commit()
|
||||
|
||||
# Download assets if needed (Images in content and main file)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
if sync_data.client_file_url:
|
||||
download_file_from_hub(sync_data.client_file_url)
|
||||
sync_assets_from_content(sync_data.client_content)
|
||||
|
||||
return HelpSyncResponse(status="OK", message="Article created on server.")
|
||||
|
||||
server_updated_at = db_article.updated_at
|
||||
if server_updated_at.tzinfo is None:
|
||||
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Caso A: Cliente es más nuevo
|
||||
if client_updated_at > server_updated_at:
|
||||
client_meta = {
|
||||
"content_type": sync_data.client_content_type,
|
||||
"file_url": sync_data.client_file_url,
|
||||
"file_size": sync_data.client_file_size,
|
||||
"mime_type": sync_data.client_mime_type,
|
||||
"context_path": sync_data.client_context_path,
|
||||
"tags": sync_data.client_tags
|
||||
}
|
||||
db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||
db_article.title = sync_data.client_title
|
||||
db_article.slug = sync_data.client_slug
|
||||
db_article.updated_at = client_updated_at
|
||||
db_article.last_editor = sync_data.last_editor
|
||||
db_article.category = sync_data.client_category
|
||||
db_article.order = sync_data.client_order
|
||||
db.commit()
|
||||
|
||||
# Download assets if needed (Images in content)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
if sync_data.client_file_url:
|
||||
download_file_from_hub(sync_data.client_file_url)
|
||||
sync_assets_from_content(sync_data.client_content)
|
||||
|
||||
return HelpSyncResponse(status="OK", message="Server updated with client data.")
|
||||
|
||||
# Caso B: Servidor es más nuevo
|
||||
elif server_updated_at > client_updated_at:
|
||||
# Inject metadata for response
|
||||
db_article = HelpCenterService._inject_metadata(db_article)
|
||||
return HelpSyncResponse(
|
||||
status="UPDATE_REQUIRED",
|
||||
server_updated_at=server_updated_at,
|
||||
server_content=db_article.content,
|
||||
server_title=db_article.title,
|
||||
server_slug=db_article.slug,
|
||||
server_category=db_article.category,
|
||||
server_order=db_article.order,
|
||||
server_content_type=getattr(db_article, "content_type", "article"),
|
||||
server_file_url=getattr(db_article, "file_url", None),
|
||||
server_file_size=getattr(db_article, "file_size", None),
|
||||
server_mime_type=getattr(db_article, "mime_type", None),
|
||||
server_context_path=getattr(db_article, "context_path", None),
|
||||
server_tags=getattr(db_article, "tags", None),
|
||||
message="Client is outdated. Update required."
|
||||
)
|
||||
|
||||
# Caso C: Iguales
|
||||
else:
|
||||
return HelpSyncResponse(status="OK", message="Already in sync.")
|
||||
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
@@ -0,0 +1,269 @@
|
||||
import logging
|
||||
import httpx
|
||||
from uuid import UUID
|
||||
from celery import shared_task
|
||||
from datetime import datetime, timezone
|
||||
from core.database import CoreSessionLocal
|
||||
from core.config import settings
|
||||
from .models import HelpArticle
|
||||
from .schemas import HelpSyncRequest, HelpSyncResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task(name="sync_all_articles_task")
|
||||
def sync_all_articles_task():
|
||||
"""
|
||||
Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central.
|
||||
Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke).
|
||||
"""
|
||||
if not settings.CENTRAL_SERVER_URL:
|
||||
logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).")
|
||||
return
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
articles = db.query(HelpArticle).all()
|
||||
for article in articles:
|
||||
sync_single_article(article.uuid)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_all_articles_task: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@shared_task(name="sync_single_article_task")
|
||||
def sync_single_article_task(article_uuid_str: str):
|
||||
"""
|
||||
Sincroniza un único artículo inmediatamente después de una edición local.
|
||||
"""
|
||||
sync_single_article(article_uuid_str)
|
||||
|
||||
|
||||
@shared_task(name="broadcast_help_update")
|
||||
def broadcast_help_update(article_uuid_str: str):
|
||||
"""
|
||||
Difunde una actualización de artículo a todos los spokes configurados.
|
||||
"""
|
||||
if not settings.SPOKE_URLS:
|
||||
logger.info("No SPOKE_URLS configured. Skipping broadcast.")
|
||||
return
|
||||
|
||||
spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()]
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first()
|
||||
if not article:
|
||||
logger.error(f"Article {article_uuid_str} not found for broadcast.")
|
||||
return
|
||||
|
||||
sync_payload = HelpSyncRequest(
|
||||
article_uuid=article.uuid,
|
||||
client_updated_at=article.updated_at,
|
||||
client_content=article.content,
|
||||
client_title=article.title,
|
||||
client_slug=article.slug,
|
||||
last_editor=article.last_editor,
|
||||
client_category=article.category,
|
||||
client_order=article.order
|
||||
).model_dump(mode='json')
|
||||
|
||||
with httpx.Client() as client:
|
||||
for spoke_url in spokes:
|
||||
# Loop Prevention: Skip if the spoke is the origin
|
||||
try:
|
||||
logger.info(f"Broadcasting update to {spoke_url}")
|
||||
response = client.post(
|
||||
spoke_url,
|
||||
json=sync_payload,
|
||||
headers=headers,
|
||||
timeout=5.0
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting to {spoke_url}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Broadcast error: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def sync_single_article(article_uuid):
|
||||
"""
|
||||
Lógica compartida para sincronizar un artículo con el servidor central.
|
||||
"""
|
||||
logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})")
|
||||
|
||||
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||
# Enhanced check to catch literal empty quotes if they slip through
|
||||
return
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||
if not article:
|
||||
return
|
||||
|
||||
sync_data = HelpSyncRequest(
|
||||
article_uuid=article.uuid,
|
||||
client_updated_at=article.updated_at,
|
||||
client_content=article.content,
|
||||
client_title=article.title,
|
||||
client_slug=article.slug,
|
||||
last_editor=article.last_editor,
|
||||
client_category=article.category,
|
||||
client_order=article.order
|
||||
)
|
||||
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
settings.CENTRAL_SERVER_URL,
|
||||
json=sync_data.model_dump(mode='json'),
|
||||
headers=headers,
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = HelpSyncResponse(**response.json())
|
||||
if result.status == "UPDATE_REQUIRED":
|
||||
# El servidor tiene una versión más nueva, actualizamos localmente
|
||||
article.content = result.server_content
|
||||
article.title = result.server_title
|
||||
article.slug = result.server_slug
|
||||
article.updated_at = result.server_updated_at
|
||||
db.commit()
|
||||
logger.info(f"Article {article.uuid} updated from server.")
|
||||
|
||||
# Download assets if needed
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
if result.server_file_url:
|
||||
download_file_from_hub(result.server_file_url)
|
||||
sync_assets_from_content(result.server_content)
|
||||
else:
|
||||
logger.info(f"Article {article.uuid} sync OK: {result.message}")
|
||||
else:
|
||||
logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing article {article.uuid}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
@shared_task(name="sync_from_hub_task")
|
||||
def sync_from_hub_task():
|
||||
"""
|
||||
Tarea de POLLING que el Cliente ejecuta periódicamente.
|
||||
Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde
|
||||
la última actualización local.
|
||||
"""
|
||||
if not settings.CENTRAL_SERVER_URL:
|
||||
return
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
# 1. Obtener la fecha de la última actualización local
|
||||
last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar()
|
||||
if not last_local_update:
|
||||
# Si no hay datos, traer todo desde el principio de los tiempos
|
||||
last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
# Asegurar timezone awareness
|
||||
if last_local_update.tzinfo is None:
|
||||
last_local_update = last_local_update.replace(tzinfo=timezone.utc)
|
||||
|
||||
logger.info(f"Polling Hub for updates since {last_local_update}")
|
||||
|
||||
# 2. Consultar al Hub
|
||||
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||
# CENTRAL_SERVER_URL es ".../help-center/sync/"
|
||||
# Queremos ".../help-center/modifications/"
|
||||
hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/")
|
||||
|
||||
with httpx.Client() as client:
|
||||
response = client.get(
|
||||
hub_url,
|
||||
params={"since": last_local_update.isoformat()},
|
||||
headers=headers,
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
articles_data = response.json()
|
||||
if not articles_data:
|
||||
logger.info("No updates found.")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(articles_data)} updates from Hub. Applying...")
|
||||
|
||||
# 3. Aplicar actualizaciones
|
||||
for art_data in articles_data:
|
||||
try:
|
||||
# Logic similar to sync_article but simpler (Force Update from Hub)
|
||||
# We assume Hub is Truth in this Polling flow
|
||||
|
||||
# Try to find by UUID
|
||||
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
|
||||
|
||||
# Fallback: find by Slug if UUID doesn't match
|
||||
if not local_article:
|
||||
local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first()
|
||||
|
||||
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
|
||||
if server_updated_at.tzinfo is None:
|
||||
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
if not local_article:
|
||||
new_article = HelpArticle(
|
||||
uuid=art_data['uuid'],
|
||||
slug=art_data['slug'],
|
||||
title=art_data['title'],
|
||||
content=art_data['content'],
|
||||
updated_at=server_updated_at,
|
||||
last_editor=art_data['last_editor'],
|
||||
category=art_data.get('category', "General"),
|
||||
order=art_data.get('order', 0)
|
||||
)
|
||||
db.add(new_article)
|
||||
logger.info(f"Created new article: {art_data['slug']}")
|
||||
else:
|
||||
# Update existing article
|
||||
# If UUID changed in Hub but slug is the same, we update UUID too
|
||||
local_article.uuid = art_data['uuid']
|
||||
local_article.slug = art_data['slug']
|
||||
local_article.title = art_data['title']
|
||||
local_article.content = art_data['content']
|
||||
local_article.updated_at = server_updated_at
|
||||
local_article.last_editor = art_data['last_editor']
|
||||
local_article.category = art_data.get('category', "General")
|
||||
local_article.order = art_data.get('order', 0)
|
||||
logger.info(f"Updated article: {art_data['slug']}")
|
||||
|
||||
db.commit() # Commit each article to avoid bulk failure
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}")
|
||||
|
||||
|
||||
# Download assets after bulk update (Polling)
|
||||
from .utils import download_file_from_hub, sync_assets_from_content
|
||||
for art_data in articles_data:
|
||||
# art_data contains the virtual fields because it was dumped via HelpArticleInDB
|
||||
if "file_url" in art_data and art_data['file_url']:
|
||||
download_file_from_hub(art_data['file_url'])
|
||||
|
||||
sync_assets_from_content(art_data.get('content', ''))
|
||||
|
||||
logger.info("Polling sync completed successfully.")
|
||||
|
||||
else:
|
||||
logger.error(f"Polling failed: {response.status_code} - {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_from_hub_task: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
111
backend/api/v1/modules/core/help_center/utils.py
Normal file
111
backend/api/v1/modules/core/help_center/utils.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config import settings
|
||||
from core.s3_keys import SYSTEM_HELP_PREFIX
|
||||
from core.storage_s3 import object_exists, put_object_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _asset_url_to_s3_key(asset_url: str) -> Optional[str]:
|
||||
"""Deriva la clave S3 bajo system/help/ a partir de una URL de artículo."""
|
||||
if "/help-center/files/" in asset_url:
|
||||
rel = asset_url.split("/help-center/files/", 1)[1].lstrip("/")
|
||||
if ".." in rel:
|
||||
return None
|
||||
return f"{SYSTEM_HELP_PREFIX}{rel}"
|
||||
u = asset_url.replace("/api/uploads/", "uploads/")
|
||||
if u.startswith("/"):
|
||||
u = u[1:]
|
||||
if u.startswith("uploads/help/"):
|
||||
return f"{SYSTEM_HELP_PREFIX}{u[len('uploads/help/') :]}"
|
||||
return None
|
||||
|
||||
|
||||
def download_file_from_hub(relative_path: str) -> bool:
|
||||
"""
|
||||
Descarga un asset del Hub y lo guarda en MinIO (system/help/...) o en disco si no hay almacenamiento S3 activo.
|
||||
relative_path: URL parcial, p. ej. '/api/uploads/help/x.png' o '/api/v1/core/help-center/files/pdfs/x.pdf'
|
||||
"""
|
||||
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||
return False
|
||||
|
||||
key = _asset_url_to_s3_key(relative_path)
|
||||
if not key:
|
||||
logger.warning("download_file_from_hub: could not map URL to S3 key: %s", relative_path)
|
||||
return False
|
||||
|
||||
if settings.use_s3_object_storage and object_exists(key):
|
||||
logger.info("S3 object %s already exists, skipping download.", key)
|
||||
return True
|
||||
|
||||
base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0]
|
||||
if "/help-center/files/" in relative_path:
|
||||
rel = relative_path.split("/help-center/files/", 1)[1].lstrip("/")
|
||||
hub_file_url = f"{base_url.rstrip('/')}/api/v1/core/help-center/files/{rel}"
|
||||
else:
|
||||
clean_path = relative_path.replace("/api/uploads/", "uploads/")
|
||||
if clean_path.startswith("/"):
|
||||
clean_path = clean_path[1:]
|
||||
hub_file_url = f"{base_url.rstrip('/')}/{clean_path}"
|
||||
|
||||
logger.info("Downloading asset from Hub: %s", hub_file_url)
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.get(hub_file_url, timeout=30.0)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Failed to download %s: Status %s URL: %s",
|
||||
relative_path,
|
||||
response.status_code,
|
||||
hub_file_url,
|
||||
)
|
||||
return False
|
||||
body = response.content
|
||||
except Exception as e:
|
||||
logger.error("Error downloading %s: %s", relative_path, str(e))
|
||||
return False
|
||||
|
||||
if settings.use_s3_object_storage:
|
||||
rel = key[len(SYSTEM_HELP_PREFIX) :]
|
||||
ct = mimetypes.guess_type(rel)[0] or "application/octet-stream"
|
||||
try:
|
||||
put_object_bytes(key, body, content_type=ct)
|
||||
logger.info("Stored hub asset in S3: %s", key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("S3 put failed for %s: %s", key, e)
|
||||
return False
|
||||
|
||||
rel = key[len(SYSTEM_HELP_PREFIX) :]
|
||||
local_path = Path("uploads/help") / rel
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(body)
|
||||
logger.info("Stored hub asset locally: %s", local_path)
|
||||
return True
|
||||
|
||||
|
||||
def sync_assets_from_content(content: str):
|
||||
"""Parsea markdown y descarga imágenes referenciadas (rutas legacy y nuevas)."""
|
||||
if not content:
|
||||
return
|
||||
|
||||
patterns = [
|
||||
r'!\[.*?\]\((/api/uploads/.*?)\)',
|
||||
r'!\[.*?\]\((/api/v1/core/help-center/files/.*?)\)',
|
||||
]
|
||||
seen = set()
|
||||
for pattern in patterns:
|
||||
for asset_url in re.findall(pattern, content):
|
||||
if asset_url in seen:
|
||||
continue
|
||||
seen.add(asset_url)
|
||||
download_file_from_hub(asset_url)
|
||||
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."""
|
||||
|
||||
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,
|
||||
)
|
||||
313
backend/api/v1/modules/core/invite_codes/service.py
Normal file
313
backend/api/v1/modules/core/invite_codes/service.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Servicio de códigos de invitación reutilizables."""
|
||||
|
||||
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:
|
||||
# Implementa la validación de company con tu modelo de compañía.
|
||||
company = None
|
||||
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": [],
|
||||
"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,
|
||||
)
|
||||
0
backend/api/v1/modules/core/invites/__init__.py
Normal file
0
backend/api/v1/modules/core/invites/__init__.py
Normal file
32
backend/api/v1/modules/core/invites/dto.py
Normal file
32
backend/api/v1/modules/core/invites/dto.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""DTOs para el módulo de invitaciones."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class CreateInviteDTO(BaseModel):
|
||||
email: EmailStr
|
||||
company_id: int
|
||||
role_id: int
|
||||
|
||||
|
||||
class InviteResponseDTO(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
expires_at: datetime
|
||||
invite_url: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InviteValidationResult(BaseModel):
|
||||
email: str
|
||||
role: str
|
||||
invite_id: int
|
||||
tenant_slug: str
|
||||
company_id: Optional[int] = None
|
||||
42
backend/api/v1/modules/core/invites/models.py
Normal file
42
backend/api/v1/modules/core/invites/models.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Modelo de token de invitación local para registro de usuarios."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from api.v1.common.base_models import BaseTimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import DateTime, Integer, JSON, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class InviteToken(Base, BaseTimestampMixin):
|
||||
"""
|
||||
Token de invitación de un solo uso para registro de usuarios.
|
||||
El token en claro NUNCA se almacena; solo su hash SHA-256.
|
||||
"""
|
||||
|
||||
__tablename__ = "invite_tokens"
|
||||
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# sha256(token_plain) — índice único
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
|
||||
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
|
||||
|
||||
# keycloak_user_id del admin que generó la invitación
|
||||
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
product_ids: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Específico de Anexo76: empresa destino para crear UserTenant
|
||||
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Token generado en el Hub (para la URL de registro del workspace)
|
||||
hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
49
backend/api/v1/modules/core/invites/routes.py
Normal file
49
backend/api/v1/modules/core/invites/routes.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Rutas para gestión de invitaciones de usuarios."""
|
||||
|
||||
import logging
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import CreateInviteDTO, InviteResponseDTO
|
||||
from .service import InviteService
|
||||
|
||||
router = APIRouter(prefix="/invites", tags=["Invites"])
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("", response_model=InviteResponseDTO, status_code=201)
|
||||
async def create_invite(
|
||||
data: CreateInviteDTO,
|
||||
request: Request,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
credentials=Depends(_bearer),
|
||||
):
|
||||
"""
|
||||
Genera un token de invitación para que un usuario externo se registre.
|
||||
Requiere permiso user.create. Usa el token del usuario actual para crear
|
||||
el invite en el Hub — no requiere credenciales de hub_admin.
|
||||
"""
|
||||
validate_access_to_resource(
|
||||
db,
|
||||
data.company_id,
|
||||
current_user,
|
||||
required_permissions=["user.create"],
|
||||
)
|
||||
|
||||
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||
created_by: str = current_user.get("sub") or ""
|
||||
|
||||
service = InviteService(db)
|
||||
return await service.create_invite(
|
||||
data=data,
|
||||
created_by=created_by,
|
||||
tenant_slug=tenant_slug,
|
||||
user_access_token=credentials.credentials,
|
||||
)
|
||||
282
backend/api/v1/modules/core/invites/service.py
Normal file
282
backend/api/v1/modules/core/invites/service.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Servicio de invitaciones de usuarios."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import ssl
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import aiosmtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from .dto import CreateInviteDTO, InviteResponseDTO, InviteValidationResult
|
||||
from .models import InviteToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INVITE_TTL_HOURS = 48
|
||||
|
||||
|
||||
def _hash_token(token_plain: str) -> str:
|
||||
return hashlib.sha256(token_plain.encode()).hexdigest()
|
||||
|
||||
|
||||
def _extract_token_from_url(url: str) -> Optional[str]:
|
||||
"""Extract invite_token query param from a URL string."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(url)
|
||||
params = parse_qs(parsed.query)
|
||||
tokens = params.get("invite_token", [])
|
||||
return tokens[0] if tokens else None
|
||||
|
||||
|
||||
class InviteService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
async def create_invite(
|
||||
self,
|
||||
data: CreateInviteDTO,
|
||||
created_by: str,
|
||||
tenant_slug: str,
|
||||
user_access_token: str = "",
|
||||
) -> InviteResponseDTO:
|
||||
import httpx
|
||||
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")
|
||||
|
||||
token_plain = secrets.token_urlsafe(32)
|
||||
token_hash = _hash_token(token_plain)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=INVITE_TTL_HOURS)
|
||||
|
||||
from api.v1.modules.core.permissions.models import CompanyRole
|
||||
|
||||
company_role = (
|
||||
self.db.query(CompanyRole)
|
||||
.filter(
|
||||
CompanyRole.id == data.role_id,
|
||||
CompanyRole.company_id == data.company_id,
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not company_role:
|
||||
raise HTTPException(status_code=404, detail="Rol no encontrado")
|
||||
|
||||
# Crear invite en el Hub usando el token del usuario actual.
|
||||
# El usuario debe tener role='admin' en su tenant dentro del Hub.
|
||||
# No se requieren credenciales de hub_admin — sin secretos en el .env del cliente.
|
||||
hub_invite_token: Optional[str] = None
|
||||
invite_url: str = ""
|
||||
if not user_access_token:
|
||||
logger.error(
|
||||
"[invite] user_access_token vacío — no se puede crear el invite en el Hub. "
|
||||
"tenant=%s email=%s",
|
||||
tenant_slug,
|
||||
data.email,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
hub_resp = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/hub/invites",
|
||||
json={"email": str(data.email), "tenant_slug": tenant_slug},
|
||||
headers={"Authorization": f"Bearer {user_access_token}"},
|
||||
)
|
||||
if hub_resp.status_code in (200, 201):
|
||||
hub_data = hub_resp.json()
|
||||
hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", ""))
|
||||
invite_url = hub_data.get("invite_url", "")
|
||||
else:
|
||||
logger.error(
|
||||
"[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s",
|
||||
hub_resp.status_code,
|
||||
hub_resp.text[:300],
|
||||
tenant_slug,
|
||||
data.email,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc)
|
||||
|
||||
# Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló
|
||||
if not invite_url:
|
||||
hub_base = settings.HUB_URL.rstrip("/")
|
||||
invite_url = (
|
||||
f"{hub_base}/register"
|
||||
f"?invite_token={token_plain}"
|
||||
f"&tenant={tenant_slug}"
|
||||
f"&email={data.email}"
|
||||
)
|
||||
|
||||
invite = InviteToken(
|
||||
token_hash=token_hash,
|
||||
tenant_slug=tenant_slug,
|
||||
email=str(data.email),
|
||||
role=company_role.code,
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
company_id=data.company_id,
|
||||
hub_invite_token=hub_invite_token,
|
||||
)
|
||||
self.db.add(invite)
|
||||
self.db.commit()
|
||||
self.db.refresh(invite)
|
||||
|
||||
# Enviar email (best-effort)
|
||||
try:
|
||||
await self._send_invite_email(
|
||||
to_email=str(data.email),
|
||||
tenant_name=tenant.name,
|
||||
invite_url=invite_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Invite email send failed (non-blocking): %s — invite_url=%s",
|
||||
exc,
|
||||
invite_url,
|
||||
)
|
||||
|
||||
return InviteResponseDTO(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
expires_at=invite.expires_at,
|
||||
invite_url=invite_url,
|
||||
created_at=invite.created_at,
|
||||
)
|
||||
|
||||
def validate(
|
||||
self,
|
||||
token_plain: str,
|
||||
tenant_slug: str,
|
||||
email: Optional[str] = None,
|
||||
) -> InviteValidationResult:
|
||||
"""Valida el token sin consumirlo. Lanza 403 genérico por seguridad."""
|
||||
token_hash = _hash_token(token_plain)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
invite = (
|
||||
self.db.query(InviteToken)
|
||||
.filter(
|
||||
InviteToken.token_hash == token_hash,
|
||||
InviteToken.tenant_slug == tenant_slug,
|
||||
InviteToken.used_at.is_(None),
|
||||
InviteToken.expires_at > now,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not invite:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Token de invitación inválido o expirado",
|
||||
)
|
||||
|
||||
if email and invite.email.lower() != email.lower():
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Token de invitación inválido o expirado",
|
||||
)
|
||||
|
||||
return InviteValidationResult(
|
||||
email=invite.email,
|
||||
role=invite.role,
|
||||
invite_id=invite.id,
|
||||
tenant_slug=invite.tenant_slug,
|
||||
company_id=invite.company_id,
|
||||
)
|
||||
|
||||
def consume_by_id(self, invite_id: int) -> None:
|
||||
invite = self.db.query(InviteToken).filter(InviteToken.id == invite_id).first()
|
||||
if invite:
|
||||
invite.used_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
async def _send_invite_email(
|
||||
self,
|
||||
to_email: str,
|
||||
tenant_name: str,
|
||||
invite_url: str,
|
||||
) -> None:
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = f"Invitación para unirse a {tenant_name} en Mi Aplicación"
|
||||
|
||||
html = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; background: #f3f4f6; padding: 40px 0;">
|
||||
<div style="max-width: 600px; margin: 0 auto; background: #ffffff; border-radius: 8px;
|
||||
overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
|
||||
<div style="background: #2563eb; padding: 32px 40px;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Mi Aplicación</h1>
|
||||
<p style="color: #bfdbfe; margin: 8px 0 0;">Sistema de gestión aduanal</p>
|
||||
</div>
|
||||
<div style="padding: 40px;">
|
||||
<h2 style="color: #111827; font-size: 20px; margin-top: 0;">
|
||||
Te han invitado a {tenant_name}
|
||||
</h2>
|
||||
<p style="color: #4b5563; line-height: 1.6;">
|
||||
Has recibido una invitación para unirte a <strong>{tenant_name}</strong>
|
||||
en Mi Aplicación. Haz clic en el botón para crear tu cuenta.
|
||||
</p>
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<a href="{invite_url}"
|
||||
style="display: inline-block; background: #2563eb; color: #ffffff;
|
||||
text-decoration: none; padding: 14px 32px; border-radius: 6px;
|
||||
font-weight: 600; font-size: 16px;">
|
||||
Aceptar invitación
|
||||
</a>
|
||||
</div>
|
||||
<p style="color: #6b7280; font-size: 13px; line-height: 1.5;">
|
||||
Este enlace es válido por <strong>48 horas</strong> y es de
|
||||
<strong>un solo uso</strong>.<br>
|
||||
Si no esperabas esta invitación, puedes ignorar este correo.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
|
||||
<p style="color: #9ca3af; font-size: 12px;">
|
||||
O copia este enlace en tu navegador:<br>
|
||||
<span style="color: #2563eb; word-break: break-all;">{invite_url}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
ssl_ctx.check_hostname = False
|
||||
ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
if settings.SMTP_PORT == 465:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=True,
|
||||
tls_context=ssl_ctx,
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(msg)
|
||||
else:
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
tls_context=ssl_ctx,
|
||||
) as smtp:
|
||||
await smtp.starttls(tls_context=ssl_ctx)
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(msg)
|
||||
7
backend/api/v1/modules/core/licenses/__init__.py
Normal file
7
backend/api/v1/modules/core/licenses/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Licenses
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
156
backend/api/v1/modules/core/licenses/dto.py
Normal file
156
backend/api/v1/modules/core/licenses/dto.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
DTOs para módulo de licencias
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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
|
||||
102
backend/api/v1/modules/core/licenses/models.py
Normal file
102
backend/api/v1/modules/core/licenses/models.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Modelos ORM para gestión de licencias
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from api.v1.common.base_models import TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Boolean, Column, DateTime
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
from sqlalchemy import ForeignKey, Integer
|
||||
|
||||
|
||||
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, TimestampMixin):
|
||||
"""
|
||||
Modelo de Licencia - Control de planes y límites por tenant
|
||||
"""
|
||||
|
||||
__tablename__ = "licenses"
|
||||
__table_args__ = {"schema": "core"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer, ForeignKey("core.tenants.id"), nullable=False, unique=True, index=True
|
||||
)
|
||||
|
||||
# Plan y características
|
||||
plan = Column(
|
||||
SQLEnum(LicensePlan),
|
||||
default=LicensePlan.FREE,
|
||||
server_default="FREE",
|
||||
nullable=False,
|
||||
)
|
||||
status = Column(
|
||||
SQLEnum(LicenseStatus),
|
||||
default=LicenseStatus.PENDING,
|
||||
server_default="PENDING",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Límites del plan
|
||||
max_users = Column(Integer, server_default="5", nullable=False)
|
||||
max_storage_gb = Column(Integer, server_default="10", nullable=False)
|
||||
max_monthly_operations = Column(Integer, server_default="1000", nullable=False)
|
||||
|
||||
# Features habilitadas (booleans)
|
||||
feature_api_access = Column(Boolean, default=True, server_default="true")
|
||||
feature_advanced_reports = Column(Boolean, default=False, server_default="false")
|
||||
feature_integrations = Column(Boolean, default=False, server_default="false")
|
||||
feature_dedicated_support = Column(Boolean, default=False, server_default="false")
|
||||
|
||||
# Vigencia
|
||||
starts_at = Column(DateTime(timezone=True), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<License(tenant_id={self.tenant_id}, plan={self.plan.value}, status={self.status.value})>"
|
||||
|
||||
|
||||
class LicenseUsage(Base, TimestampMixin):
|
||||
"""
|
||||
Modelo para tracking de uso de licencia
|
||||
"""
|
||||
|
||||
__tablename__ = "license_usage"
|
||||
__table_args__ = {"schema": "core"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer, ForeignKey("core.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, server_default="0")
|
||||
storage_used_gb = Column(Integer, default=0, server_default="0")
|
||||
operations_count = Column(Integer, default=0, server_default="0")
|
||||
api_calls_count = Column(Integer, default=0, server_default="0")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LicenseUsage(tenant_id={self.tenant_id}, operations={self.operations_count})>"
|
||||
119
backend/api/v1/modules/core/licenses/routes.py
Normal file
119
backend/api/v1/modules/core/licenses/routes.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Endpoints API para gestión de licencias
|
||||
"""
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, has_role
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
LicenseCreateDTO,
|
||||
LicenseResponseDTO,
|
||||
LicenseUpdateDTO,
|
||||
LicenseUsageResponseDTO,
|
||||
LicenseValidationResponseDTO,
|
||||
)
|
||||
from .service import LicenseService
|
||||
|
||||
router = APIRouter(prefix="/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
|
||||
260
backend/api/v1/modules/core/licenses/service.py
Normal file
260
backend/api/v1/modules/core/licenses/service.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Servicio de lógica de negocio para licencias
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
LicenseCreateDTO,
|
||||
LicenseResponseDTO,
|
||||
LicenseUpdateDTO,
|
||||
LicenseUsageResponseDTO,
|
||||
)
|
||||
from .models import License, LicensePlan, LicenseStatus, LicenseUsage
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
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),
|
||||
)
|
||||
324
backend/api/v1/modules/core/permissions/README.md
Normal file
324
backend/api/v1/modules/core/permissions/README.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Módulo de Permisos Multi-Tenant
|
||||
|
||||
Sistema completo de permisos granulares para aplicaciones multi-tenant con FastAPI y SQLAlchemy.
|
||||
|
||||
## 📁 Estructura del Módulo
|
||||
|
||||
```
|
||||
backend/api/v1/modules/core/permissions/
|
||||
├── __init__.py # Exports del módulo
|
||||
├── models.py # Modelos SQLAlchemy
|
||||
├── service.py # Lógica de negocio
|
||||
├── dependencies.py # Dependencias FastAPI
|
||||
├── schemas.py # Modelos Pydantic (request/response)
|
||||
└── routes.py # Endpoints de la API
|
||||
```
|
||||
|
||||
## 🎯 Componentes
|
||||
|
||||
### **models.py**
|
||||
|
||||
Define los modelos de base de datos:
|
||||
|
||||
- `Permission` - Permisos del sistema (ej: "invoice.view", "invoice.edit")
|
||||
- `ClientRole` - Roles personalizados por cliente
|
||||
- `RolePermission` - Relación roles-permisos
|
||||
- `UserClientRole` - Asignación usuario-rol-cliente
|
||||
- `UserClientPermission` - Permisos directos por usuario
|
||||
|
||||
### **service.py**
|
||||
|
||||
Contiene la clase `PermissionService` con métodos:
|
||||
|
||||
- `get_user_permissions()` - Obtiene todos los permisos de un usuario
|
||||
- `has_permission()` - Verifica un permiso específico
|
||||
- `has_all_permissions()` - Verifica múltiples permisos (AND)
|
||||
- `has_any_permission()` - Verifica múltiples permisos (OR)
|
||||
- `assign_role_to_user()` - Asigna roles a usuarios
|
||||
- `grant_direct_permission()` - Concede permisos directos
|
||||
|
||||
### **dependencies.py**
|
||||
|
||||
Dependencias para proteger rutas:
|
||||
|
||||
- `PermissionChecker` - Clase para verificar múltiples permisos
|
||||
- `RequirePermission` - Clase para verificar un solo permiso
|
||||
- `get_client_id()` - Extrae el ID del cliente del header
|
||||
- `get_permission_service()` - Proporciona instancia del servicio
|
||||
- `get_current_user_permissions()` - Devuelve permisos del usuario
|
||||
|
||||
### **schemas.py**
|
||||
|
||||
Modelos Pydantic para request/response:
|
||||
|
||||
- Responses: `PermissionResponse`, `ClientRoleResponse`, `UserPermissionsResponse`, etc.
|
||||
- Requests: `AssignRoleRequest`, `GrantPermissionRequest`, `CreateRoleRequest`, etc.
|
||||
|
||||
### **routes.py**
|
||||
|
||||
Endpoints de la API:
|
||||
|
||||
- `GET /permissions/me` - Permisos del usuario actual
|
||||
- `GET /permissions/available` - Lista todos los permisos
|
||||
- `GET /permissions/roles` - Lista roles del cliente
|
||||
- `POST /permissions/roles` - Crea un rol
|
||||
- `POST /permissions/assign-role` - Asigna rol a usuario
|
||||
- `POST /permissions/grant-permission` - Concede permiso directo
|
||||
- Ejemplos de rutas protegidas
|
||||
|
||||
## 🚀 Uso Rápido
|
||||
|
||||
### Importar el módulo
|
||||
|
||||
```python
|
||||
from api.v1.modules.core.permissions import (
|
||||
Permission,
|
||||
ClientRole,
|
||||
PermissionService,
|
||||
PermissionChecker,
|
||||
RequirePermission,
|
||||
router
|
||||
)
|
||||
```
|
||||
|
||||
### Registrar las rutas
|
||||
|
||||
```python
|
||||
# En backend/api/v1/router.py
|
||||
from api.v1.modules.core.permissions import router as permissions_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(permissions_router)
|
||||
```
|
||||
|
||||
### Proteger una ruta con permiso único
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from api.v1.modules.core.permissions import RequirePermission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
_: None = Depends(RequirePermission("invoice.view"))
|
||||
):
|
||||
return {"invoices": [...]}
|
||||
```
|
||||
|
||||
### Proteger con múltiples permisos
|
||||
|
||||
```python
|
||||
from api.v1.modules.core.permissions import PermissionChecker
|
||||
|
||||
@router.post("/invoices")
|
||||
async def create_invoice(
|
||||
_: None = Depends(PermissionChecker(
|
||||
["invoice.view", "invoice.create"],
|
||||
require_all=True # Requiere TODOS
|
||||
))
|
||||
):
|
||||
return {"created": True}
|
||||
```
|
||||
|
||||
### Usar permisos en la lógica
|
||||
|
||||
```python
|
||||
from api.v1.modules.core.permissions import get_current_user_permissions
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def dashboard(
|
||||
permissions: set = Depends(get_current_user_permissions)
|
||||
):
|
||||
widgets = []
|
||||
|
||||
if "invoice.view" in permissions:
|
||||
widgets.append({"type": "invoices", "data": [...]})
|
||||
|
||||
return {"widgets": widgets}
|
||||
```
|
||||
|
||||
## 📊 Base de Datos
|
||||
|
||||
### Ejecutar migración
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
Esto crea las tablas y permisos iniciales:
|
||||
|
||||
- **invoice.*** - view, create, edit, delete, approve
|
||||
- **user.*** - view, create, edit, delete
|
||||
- **report.*** - financial.view, admin.view, export
|
||||
- **roles.*** - view, create, edit, delete, assign
|
||||
- **permissions.*** - view, grant
|
||||
|
||||
## 🔐 Flujo de Autenticación
|
||||
|
||||
1. Usuario hace request con token JWT de Keycloak
|
||||
2. Header `X-Client-ID` indica el cliente/tenant
|
||||
3. Sistema extrae `user_id` del token
|
||||
4. Consulta permisos del usuario en ese cliente
|
||||
5. Valida si tiene el permiso requerido
|
||||
6. Devuelve 200 OK o 403 Forbidden
|
||||
|
||||
## 💡 Ejemplos Prácticos
|
||||
|
||||
### Crear un rol personalizado
|
||||
|
||||
```python
|
||||
from api.v1.modules.core.permissions import PermissionService
|
||||
from core.database import get_db
|
||||
|
||||
db = next(get_db())
|
||||
service = PermissionService(db)
|
||||
|
||||
# Crear rol
|
||||
role = ClientRole(
|
||||
client_id=1,
|
||||
name="Contador",
|
||||
code="accountant",
|
||||
description="Acceso a módulo contable"
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
```
|
||||
|
||||
### Asignar permisos a un rol
|
||||
|
||||
```python
|
||||
from api.v1.modules.core.permissions.models import RolePermission
|
||||
|
||||
# Obtener permisos de facturación
|
||||
invoice_perms = db.query(Permission).filter(
|
||||
Permission.module == "invoice"
|
||||
).all()
|
||||
|
||||
# Asignar al rol
|
||||
for perm in invoice_perms:
|
||||
role_perm = RolePermission(
|
||||
client_role_id=role.id,
|
||||
permission_id=perm.id
|
||||
)
|
||||
db.add(role_perm)
|
||||
|
||||
db.commit()
|
||||
```
|
||||
|
||||
### Asignar rol a usuario
|
||||
|
||||
```python
|
||||
service.assign_role_to_user(
|
||||
user_id="user-uuid-from-keycloak",
|
||||
client_id=1,
|
||||
role_id=role.id,
|
||||
assigned_by="admin-uuid"
|
||||
)
|
||||
```
|
||||
|
||||
### Conceder permiso temporal
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
service.grant_direct_permission(
|
||||
user_id="user-uuid",
|
||||
client_id=1,
|
||||
permission_code="invoice.delete",
|
||||
assigned_by="admin-uuid",
|
||||
expires_at=datetime.utcnow() + timedelta(days=7)
|
||||
)
|
||||
```
|
||||
|
||||
## ⚡ Optimización de Rendimiento
|
||||
|
||||
### 1. Caché con Redis
|
||||
|
||||
```python
|
||||
import redis
|
||||
from functools import lru_cache
|
||||
|
||||
redis_client = redis.Redis(host='localhost', port=6379)
|
||||
|
||||
def get_cached_permissions(user_id: str, client_id: int) -> set:
|
||||
cache_key = f"perms:{user_id}:{client_id}"
|
||||
|
||||
cached = redis_client.get(cache_key)
|
||||
if cached:
|
||||
return set(cached.decode().split(','))
|
||||
|
||||
# Consultar DB
|
||||
service = PermissionService(db)
|
||||
permissions = service.get_user_permissions(user_id, client_id)
|
||||
|
||||
# Cachear por 5 minutos
|
||||
redis_client.setex(cache_key, 300, ','.join(permissions))
|
||||
|
||||
return permissions
|
||||
```
|
||||
|
||||
### 2. Índices de Base de Datos
|
||||
|
||||
Ya están definidos en los modelos:
|
||||
|
||||
- Índices compuestos para consultas eficientes
|
||||
- Índices únicos para prevenir duplicados
|
||||
- Índices en foreign keys
|
||||
|
||||
### 3. Query Optimization
|
||||
|
||||
El servicio usa JOINs eficientes en lugar de N+1 queries.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from api.v1.modules.core.permissions import PermissionService
|
||||
from api.v1.modules.core.permissions.models import Permission, ClientRole
|
||||
|
||||
def test_user_has_permission_from_role(db_session):
|
||||
# Setup
|
||||
perm = Permission(code="invoice.view", module="invoice", action="view")
|
||||
db_session.add(perm)
|
||||
|
||||
role = ClientRole(client_id=1, code="viewer", name="Viewer")
|
||||
db_session.add(role)
|
||||
db_session.commit()
|
||||
|
||||
# Test
|
||||
service = PermissionService(db_session)
|
||||
assert service.has_permission("user-123", 1, "invoice.view")
|
||||
```
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
- **Client ID**: Por defecto se obtiene del header `X-Client-ID`, pero puede adaptarse a subdominios o JWT
|
||||
- **User ID**: Se extrae del campo `sub` del token JWT de Keycloak
|
||||
- **Permisos Directos**: Pueden revocar permisos heredados de roles (`is_granted=False`)
|
||||
- **Soft Delete**: Los roles y permisos se desactivan (`is_active=False`) en lugar de eliminarse
|
||||
|
||||
## 🔗 Integración con Keycloak
|
||||
|
||||
Los roles globales de Keycloak pueden coexistir con los roles locales:
|
||||
|
||||
```python
|
||||
@router.get("/protected")
|
||||
async def protected_route(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
permissions: set = Depends(get_current_user_permissions)
|
||||
):
|
||||
# Verificar rol global de Keycloak
|
||||
keycloak_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||
|
||||
if "super_admin" in keycloak_roles:
|
||||
# Super admin tiene acceso total
|
||||
return {"access": "granted", "level": "global"}
|
||||
|
||||
# Verificar permisos a nivel de cliente
|
||||
if "invoice.view" in permissions:
|
||||
return {"access": "granted", "level": "client"}
|
||||
|
||||
raise HTTPException(403, "No access")
|
||||
```
|
||||
38
backend/api/v1/modules/core/permissions/__init__.py
Normal file
38
backend/api/v1/modules/core/permissions/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Módulo de permisos multi-tenant.
|
||||
Proporciona modelos, servicios y rutas para gestión de permisos granulares por companye.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
Permission,
|
||||
CompanyRole,
|
||||
RolePermission,
|
||||
UserCompanyRole,
|
||||
UserCompanyPermission,
|
||||
)
|
||||
from .service import PermissionService
|
||||
from .dependencies import (
|
||||
PermissionChecker,
|
||||
RequirePermission,
|
||||
get_permission_service,
|
||||
get_current_user_permissions,
|
||||
)
|
||||
from .routes import router
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"Permission",
|
||||
"CompanyRole",
|
||||
"RolePermission",
|
||||
"UserCompanyRole",
|
||||
"UserCompanyPermission",
|
||||
# Service
|
||||
"PermissionService",
|
||||
# Dependencies
|
||||
"PermissionChecker",
|
||||
"RequirePermission",
|
||||
"get_permission_service",
|
||||
"get_current_user_permissions",
|
||||
# Router
|
||||
"router",
|
||||
]
|
||||
205
backend/api/v1/modules/core/permissions/cache.py
Normal file
205
backend/api/v1/modules/core/permissions/cache.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Set, Iterable
|
||||
|
||||
from core.config import settings
|
||||
|
||||
try:
|
||||
import redis # type: ignore
|
||||
except Exception: # pragma: no cover - redis is optional in some envs
|
||||
redis = None # type: ignore
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PermissionCache:
|
||||
"""
|
||||
Caché de permisos basada en Valkey/Redis.
|
||||
|
||||
- Clave por combinación (tenant_id, company_id, user_id)
|
||||
- Guarda el set de códigos de permiso como string CSV
|
||||
- TTL controlado por configuración (`PERMISSION_CACHE_TTL_SECONDS`)
|
||||
|
||||
Todas las operaciones fallan en modo silencioso para no afectar el flujo
|
||||
principal de la aplicación si Redis/Valkey no está disponible.
|
||||
"""
|
||||
|
||||
KEY_PREFIX = "permissions:v1"
|
||||
|
||||
def __init__(self, client: "redis.Redis | None" = None) -> None: # type: ignore[name-defined]
|
||||
self.ttl_seconds = int(getattr(settings, "PERMISSION_CACHE_TTL_SECONDS", 300) or 300)
|
||||
enabled_flag = bool(getattr(settings, "PERMISSION_CACHE_ENABLED", True))
|
||||
|
||||
# Si redis no está instalado, deshabilitar caché
|
||||
if redis is None:
|
||||
self._client = None
|
||||
self.enabled = False
|
||||
return
|
||||
|
||||
if client is not None:
|
||||
self._client = client
|
||||
self.enabled = enabled_flag
|
||||
return
|
||||
|
||||
url = (
|
||||
os.getenv("VALKEY_URL")
|
||||
or os.getenv("REDIS_URL")
|
||||
or getattr(settings, "VALKEY_URL", "redis://valkey:6379/0")
|
||||
)
|
||||
|
||||
try:
|
||||
# decode_responses=True para trabajar con str en lugar de bytes
|
||||
self._client = redis.Redis.from_url(url, decode_responses=True)
|
||||
# Probar conexión rápida (no crítico si falla)
|
||||
if enabled_flag:
|
||||
try:
|
||||
self._client.ping()
|
||||
self.enabled = True
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"permission_cache_ping_failed",
|
||||
extra={"url": url},
|
||||
)
|
||||
self.enabled = False
|
||||
else:
|
||||
self.enabled = False
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"permission_cache_init_failed",
|
||||
extra={"url": url, "error": str(exc)},
|
||||
)
|
||||
self._client = None
|
||||
self.enabled = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers de clave
|
||||
# ------------------------------------------------------------------
|
||||
def build_permissions_key(
|
||||
self,
|
||||
tenant_id: Optional[int],
|
||||
company_id: int,
|
||||
user_id: str,
|
||||
) -> str:
|
||||
"""
|
||||
Construye la clave única de caché para un usuario en una compañía.
|
||||
"""
|
||||
tenant_part = str(tenant_id) if tenant_id is not None else "global"
|
||||
return f"{self.KEY_PREFIX}:tenant:{tenant_part}:company:{company_id}:user:{user_id}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Operaciones de lectura/escritura
|
||||
# ------------------------------------------------------------------
|
||||
def get_permissions(
|
||||
self,
|
||||
cache_key: str,
|
||||
**context: object,
|
||||
) -> Optional[Set[str]]:
|
||||
"""
|
||||
Obtiene el set de permisos desde caché.
|
||||
|
||||
Devuelve:
|
||||
- set[str] si hay caché válido
|
||||
- None si no hay entrada o si la caché está deshabilitada
|
||||
"""
|
||||
if not self.enabled or not self._client:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = self._client.get(cache_key)
|
||||
if raw is None:
|
||||
return None
|
||||
if not raw:
|
||||
return set()
|
||||
return set(raw.split(","))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"permission_cache_get_failed",
|
||||
extra={"cache_key": cache_key, "error": str(exc), **context},
|
||||
)
|
||||
return None
|
||||
|
||||
def set_permissions(
|
||||
self,
|
||||
cache_key: str,
|
||||
permissions: Iterable[str],
|
||||
**context: object,
|
||||
) -> None:
|
||||
"""
|
||||
Escribe el set de permisos en caché con TTL.
|
||||
"""
|
||||
if not self.enabled or not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
value = ",".join(sorted(set(permissions)))
|
||||
self._client.setex(cache_key, self.ttl_seconds, value)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"permission_cache_set_failed",
|
||||
extra={"cache_key": cache_key, "error": str(exc), **context},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Invalidaciones
|
||||
# ------------------------------------------------------------------
|
||||
def _delete_pattern(self, pattern: str) -> None:
|
||||
"""
|
||||
Elimina todas las llaves que coincidan con un patrón.
|
||||
"""
|
||||
if not self.enabled or not self._client:
|
||||
return
|
||||
|
||||
try:
|
||||
# scan_iter evita bloquear Redis en grandes keyspaces
|
||||
keys = list(self._client.scan_iter(match=pattern))
|
||||
if keys:
|
||||
self._client.delete(*keys)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"permission_cache_delete_pattern_failed",
|
||||
extra={"pattern": pattern, "error": str(exc)},
|
||||
)
|
||||
|
||||
def invalidate_user(
|
||||
self,
|
||||
tenant_id: Optional[int],
|
||||
company_id: int,
|
||||
user_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Invalida el caché de permisos para un usuario específico.
|
||||
"""
|
||||
if not self.enabled or not self._client:
|
||||
return
|
||||
|
||||
cache_key = self.build_permissions_key(tenant_id, company_id, user_id)
|
||||
try:
|
||||
self._client.delete(cache_key)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_user_failed",
|
||||
extra={
|
||||
"cache_key": cache_key,
|
||||
"tenant_id": tenant_id,
|
||||
"company_id": company_id,
|
||||
"user_id": user_id,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
def invalidate_company(self, company_id: int) -> None:
|
||||
"""
|
||||
Invalida el caché de permisos para todos los usuarios de una compañía.
|
||||
"""
|
||||
pattern = f"{self.KEY_PREFIX}:tenant:*:company:{company_id}:user:*"
|
||||
self._delete_pattern(pattern)
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""
|
||||
Elimina TODAS las entradas del caché de permisos.
|
||||
Úsese con precaución (ej. cleanup_cli).
|
||||
"""
|
||||
pattern = f"{self.KEY_PREFIX}:*"
|
||||
self._delete_pattern(pattern)
|
||||
|
||||
68
backend/api/v1/modules/core/permissions/cleanup_cli.py
Normal file
68
backend/api/v1/modules/core/permissions/cleanup_cli.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Script CLI para LIMPIEZA TOTAL del sistema de permisos.
|
||||
Borra todos los roles, asignaciones y el catálogo de permisos.
|
||||
Úselo con precaución.
|
||||
|
||||
Uso:
|
||||
docker exec -it <container> python3 -m api.v1.modules.core.permissions.cleanup_cli
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
|
||||
# Configurar logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Asegurar que el backend esté en el path
|
||||
sys.path.append(os.path.abspath("."))
|
||||
sys.path.append(os.path.abspath("backend"))
|
||||
|
||||
from core.database import CoreSessionLocal
|
||||
from api.v1.modules.core.permissions.cache import PermissionCache
|
||||
|
||||
def run_cleanup():
|
||||
"""Ejecuta el borrado de tablas en orden de dependencias."""
|
||||
logger.warning("INICIANDO LIMPIEZA TOTAL DE PERMISOS Y ROLES...")
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
# 1. Borrar asignaciones directas de permisos a usuarios
|
||||
logger.info("Borrando asignaciones directas de usuario...")
|
||||
db.execute(text("DELETE FROM core.user_company_permissions"))
|
||||
|
||||
# 2. Borrar relación entre roles y permisos
|
||||
logger.info("Borrando mapeo de roles y permisos...")
|
||||
db.execute(text("DELETE FROM core.role_permissions"))
|
||||
|
||||
# 3. Borrar asignación de roles a usuarios
|
||||
logger.info("Borrando asignación de roles a usuarios...")
|
||||
db.execute(text("DELETE FROM core.user_company_roles"))
|
||||
|
||||
# 4. Borrar los roles mismos
|
||||
logger.info("Borrando el catálogo de roles...")
|
||||
db.execute(text("DELETE FROM core.company_roles"))
|
||||
|
||||
# 5. Borrar el catálogo base de permisos
|
||||
logger.info("Borrando el catálogo base de permisos...")
|
||||
db.execute(text("DELETE FROM core.permissions"))
|
||||
|
||||
db.commit()
|
||||
# Limpiar también el caché de permisos en Valkey
|
||||
PermissionCache().invalidate_all()
|
||||
logger.info("=" * 40)
|
||||
logger.info("LIMPIEZA COMPLETADA CON ÉXITO")
|
||||
logger.info("El sistema de permisos está ahora en blanco.")
|
||||
logger.info("=" * 40)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error crítico durante la limpieza: {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_cleanup()
|
||||
205
backend/api/v1/modules/core/permissions/dependencies.py
Normal file
205
backend/api/v1/modules/core/permissions/dependencies.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Dependencias de FastAPI para verificación de permisos multi-tenant.
|
||||
Proporciona decoradores y funciones para proteger rutas con permisos específicos.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Callable
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from functools import wraps
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user # Asumiendo que existe esta función
|
||||
from .service import PermissionService
|
||||
|
||||
# Dependencia para obtener el servicio de permisos
|
||||
def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService:
|
||||
"""
|
||||
Crea una instancia del servicio de permisos con la sesión de base de datos.
|
||||
"""
|
||||
return PermissionService(db)
|
||||
|
||||
|
||||
# Clase para verificación de permisos (puede usarse como dependencia)
|
||||
class PermissionChecker:
|
||||
"""
|
||||
Verificador de permisos que puede usarse como dependencia de FastAPI.
|
||||
|
||||
Ejemplo de uso:
|
||||
@app.get("/invoices")
|
||||
async def list_invoices(
|
||||
_: None = Depends(PermissionChecker(["invoice.view"]))
|
||||
):
|
||||
return {"invoices": [...]}
|
||||
"""
|
||||
|
||||
def __init__(self, required_permissions: List[str], require_all: bool = True):
|
||||
"""
|
||||
Args:
|
||||
required_permissions: Lista de permisos requeridos
|
||||
require_all: Si True, requiere TODOS los permisos.
|
||||
Si False, requiere AL MENOS UNO.
|
||||
"""
|
||||
self.required_permissions = required_permissions
|
||||
self.require_all = require_all
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
company_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
permission_service: PermissionService = Depends(get_permission_service),
|
||||
):
|
||||
"""
|
||||
Verifica que el usuario tenga los permisos requeridos.
|
||||
|
||||
Lanza HTTPException 403 si no tiene permisos.
|
||||
"""
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in token",
|
||||
)
|
||||
|
||||
# Verificar permisos sobre la compañía
|
||||
if self.require_all:
|
||||
has_access = permission_service.has_all_permissions(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_codes=self.required_permissions,
|
||||
)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_codes=self.required_permissions,
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {', '.join(self.required_permissions)}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Función alternativa para verificar un solo permiso
|
||||
class RequirePermission:
|
||||
"""
|
||||
Verificador simple para un único permiso.
|
||||
|
||||
Ejemplo:
|
||||
@app.post("/invoices")
|
||||
async def create_invoice(
|
||||
_: None = Depends(RequirePermission("invoice.create"))
|
||||
):
|
||||
return {"created": True}
|
||||
"""
|
||||
|
||||
def __init__(self, permission_code: str):
|
||||
self.permission_code = permission_code
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
company_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
permission_service: PermissionService = Depends(get_permission_service),
|
||||
):
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in token",
|
||||
)
|
||||
|
||||
has_permission = permission_service.has_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_code=self.permission_code,
|
||||
)
|
||||
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permission: {self.permission_code}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Decorador personalizado para aplicar a funciones (opcional)
|
||||
def require_permissions(*permissions: str, require_all: bool = True):
|
||||
"""
|
||||
Decorador para verificar permisos en funciones.
|
||||
Útil para lógica de negocio fuera de rutas FastAPI.
|
||||
|
||||
Ejemplo:
|
||||
@require_permissions("invoice.edit", "invoice.view")
|
||||
def update_invoice_logic(invoice_id: int, user_id: str, company_id: int, db: Session):
|
||||
# Lógica de actualización
|
||||
pass
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Extraer user_id, company_id y db de los argumentos
|
||||
user_id = kwargs.get("user_id")
|
||||
company_id = kwargs.get("company_id")
|
||||
db = kwargs.get("db")
|
||||
|
||||
if not all([user_id, company_id, db]):
|
||||
raise ValueError(
|
||||
"Function must receive 'user_id', 'company_id', and 'db' as keyword arguments"
|
||||
)
|
||||
|
||||
# Verificar permisos
|
||||
permission_service = PermissionService(db)
|
||||
|
||||
if require_all:
|
||||
has_access = permission_service.has_all_permissions(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_codes=list(permissions),
|
||||
)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_codes=list(permissions),
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing required permissions: {', '.join(permissions)}",
|
||||
)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Función helper para obtener permisos del usuario actual
|
||||
async def get_current_user_permissions(
|
||||
company_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
permission_service: PermissionService = Depends(get_permission_service),
|
||||
) -> set:
|
||||
"""
|
||||
Devuelve todos los permisos del usuario actual en la compañía.
|
||||
Útil para endpoints que necesitan conocer los permisos disponibles.
|
||||
"""
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User ID not found in token",
|
||||
)
|
||||
|
||||
return permission_service.get_user_permissions(user_id, company_id, use_cache=True)
|
||||
247
backend/api/v1/modules/core/permissions/models.py
Normal file
247
backend/api/v1/modules/core/permissions/models.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Modelos de permisos multi-tenant para el sistema.
|
||||
Este módulo define el sistema de permisos granular por compañia/tenant.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
Integer,
|
||||
ForeignKey,
|
||||
DateTime,
|
||||
Boolean,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
# Modelo para permisos del sistema
|
||||
# Representa acciones específicas como "invoice.view", "invoice.edit", etc.
|
||||
class Permission(Base, TimestampMixin):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(100), unique=True, nullable=False, index=True
|
||||
)
|
||||
# Código único del permiso (ej: "invoice.view", "user.edit")
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
# Descripción legible del permiso
|
||||
|
||||
module: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
# Módulo al que pertenece (ej: "invoice", "user", "report")
|
||||
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
# Acción específica (ej: "view", "edit", "delete", "create")
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true", nullable=False
|
||||
)
|
||||
# Permite desactivar permisos sin eliminarlos
|
||||
|
||||
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||
|
||||
# Relaciones
|
||||
role_permissions: Mapped[list["RolePermission"]] = relationship(
|
||||
"RolePermission", back_populates="permission", cascade="all, delete-orphan"
|
||||
)
|
||||
user_company_permissions: Mapped[list["UserCompanyPermission"]] = relationship(
|
||||
"UserCompanyPermission",
|
||||
back_populates="permission",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
# Modelo para roles personalizados por compañia/tenant
|
||||
# Cada compañia puede definir sus propios roles con nombres personalizados
|
||||
class CompanyRole(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "company_roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# Nombre del rol (ej: "Administrador", "Contador", "Vendedor")
|
||||
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# Código único del rol dentro del compañia (ej: "admin", "accountant")
|
||||
|
||||
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
# Descripción del rol
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true", nullable=False
|
||||
)
|
||||
# Permite desactivar roles sin eliminarlos
|
||||
|
||||
# Restricción: el código del rol debe ser único por compañia
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"company_id", "tenant_id", "code", name="uq_company_role_code"
|
||||
),
|
||||
Index(
|
||||
"ix_company_roles_company_id_is_active",
|
||||
"company_id",
|
||||
"tenant_id",
|
||||
"is_active",
|
||||
),
|
||||
{"schema": "core", "extend_existing": True},
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
role_permissions: Mapped[list["RolePermission"]] = relationship(
|
||||
"RolePermission", back_populates="company_role", cascade="all, delete-orphan"
|
||||
)
|
||||
user_company_roles: Mapped[list["UserCompanyRole"]] = relationship(
|
||||
"UserCompanyRole",
|
||||
back_populates="company_role",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
# Tabla de relación entre roles de compañia y permisos
|
||||
# Define qué permisos tiene cada rol
|
||||
class RolePermission(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
company_role_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("core.company_roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
permission_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("core.permissions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Restricción: un permiso no puede estar duplicado en el mismo rol
|
||||
__table_args__ = (
|
||||
UniqueConstraint("company_role_id", "permission_id", name="uq_role_permission"),
|
||||
Index("ix_role_permissions_composite", "company_role_id", "permission_id"),
|
||||
{"schema": "core", "extend_existing": True},
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
company_role: Mapped["CompanyRole"] = relationship(
|
||||
"CompanyRole", back_populates="role_permissions"
|
||||
)
|
||||
permission: Mapped["Permission"] = relationship(
|
||||
"Permission", back_populates="role_permissions"
|
||||
)
|
||||
|
||||
|
||||
# Tabla de relación entre usuarios y roles de compañia
|
||||
# Define qué roles tiene cada usuario en cada compañia
|
||||
class UserCompanyRole(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "user_company_roles"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
# ID del usuario (puede ser UUID de Keycloak u otro identificador)
|
||||
|
||||
company_role_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("core.company_roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true", nullable=False
|
||||
)
|
||||
# Permite desactivar asignaciones sin eliminarlas
|
||||
|
||||
assigned_by: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
# ID del usuario que asignó este rol
|
||||
|
||||
# Restricción: un usuario no puede tener el mismo rol duplicado en un compañia
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id",
|
||||
"company_id",
|
||||
"tenant_id",
|
||||
"company_role_id",
|
||||
name="uq_user_company_role",
|
||||
),
|
||||
Index(
|
||||
"ix_user_company_roles_user_company",
|
||||
"user_id",
|
||||
"company_id",
|
||||
"tenant_id",
|
||||
"is_active",
|
||||
),
|
||||
{"schema": "core", "extend_existing": True},
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
company_role: Mapped["CompanyRole"] = relationship(
|
||||
"CompanyRole", back_populates="user_company_roles"
|
||||
)
|
||||
|
||||
|
||||
# Tabla para permisos directos de usuario por compañia (opcional)
|
||||
# Permite asignar permisos específicos a un usuario sin necesidad de un rol
|
||||
# Útil para casos excepcionales o permisos temporales
|
||||
class UserCompanyPermission(Base, TenantScopedMixin, TimestampMixin):
|
||||
__tablename__ = "user_company_permissions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
# ID del usuario
|
||||
|
||||
permission_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("core.permissions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
is_granted: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true", nullable=False
|
||||
)
|
||||
# True = permiso concedido, False = permiso revocado explícitamente
|
||||
# Permite revocar permisos que vienen de roles
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true", nullable=False
|
||||
)
|
||||
|
||||
assigned_by: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
# Permite permisos temporales con fecha de expiración
|
||||
|
||||
# Restricción: un usuario no puede tener el mismo permiso duplicado en un compañia
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id",
|
||||
"company_id",
|
||||
"tenant_id",
|
||||
"permission_id",
|
||||
name="uq_user_company_permission",
|
||||
),
|
||||
Index(
|
||||
"ix_user_company_permissions_composite",
|
||||
"user_id",
|
||||
"company_id",
|
||||
"tenant_id",
|
||||
"is_active",
|
||||
),
|
||||
{"schema": "core", "extend_existing": True},
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
permission: Mapped["Permission"] = relationship(
|
||||
"Permission", back_populates="user_company_permissions"
|
||||
)
|
||||
101
backend/api/v1/modules/core/permissions/registry.py
Normal file
101
backend/api/v1/modules/core/permissions/registry.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Registro centralizado para la modulación de permisos.
|
||||
Permite que cada módulo registre sus propios permisos de forma dinámica.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class PermissionDefinition:
|
||||
"""Representa la definición de un permiso en un módulo."""
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
module: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Lógica de autocompletado para evitar redundancia."""
|
||||
# Si el código tiene el formato "modulo.sub.accion" o "modulo.accion"
|
||||
parts = self.code.split(".")
|
||||
|
||||
# Extraer módulo si no se especificó
|
||||
if not self.module and len(parts) > 1:
|
||||
self.module = parts[0]
|
||||
elif not self.module:
|
||||
self.module = "system" # Default fallback
|
||||
|
||||
# Extraer acción si no se especificó (es la última parte del código)
|
||||
if not self.action and len(parts) > 1:
|
||||
self.action = parts[-1]
|
||||
elif not self.action:
|
||||
self.action = "view" # Default fallback
|
||||
|
||||
|
||||
class PermissionRegistry:
|
||||
"""
|
||||
Registro Singleton para permisos de la aplicación.
|
||||
Cada módulo de la API debe importar este registro y dar de alta sus permisos.
|
||||
"""
|
||||
_instance = None
|
||||
_permissions: Dict[str, PermissionDefinition] = {}
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(PermissionRegistry, cls).__new__(cls)
|
||||
cls._permissions = {}
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def register(cls,
|
||||
code: str,
|
||||
description: Optional[str] = None,
|
||||
module: Optional[str] = None,
|
||||
action: Optional[str] = None) -> None:
|
||||
"""
|
||||
Registra un nuevo permiso en el sistema.
|
||||
"""
|
||||
if code in cls._permissions:
|
||||
logger.debug(f"Permiso {code} ya está registrado, actualizando metadatos.")
|
||||
|
||||
cls._permissions[code] = PermissionDefinition(
|
||||
code=code,
|
||||
description=description,
|
||||
module=module,
|
||||
action=action
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def register_many(cls, permissions_list: List[tuple]) -> None:
|
||||
"""
|
||||
Registra múltiples permisos desde una lista de tuplas.
|
||||
Útil para migrar seeds estáticos.
|
||||
"""
|
||||
for item in permissions_list:
|
||||
if len(item) == 2: # (code, description)
|
||||
cls.register(code=item[0], description=item[1])
|
||||
elif len(item) >= 3: # (code, description, module, ...)
|
||||
cls.register(
|
||||
code=item[0],
|
||||
description=item[1],
|
||||
module=item[2],
|
||||
action=item[3] if len(item) > 3 else None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> List[PermissionDefinition]:
|
||||
"""Retorna todos los permisos registrados."""
|
||||
return list(cls._permissions.values())
|
||||
|
||||
@classmethod
|
||||
def get_by_module(cls, module_name: str) -> List[PermissionDefinition]:
|
||||
"""Retorna los permisos de un módulo específico."""
|
||||
return [p for p in cls._permissions.values() if p.module == module_name]
|
||||
|
||||
|
||||
# Instancia global para facilitar el acceso
|
||||
registry = PermissionRegistry()
|
||||
1364
backend/api/v1/modules/core/permissions/routes.py
Normal file
1364
backend/api/v1/modules/core/permissions/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
331
backend/api/v1/modules/core/permissions/schemas.py
Normal file
331
backend/api/v1/modules/core/permissions/schemas.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Esquemas Pydantic para el módulo de permisos.
|
||||
Define los modelos de request/response para las APIs de permisos.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS DE RESPONSE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
"""Esquema de respuesta para un permiso individual."""
|
||||
|
||||
id: int
|
||||
code: str = Field(..., description="Código único del permiso (ej: 'invoice.view')")
|
||||
description: Optional[str] = Field(None, description="Descripción del permiso")
|
||||
module: str = Field(..., description="Módulo al que pertenece (ej: 'invoice')")
|
||||
action: str = Field(..., description="Acción específica (ej: 'view', 'edit')")
|
||||
is_active: bool = Field(..., description="Si el permiso está activo")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CompanyRoleResponse(BaseModel):
|
||||
"""Esquema de respuesta para un rol de companye."""
|
||||
|
||||
id: int
|
||||
company_id: int = Field(..., description="ID del companye al que pertenece el rol")
|
||||
name: str = Field(..., description="Nombre del rol (ej: 'Administrador')")
|
||||
code: str = Field(..., description="Código del rol (ej: 'admin')")
|
||||
description: Optional[str] = Field(None, description="Descripción del rol")
|
||||
is_active: bool = Field(..., description="Si el rol está activo")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CompanyRoleWithPermissionsResponse(CompanyRoleResponse):
|
||||
"""Esquema de respuesta para un rol con sus permisos incluidos."""
|
||||
|
||||
permissions: List[PermissionResponse] = Field(
|
||||
default_factory=list, description="Lista de permisos asignados a este rol"
|
||||
)
|
||||
|
||||
|
||||
class UserPermissionsResponse(BaseModel):
|
||||
"""Esquema de respuesta para los permisos de un usuario."""
|
||||
|
||||
user_id: str = Field(..., description="ID del usuario")
|
||||
company_id: int = Field(..., description="ID del companye")
|
||||
tenant_id: Optional[int] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"ID del tenant resuelto en backend para esta compañía. Permite "
|
||||
"al frontend dejar de leer tenant_id desde claims del JWT."
|
||||
),
|
||||
)
|
||||
permissions: List[str] = Field(
|
||||
default_factory=list, description="Lista de códigos de permisos del usuario"
|
||||
)
|
||||
roles: List[str] = Field(
|
||||
default_factory=list, description="Lista de nombres de roles del usuario"
|
||||
)
|
||||
allowed_systems: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory",
|
||||
)
|
||||
|
||||
|
||||
class UserCompanyRoleResponse(BaseModel):
|
||||
"""Esquema de respuesta para la asignación de rol a usuario."""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
company_id: int
|
||||
company_role_id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
assigned_by: Optional[str] = None
|
||||
company_role: Optional[CompanyRoleResponse] = Field(None, description="Información del rol asignado")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserCompanyPermissionResponse(BaseModel):
|
||||
"""Esquema de respuesta para un permiso directo de usuario."""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
company_id: int
|
||||
permission_id: int
|
||||
permission_code: Optional[str] = None
|
||||
is_granted: bool = Field(
|
||||
..., description="True si está concedido, False si está revocado"
|
||||
)
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
assigned_by: Optional[str] = None
|
||||
expires_at: Optional[datetime] = Field(
|
||||
None, description="Fecha de expiración del permiso"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS DE REQUEST
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AssignRoleRequest(BaseModel):
|
||||
"""Esquema de request para asignar un rol a un usuario."""
|
||||
|
||||
user_id: str = Field(..., description="ID del usuario al que se asignará el rol")
|
||||
role_id: int = Field(..., description="ID del rol a asignar")
|
||||
|
||||
|
||||
class RemoveRoleRequest(BaseModel):
|
||||
"""Esquema de request para remover un rol de un usuario."""
|
||||
|
||||
user_id: str = Field(..., description="ID del usuario")
|
||||
role_id: int = Field(..., description="ID del rol a remover")
|
||||
|
||||
|
||||
class GrantPermissionRequest(BaseModel):
|
||||
"""Esquema de request para conceder un permiso directo a un usuario."""
|
||||
|
||||
user_id: str = Field(..., description="ID del usuario")
|
||||
permission_code: str = Field(
|
||||
..., description="Código del permiso a conceder (ej: 'invoice.delete')"
|
||||
)
|
||||
expires_at: Optional[datetime] = Field(
|
||||
None, description="Fecha de expiración del permiso (opcional)"
|
||||
)
|
||||
|
||||
|
||||
class RevokePermissionRequest(BaseModel):
|
||||
"""Esquema de request para revocar un permiso directo."""
|
||||
|
||||
user_id: str = Field(..., description="ID del usuario")
|
||||
permission_code: str = Field(..., description="Código del permiso a revocar")
|
||||
|
||||
|
||||
class CreateRoleRequest(BaseModel):
|
||||
"""Esquema de request para crear un rol personalizado."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Nombre del rol")
|
||||
code: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Código único del rol (ej: 'custom_admin')",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
None, max_length=255, description="Descripción del rol"
|
||||
)
|
||||
permission_ids: List[int] = Field(
|
||||
default_factory=list, description="IDs de permisos a asignar al rol"
|
||||
)
|
||||
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
"""Esquema de request para actualizar un rol existente."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class AssignPermissionsToRoleRequest(BaseModel):
|
||||
"""Esquema de request para asignar permisos a un rol."""
|
||||
|
||||
permission_ids: List[int] = Field(
|
||||
..., description="Lista de IDs de permisos a asignar al rol"
|
||||
)
|
||||
replace_existing: bool = Field(
|
||||
False,
|
||||
description="Si True, reemplaza los permisos existentes. Si False, los agrega.",
|
||||
)
|
||||
|
||||
|
||||
class CreatePermissionRequest(BaseModel):
|
||||
"""Esquema de request para crear un nuevo permiso (uso administrativo)."""
|
||||
|
||||
code: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
description="Código único del permiso (ej: 'custom_module.action')",
|
||||
)
|
||||
description: Optional[str] = Field(None, max_length=255)
|
||||
module: str = Field(
|
||||
..., min_length=1, max_length=50, description="Módulo del permiso"
|
||||
)
|
||||
action: str = Field(
|
||||
..., min_length=1, max_length=50, description="Acción del permiso"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS DE RESPUESTA GENÉRICOS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Respuesta genérica de éxito."""
|
||||
|
||||
success: bool = True
|
||||
message: str = Field(..., description="Mensaje descriptivo de la operación")
|
||||
data: Optional[dict] = Field(None, description="Datos adicionales opcionales")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Respuesta genérica de error."""
|
||||
|
||||
success: bool = False
|
||||
detail: str = Field(..., description="Descripción del error")
|
||||
error_code: Optional[str] = Field(None, description="Código de error específico")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS DE PAGINACIÓN
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""Esquema genérico para respuestas paginadas."""
|
||||
|
||||
items: List[dict] = Field(default_factory=list)
|
||||
total: int = Field(..., description="Total de items disponibles")
|
||||
page: int = Field(..., description="Página actual")
|
||||
page_size: int = Field(..., description="Tamaño de página")
|
||||
total_pages: int = Field(..., description="Total de páginas disponibles")
|
||||
|
||||
|
||||
class PermissionListResponse(BaseModel):
|
||||
"""Lista paginada de permisos."""
|
||||
|
||||
items: List[PermissionResponse]
|
||||
total: int
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
|
||||
|
||||
class RoleListResponse(BaseModel):
|
||||
"""Lista paginada de roles."""
|
||||
|
||||
items: List[CompanyRoleResponse]
|
||||
total: int
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
|
||||
|
||||
class UserRoleListResponse(BaseModel):
|
||||
"""Lista paginada de asignaciones de roles a usuarios."""
|
||||
|
||||
items: List[UserCompanyRoleResponse]
|
||||
total: int
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
|
||||
|
||||
class AssignUserRoleRequest(BaseModel):
|
||||
"""Esquema de request para asignar un rol a un usuario."""
|
||||
|
||||
user_id: str = Field(..., description="ID del usuario")
|
||||
company_role_id: int = Field(..., description="ID del rol a asignar")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SCHEMAS PARA PERMISOS INDIVIDUALES DE USUARIO
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class UserPermissionResponse(BaseModel):
|
||||
"""Esquema de respuesta para un permiso individual de usuario."""
|
||||
|
||||
id: int
|
||||
user_id: str
|
||||
permission_id: int
|
||||
company_id: int
|
||||
tenant_id: int
|
||||
is_granted: bool = Field(..., description="True = permiso concedido, False = permiso revocado")
|
||||
is_active: bool
|
||||
assigned_by: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
permission: Optional[PermissionResponse] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AssignUserPermissionRequest(BaseModel):
|
||||
"""Esquema de request para asignar un permiso individual a un usuario."""
|
||||
|
||||
permission_id: int = Field(..., description="ID del permiso")
|
||||
is_granted: bool = Field(True, description="True para conceder, False para revocar")
|
||||
expires_at: Optional[datetime] = Field(None, description="Fecha de expiración (opcional)")
|
||||
|
||||
|
||||
class UserPermissionsListResponse(BaseModel):
|
||||
"""Lista de permisos individuales de un usuario."""
|
||||
|
||||
items: List[UserPermissionResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class EffectiveUserPermissionsResponse(BaseModel):
|
||||
"""Permisos efectivos de un usuario (roles + individuales - revocados)."""
|
||||
|
||||
user_id: str
|
||||
company_id: int
|
||||
role_permissions: List[PermissionResponse] = Field(
|
||||
default_factory=list, description="Permisos heredados de roles"
|
||||
)
|
||||
granted_permissions: List[PermissionResponse] = Field(
|
||||
default_factory=list, description="Permisos individuales concedidos"
|
||||
)
|
||||
revoked_permissions: List[PermissionResponse] = Field(
|
||||
default_factory=list, description="Permisos revocados explícitamente"
|
||||
)
|
||||
effective_permissions: List[PermissionResponse] = Field(
|
||||
default_factory=list, description="Permisos finales efectivos"
|
||||
)
|
||||
585
backend/api/v1/modules/core/permissions/service.py
Normal file
585
backend/api/v1/modules/core/permissions/service.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Servicio de gestión de permisos multi-tenant.
|
||||
Proporciona funciones para verificar y obtener permisos de usuarios por companye.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Set, Optional, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from core.database import RLS_TENANT_KEY
|
||||
from .cache import PermissionCache
|
||||
from .models import (
|
||||
Permission,
|
||||
CompanyRole,
|
||||
RolePermission,
|
||||
UserCompanyRole,
|
||||
UserCompanyPermission,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PermissionService:
|
||||
"""
|
||||
Servicio para gestionar permisos de usuarios en contextos multi-tenant.
|
||||
Combina permisos de roles y permisos directos del usuario.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self._cache = PermissionCache()
|
||||
|
||||
def _ensure_user_tenant_row_for_company(
|
||||
self, user_id: str, company_id: int
|
||||
) -> None:
|
||||
"""STUB — implementa con el modelo de compañía de tu proyecto."""
|
||||
|
||||
def _resolve_tenant_id_for_company(self, company_id: int) -> Optional[int]:
|
||||
"""
|
||||
Resuelve tenant_id efectivo para una compañía usando primero el contexto RLS
|
||||
de la sesión y, como fallback, la tabla de compañías.
|
||||
"""
|
||||
tenant_id = self.db.info.get(RLS_TENANT_KEY)
|
||||
if tenant_id is not None:
|
||||
try:
|
||||
return int(tenant_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Sin modelo de compañía en la plantilla — implementa la consulta aquí.
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"resolve_tenant_id_for_company_failed",
|
||||
extra={
|
||||
"company_id": company_id,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_user_permissions_uncached(
|
||||
self, user_id: str, company_id: int
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Lógica de cálculo de permisos sin caché.
|
||||
"""
|
||||
role_permissions = self._get_permissions_from_roles(user_id, company_id)
|
||||
direct_permissions = self._get_direct_permissions(user_id, company_id)
|
||||
|
||||
all_permissions = role_permissions.copy()
|
||||
|
||||
for perm_code, is_granted in direct_permissions.items():
|
||||
if is_granted:
|
||||
all_permissions.add(perm_code)
|
||||
else:
|
||||
all_permissions.discard(perm_code)
|
||||
|
||||
return all_permissions
|
||||
|
||||
def get_user_permissions(
|
||||
self, user_id: str, company_id: int, use_cache: bool = False
|
||||
) -> Set[str]:
|
||||
"""
|
||||
Obtiene todos los permisos de un usuario para un companye específico.
|
||||
Puede usar caché de Valkey cuando use_cache=True.
|
||||
"""
|
||||
if not use_cache:
|
||||
return self._get_user_permissions_uncached(user_id, company_id)
|
||||
|
||||
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||
cache_key = self._cache.build_permissions_key(tenant_id, company_id, user_id)
|
||||
|
||||
cached = self._cache.get_permissions(
|
||||
cache_key,
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
permissions = self._get_user_permissions_uncached(user_id, company_id)
|
||||
self._cache.set_permissions(
|
||||
cache_key,
|
||||
permissions,
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
return permissions
|
||||
|
||||
def _get_permissions_from_roles(self, user_id: str, company_id: int) -> Set[str]:
|
||||
"""
|
||||
Obtiene permisos derivados de los roles del usuario en el companye.
|
||||
|
||||
Realiza un JOIN eficiente para obtener todos los permisos de los roles activos.
|
||||
"""
|
||||
query = (
|
||||
self.db.query(Permission.code)
|
||||
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||
.join(CompanyRole, CompanyRole.id == RolePermission.company_role_id)
|
||||
.join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id)
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.is_active == True,
|
||||
CompanyRole.is_active == True,
|
||||
Permission.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.all()
|
||||
return {perm_code for (perm_code,) in results}
|
||||
|
||||
def _get_direct_permissions(self, user_id: str, company_id: int) -> dict:
|
||||
"""
|
||||
Obtiene permisos directos asignados al usuario.
|
||||
|
||||
Returns:
|
||||
Dict con código de permiso como key y is_granted como value
|
||||
{
|
||||
"invoice.delete": True, # Permiso concedido
|
||||
"user.delete": False # Permiso revocado explícitamente
|
||||
}
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
query = (
|
||||
self.db.query(Permission.code, UserCompanyPermission.is_granted)
|
||||
.join(
|
||||
UserCompanyPermission,
|
||||
UserCompanyPermission.permission_id == Permission.id,
|
||||
)
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyPermission.user_id == user_id,
|
||||
UserCompanyPermission.company_id == company_id,
|
||||
UserCompanyPermission.is_active == True,
|
||||
Permission.is_active == True,
|
||||
or_(
|
||||
UserCompanyPermission.expires_at.is_(None),
|
||||
UserCompanyPermission.expires_at > now,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.all()
|
||||
return {perm_code: is_granted for perm_code, is_granted in results}
|
||||
|
||||
def has_permission(
|
||||
self, user_id: str, company_id: int, permission_code: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verifica si un usuario tiene un permiso específico en un companye.
|
||||
|
||||
Args:
|
||||
user_id: ID del usuario
|
||||
company_id: ID del companye/tenant
|
||||
permission_code: Código del permiso (ej: "invoice.edit")
|
||||
|
||||
Returns:
|
||||
True si el usuario tiene el permiso, False en caso contrario
|
||||
"""
|
||||
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
|
||||
return permission_code in permissions
|
||||
|
||||
def has_any_permission(
|
||||
self, user_id: str, company_id: int, permission_codes: List[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Verifica si el usuario tiene al menos uno de los permisos especificados.
|
||||
"""
|
||||
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
|
||||
return any(perm in permissions for perm in permission_codes)
|
||||
|
||||
def has_all_permissions(
|
||||
self, user_id: str, company_id: int, permission_codes: List[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Verifica si el usuario tiene todos los permisos especificados.
|
||||
"""
|
||||
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
|
||||
return all(perm in permissions for perm in permission_codes)
|
||||
|
||||
def get_user_roles(self, user_id: str, company_id: int) -> List[CompanyRole]:
|
||||
"""
|
||||
Obtiene los roles activos de un usuario en un companye.
|
||||
"""
|
||||
query = (
|
||||
self.db.query(CompanyRole)
|
||||
.join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id)
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.is_active == True,
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return query.all()
|
||||
|
||||
def assign_role_to_user(
|
||||
self,
|
||||
user_id: str,
|
||||
company_id: int,
|
||||
role_id: int,
|
||||
assigned_by: Optional[str] = None,
|
||||
) -> UserCompanyRole:
|
||||
"""
|
||||
Asigna un rol a un usuario en un companye específico.
|
||||
"""
|
||||
# Verificar que el rol pertenece al companye
|
||||
role = (
|
||||
self.db.query(CompanyRole)
|
||||
.filter(
|
||||
and_(
|
||||
CompanyRole.id == role_id,
|
||||
CompanyRole.company_id == company_id,
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not role:
|
||||
raise ValueError(f"Role {role_id} not found for company {company_id}")
|
||||
|
||||
# Verificar si ya existe la asignación
|
||||
existing = (
|
||||
self.db.query(UserCompanyRole)
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.company_role_id == role_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
existing.assigned_at = datetime.utcnow()
|
||||
existing.assigned_by = assigned_by
|
||||
self.db.commit()
|
||||
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||
# Invalidar caché del usuario tras reactivar el rol
|
||||
try:
|
||||
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_user_after_assign_role_failed",
|
||||
extra={"user_id": user_id, "company_id": company_id},
|
||||
)
|
||||
return existing
|
||||
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||
return existing
|
||||
|
||||
# Crear nueva asignación
|
||||
user_role = UserCompanyRole(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
company_role_id=role_id,
|
||||
assigned_by=assigned_by,
|
||||
)
|
||||
|
||||
self.db.add(user_role)
|
||||
self.db.commit()
|
||||
self.db.refresh(user_role)
|
||||
|
||||
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||
|
||||
# Invalidar caché del usuario tras nueva asignación de rol
|
||||
try:
|
||||
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_user_after_assign_role_failed",
|
||||
extra={"user_id": user_id, "company_id": company_id},
|
||||
)
|
||||
|
||||
return user_role
|
||||
|
||||
def grant_direct_permission(
|
||||
self,
|
||||
user_id: str,
|
||||
company_id: int,
|
||||
permission_code: str,
|
||||
assigned_by: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> UserCompanyPermission:
|
||||
"""
|
||||
Concede un permiso directo a un usuario en un companye.
|
||||
"""
|
||||
# Obtener el permiso por código
|
||||
permission = (
|
||||
self.db.query(Permission)
|
||||
.filter(
|
||||
and_(Permission.code == permission_code, Permission.is_active == True)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not permission:
|
||||
raise ValueError(f"Permission {permission_code} not found")
|
||||
|
||||
# Verificar si ya existe
|
||||
existing = (
|
||||
self.db.query(UserCompanyPermission)
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyPermission.user_id == user_id,
|
||||
UserCompanyPermission.company_id == company_id,
|
||||
UserCompanyPermission.permission_id == permission.id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.is_granted = True
|
||||
existing.is_active = True
|
||||
existing.assigned_at = datetime.utcnow()
|
||||
existing.assigned_by = assigned_by
|
||||
existing.expires_at = expires_at
|
||||
self.db.commit()
|
||||
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||
# Invalidar caché del usuario tras mutación
|
||||
try:
|
||||
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||
except Exception:
|
||||
# La invalidación de caché nunca debe romper la operación principal
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_user_after_grant_failed",
|
||||
extra={"user_id": user_id, "company_id": company_id},
|
||||
)
|
||||
return existing
|
||||
|
||||
# Crear nuevo permiso directo
|
||||
user_permission = UserCompanyPermission(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
permission_id=permission.id,
|
||||
is_granted=True,
|
||||
assigned_by=assigned_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
self.db.add(user_permission)
|
||||
self.db.commit()
|
||||
self.db.refresh(user_permission)
|
||||
|
||||
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||
|
||||
# Invalidar caché del usuario tras mutación
|
||||
try:
|
||||
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_user_after_grant_failed",
|
||||
extra={"user_id": user_id, "company_id": company_id},
|
||||
)
|
||||
|
||||
return user_permission
|
||||
|
||||
def sync_permissions(self) -> dict:
|
||||
"""
|
||||
Sincroniza los permisos registrados en el registry con la base de datos.
|
||||
Inserta nuevos permisos y actualiza los existentes.
|
||||
"""
|
||||
from .registry import registry
|
||||
# IMPORTANTE: Importar seed_v2 para que se ejecute register_core_permissions()
|
||||
from . import seed_v2
|
||||
|
||||
registered_permissions = registry.get_all()
|
||||
synced_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for p_def in registered_permissions:
|
||||
# Buscar permiso existente
|
||||
db_permission = self.db.query(Permission).filter(Permission.code == p_def.code).first()
|
||||
|
||||
if db_permission:
|
||||
# Actualizar si hay cambios
|
||||
changed = False
|
||||
if db_permission.description != p_def.description:
|
||||
db_permission.description = p_def.description
|
||||
changed = True
|
||||
if db_permission.module != p_def.module:
|
||||
db_permission.module = p_def.module
|
||||
changed = True
|
||||
if db_permission.action != p_def.action:
|
||||
db_permission.action = p_def.action
|
||||
changed = True
|
||||
if db_permission.is_active != p_def.is_active:
|
||||
db_permission.is_active = p_def.is_active
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
updated_count += 1
|
||||
else:
|
||||
# Crear nuevo
|
||||
new_permission = Permission(
|
||||
code=p_def.code,
|
||||
description=p_def.description,
|
||||
module=p_def.module,
|
||||
action=p_def.action,
|
||||
is_active=p_def.is_active
|
||||
)
|
||||
self.db.add(new_permission)
|
||||
synced_count += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return {
|
||||
"synced": synced_count,
|
||||
"updated": updated_count,
|
||||
"total_registered": len(registered_permissions)
|
||||
}
|
||||
|
||||
def bootstrap_super_admin(self, user_id: str, company_id: int) -> bool:
|
||||
"""
|
||||
Crea un rol de Super Administrador con todos los permisos y se lo asigna al usuario.
|
||||
Diseñado para el primer inicio de una compañía o para asegurar acceso a administradores.
|
||||
"""
|
||||
from .models import CompanyRole, RolePermission, UserCompanyRole, Permission
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# 0. Sincronizar permisos por si la tabla esta vacía
|
||||
# Esto puebla la tabla 'permissions' desde el registry de código
|
||||
logger.info("Bootstrap: Sincronizando catálogo de permisos desde el registry...")
|
||||
from . import seed_v2
|
||||
sync_res = self.sync_permissions()
|
||||
logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.")
|
||||
|
||||
# 1. Obtener el tenant_id (implementa con tu modelo de compañía)
|
||||
tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1
|
||||
|
||||
# 2. Buscar si ya existe el rol "super_admin"
|
||||
admin_role = self.db.query(CompanyRole).filter(
|
||||
CompanyRole.company_id == company_id,
|
||||
CompanyRole.code == "super_admin"
|
||||
).first()
|
||||
|
||||
if not admin_role:
|
||||
logger.info(f"Bootstrap: Creando Super Administrador para usuario {user_id} (Company: {company_id})")
|
||||
# Crear el rol Super Administrador si no existe
|
||||
admin_role = CompanyRole(
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Super Administrador",
|
||||
code="super_admin",
|
||||
description="Rol con acceso total al sistema (generado automáticamente)",
|
||||
is_active=True
|
||||
)
|
||||
self.db.add(admin_role)
|
||||
self.db.flush()
|
||||
|
||||
# 4. Asignar TODOS los permisos activos al rol
|
||||
all_perms = self.db.query(Permission).filter(Permission.is_active == True).all()
|
||||
if not all_perms:
|
||||
logger.warning("Bootstrap: ¡ALERTA! No se encontraron permisos en la DB ni tras la sincronización.")
|
||||
|
||||
for perm in all_perms:
|
||||
role_perm = RolePermission(
|
||||
company_role_id=admin_role.id,
|
||||
permission_id=perm.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id
|
||||
)
|
||||
self.db.add(role_perm)
|
||||
else:
|
||||
logger.info(f"Bootstrap: El rol super_admin ya existe para la compañía {company_id}. Sincronizando permisos nuevos si es necesario.")
|
||||
# Asegurar que el rol tenga TODOS los permisos activos (incluidos los agregados después)
|
||||
all_perms = self.db.query(Permission).filter(Permission.is_active == True).all()
|
||||
existing_perm_ids = {
|
||||
pid
|
||||
for (pid,) in self.db.query(RolePermission.permission_id).filter(
|
||||
RolePermission.company_role_id == admin_role.id
|
||||
)
|
||||
}
|
||||
added = 0
|
||||
for perm in all_perms:
|
||||
if perm.id in existing_perm_ids:
|
||||
continue
|
||||
role_perm = RolePermission(
|
||||
company_role_id=admin_role.id,
|
||||
permission_id=perm.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
self.db.add(role_perm)
|
||||
added += 1
|
||||
if added:
|
||||
logger.info(
|
||||
"Bootstrap: sincronicé %s permisos nuevos en super_admin company_id=%s",
|
||||
added,
|
||||
company_id,
|
||||
)
|
||||
|
||||
# 5. Asegurar que el usuario tenga el rol asignado
|
||||
user_has_role = self.db.query(UserCompanyRole).filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.company_role_id == admin_role.id
|
||||
).first()
|
||||
|
||||
if not user_has_role:
|
||||
logger.info(f"Bootstrap: Asignando rol Super Administrador al usuario {user_id}")
|
||||
user_role = UserCompanyRole(
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
tenant_id=tenant_id,
|
||||
company_role_id=admin_role.id,
|
||||
is_active=True,
|
||||
assigned_by="SYSTEM_BOOTSTRAP"
|
||||
)
|
||||
self.db.add(user_role)
|
||||
self.db.commit()
|
||||
# Invalida caché de permisos de la compañía y del usuario afectado
|
||||
try:
|
||||
self._cache.invalidate_company(company_id)
|
||||
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_after_bootstrap_failed",
|
||||
extra={"user_id": user_id, "company_id": company_id},
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.info(f"Bootstrap: El usuario {user_id} ya tiene el rol asignado.")
|
||||
self.db.commit()
|
||||
# Invalida caché de permisos de la compañía para reflejar cambios de catálogo
|
||||
try:
|
||||
self._cache.invalidate_company(company_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"permission_cache_invalidate_after_bootstrap_failed",
|
||||
extra={"user_id": user_id, "company_id": company_id},
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Bootstrap: ERROR CRÍTICO - {str(e)}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
50
backend/api/v1/modules/core/permissions/sync_cli.py
Normal file
50
backend/api/v1/modules/core/permissions/sync_cli.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Script CLI para sincronizar los permisos de la aplicación.
|
||||
Útil para el bootstrap inicial cuando el endpoint /sync aún no es accesible
|
||||
o cuando se desea forzar una actualización desde la consola/Docker.
|
||||
|
||||
Uso:
|
||||
docker exec -it <container> python3 -m api.v1.modules.core.permissions.sync_cli
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
# Configurar logging básico para ver resultados en consola
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Asegurar que el backend esté en el path
|
||||
sys.path.append(os.path.abspath("."))
|
||||
sys.path.append(os.path.abspath("backend"))
|
||||
|
||||
from core.database import CoreSessionLocal
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
|
||||
def run_sync():
|
||||
"""Ejecuta la lógica de sincronización modular."""
|
||||
logger.info("Iniciando Sincronización Modular de Permisos...")
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
service = PermissionService(db)
|
||||
result = service.sync_permissions()
|
||||
|
||||
logger.info("-" * 40)
|
||||
logger.info(f"Sincronización Exitosa!")
|
||||
logger.info(f" - Nuevos insertados: {result['synced']}")
|
||||
logger.info(f" - Existentes actualizados: {result['updated']}")
|
||||
logger.info(f" - Total en Registry: {result['total_registered']}")
|
||||
logger.info("-" * 40)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error crítico durante la sincronización: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_sync()
|
||||
26
backend/api/v1/modules/core/router.py
Normal file
26
backend/api/v1/modules/core/router.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
from .tenants.routes import router as tenants_router
|
||||
from .user_tenant.routes import router as user_tenant_router
|
||||
from .users.routes import router as users_router
|
||||
from .dashboard.routes import router as dashboard_router
|
||||
from .help_center.routes import router as help_center_router
|
||||
from .tasks_tracking.routes import router as tasks_tracking_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
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"])
|
||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||
router.include_router(permissions_router, prefix="/core", tags=["core / permissions"])
|
||||
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
||||
router.include_router(help_center_router, prefix="/core", tags=["core / help-center"])
|
||||
router.include_router(tasks_tracking_router, prefix="/core", tags=["core / tasks"])
|
||||
4
backend/api/v1/modules/core/tasks_tracking/__init__.py
Normal file
4
backend/api/v1/modules/core/tasks_tracking/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .dispatch import track_and_dispatch
|
||||
from .service import TaskTrackerService
|
||||
|
||||
__all__ = ["track_and_dispatch", "TaskTrackerService"]
|
||||
75
backend/api/v1/modules/core/tasks_tracking/dispatch.py
Normal file
75
backend/api/v1/modules/core/tasks_tracking/dispatch.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from celery import Task
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import rls_company_var, rls_tenant_var
|
||||
|
||||
from .service import TaskTrackerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def track_and_dispatch(
|
||||
*,
|
||||
db: Session,
|
||||
task: Task,
|
||||
tenant_id: int,
|
||||
task_name: str,
|
||||
task_group: str,
|
||||
company_id: int | None = None,
|
||||
requested_by_user: str | None = None,
|
||||
task_origin: str | None = None,
|
||||
args: list[Any] | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
task_id: str | None = None,
|
||||
meta_payload: dict[str, Any] | None = None,
|
||||
):
|
||||
# Propaga contexto RLS vía Celery headers (leídos en task_prerun) y
|
||||
# ContextVars (para modo eager, donde before_task_publish no dispara).
|
||||
headers = {"rls_tenant_id": str(int(tenant_id))}
|
||||
if company_id is not None:
|
||||
headers["rls_company_id"] = str(int(company_id))
|
||||
else:
|
||||
logger.warning(
|
||||
"Dispatching task without company_id in RLS headers task=%s tenant_id=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
tenant_id,
|
||||
)
|
||||
|
||||
prev_tenant = rls_tenant_var.get()
|
||||
prev_company = rls_company_var.get()
|
||||
rls_tenant_var.set(int(tenant_id))
|
||||
rls_company_var.set(int(company_id) if company_id is not None else None)
|
||||
try:
|
||||
logger.info(
|
||||
"Dispatching Celery task task=%s task_id=%s tenant_id=%s company_id=%s headers=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
tenant_id,
|
||||
company_id,
|
||||
headers,
|
||||
)
|
||||
celery_task = task.apply_async(
|
||||
args=args or [],
|
||||
kwargs=kwargs or {},
|
||||
task_id=task_id,
|
||||
headers=headers,
|
||||
)
|
||||
finally:
|
||||
rls_tenant_var.set(prev_tenant)
|
||||
rls_company_var.set(prev_company)
|
||||
|
||||
tracker = TaskTrackerService(db)
|
||||
tracker.register_dispatch(
|
||||
task_id=celery_task.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
requested_by_user=requested_by_user,
|
||||
task_name=task_name,
|
||||
task_group=task_group,
|
||||
task_origin=task_origin,
|
||||
meta_payload=meta_payload,
|
||||
)
|
||||
return celery_task
|
||||
62
backend/api/v1/modules/core/tasks_tracking/models.py
Normal file
62
backend/api/v1/modules/core/tasks_tracking/models.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class TaskRun(Base):
|
||||
__tablename__ = "task_runs"
|
||||
__table_args__ = (
|
||||
Index("ix_core_task_runs_task_id", "task_id", unique=True),
|
||||
Index("ix_core_task_runs_tenant_updated", "tenant_id", "updated_at"),
|
||||
Index("ix_core_task_runs_tenant_status_updated", "tenant_id", "status", "updated_at"),
|
||||
Index("ix_core_task_runs_tenant_group_updated", "tenant_id", "task_group", "updated_at"),
|
||||
Index("ix_core_task_runs_tenant_company_updated", "tenant_id", "company_id", "updated_at"),
|
||||
{"schema": "core"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("core.tenants.id"), nullable=False, index=True
|
||||
)
|
||||
company_id: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True, index=True
|
||||
)
|
||||
requested_by_user: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
task_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
task_group: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
task_origin: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
String(20), nullable=False, default=TaskStatus.PENDING.value
|
||||
)
|
||||
celery_state_raw: Mapped[str] = mapped_column(String(30), nullable=False, default="PENDING")
|
||||
progress_current: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
progress_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
progress_percent: Mapped[float | None] = mapped_column(nullable=True)
|
||||
progress_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
retries: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
exception_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
exception_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
traceback_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
result_summary: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
meta_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
149
backend/api/v1/modules/core/tasks_tracking/routes.py
Normal file
149
backend/api/v1/modules/core/tasks_tracking/routes.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, resolve_tenant_id_required
|
||||
|
||||
from .models import TaskRun, TaskStatus
|
||||
from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest
|
||||
from .service import TaskTrackerService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _map_row(row: TaskRun) -> TaskRunListItem:
|
||||
pct = row.progress_percent
|
||||
if row.status == TaskStatus.COMPLETED.value and (pct is None or pct == 0):
|
||||
pct = 100.0
|
||||
return TaskRunListItem(
|
||||
task_id=row.task_id,
|
||||
task_name=row.task_name,
|
||||
task_group=row.task_group,
|
||||
task_origin=row.task_origin,
|
||||
status=row.status,
|
||||
celery_state_raw=row.celery_state_raw,
|
||||
progress={
|
||||
"current": row.progress_current,
|
||||
"total": row.progress_total,
|
||||
"percent": pct,
|
||||
"message": row.progress_message,
|
||||
},
|
||||
retries=row.retries,
|
||||
error=(
|
||||
{"type": row.exception_type, "message": row.exception_message}
|
||||
if row.exception_type or row.exception_message
|
||||
else None
|
||||
),
|
||||
tenant_id=row.tenant_id,
|
||||
requested_by_user=row.requested_by_user,
|
||||
started_at=row.started_at,
|
||||
finished_at=row.finished_at,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=TaskRunsResponse)
|
||||
def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
status: list[str] | None = Query(None),
|
||||
task_group: list[str] | None = Query(None),
|
||||
task_name: list[str] | None = Query(None),
|
||||
company_id: int | None = Query(None),
|
||||
search: str | None = Query(None),
|
||||
sync_active: bool = Query(False),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = resolve_tenant_id_required(current_user)
|
||||
|
||||
tracker = TaskTrackerService(db)
|
||||
if sync_active:
|
||||
tracker.sync_active_tasks(tenant_id=tenant_id)
|
||||
|
||||
rows, total = tracker.list_tasks(
|
||||
tenant_id=tenant_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
task_group=task_group,
|
||||
task_name=task_name,
|
||||
company_id=company_id,
|
||||
search=search,
|
||||
)
|
||||
items = [_map_row(row) for row in rows]
|
||||
return TaskRunsResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
has_next=(page * page_size) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=TaskRunDetail)
|
||||
def get_task_detail(
|
||||
task_id: str,
|
||||
sync: bool = Query(True),
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = resolve_tenant_id_required(current_user)
|
||||
|
||||
query = db.query(TaskRun).filter(TaskRun.task_id == task_id)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(TaskRun.tenant_id == tenant_id)
|
||||
row = query.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
tracker = TaskTrackerService(db)
|
||||
if sync:
|
||||
row = tracker.sync_task(row)
|
||||
|
||||
item = _map_row(row)
|
||||
return TaskRunDetail(
|
||||
**item.model_dump(),
|
||||
traceback_excerpt=row.traceback_excerpt,
|
||||
result_summary=row.result_summary,
|
||||
meta_payload=row.meta_payload,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/sync")
|
||||
def sync_tasks(
|
||||
body: TaskSyncRequest,
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = resolve_tenant_id_required(current_user)
|
||||
tracker = TaskTrackerService(db)
|
||||
updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids)
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.get("/tasks/catalogs", response_model=TaskCatalogsResponse)
|
||||
def get_catalogs(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = resolve_tenant_id_required(current_user)
|
||||
|
||||
groups_q = db.query(TaskRun.task_group)
|
||||
names_q = db.query(TaskRun.task_name)
|
||||
statuses_q = db.query(TaskRun.status)
|
||||
if tenant_id is not None:
|
||||
groups_q = groups_q.filter(TaskRun.tenant_id == tenant_id)
|
||||
names_q = names_q.filter(TaskRun.tenant_id == tenant_id)
|
||||
statuses_q = statuses_q.filter(TaskRun.tenant_id == tenant_id)
|
||||
groups = groups_q.distinct().order_by(TaskRun.task_group).all()
|
||||
names = names_q.distinct().order_by(TaskRun.task_name).all()
|
||||
statuses = statuses_q.distinct().order_by(TaskRun.status).all()
|
||||
return TaskCatalogsResponse(
|
||||
task_groups=[g[0] for g in groups if g[0]],
|
||||
task_names=[n[0] for n in names if n[0]],
|
||||
statuses=[s[0] for s in statuses if s[0]],
|
||||
)
|
||||
58
backend/api/v1/modules/core/tasks_tracking/schemas.py
Normal file
58
backend/api/v1/modules/core/tasks_tracking/schemas.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TaskProgress(BaseModel):
|
||||
current: int | None = None
|
||||
total: int | None = None
|
||||
percent: float | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class TaskError(BaseModel):
|
||||
type: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class TaskRunListItem(BaseModel):
|
||||
task_id: str
|
||||
task_name: str
|
||||
task_group: str
|
||||
task_origin: str | None = None
|
||||
status: str
|
||||
celery_state_raw: str
|
||||
progress: TaskProgress
|
||||
retries: int | None = None
|
||||
error: TaskError | None = None
|
||||
tenant_id: int
|
||||
requested_by_user: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TaskRunDetail(TaskRunListItem):
|
||||
traceback_excerpt: str | None = None
|
||||
result_summary: dict[str, Any] | None = None
|
||||
meta_payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskRunsResponse(BaseModel):
|
||||
items: list[TaskRunListItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_next: bool
|
||||
|
||||
|
||||
class TaskSyncRequest(BaseModel):
|
||||
task_ids: list[str] | None = None
|
||||
|
||||
|
||||
class TaskCatalogsResponse(BaseModel):
|
||||
task_groups: list[str]
|
||||
task_names: list[str]
|
||||
statuses: list[str]
|
||||
246
backend/api/v1/modules/core/tasks_tracking/service.py
Normal file
246
backend/api/v1/modules/core/tasks_tracking/service.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from sqlalchemy import asc, desc, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
from .models import TaskRun, TaskStatus
|
||||
|
||||
# No usar valid_rows/total_rows del resultado final: en SUCCESS distorsiona el % (errores de scan).
|
||||
_CELERY_TERMINAL_RAW = frozenset({"SUCCESS", "FAILURE", "REVOKED", "REJECTED"})
|
||||
|
||||
|
||||
def _progress_from_row_count_dict(d: dict[str, Any]) -> tuple[int, int] | None:
|
||||
total = d.get("total_rows")
|
||||
if not isinstance(total, (int, float)) or total <= 0:
|
||||
return None
|
||||
valid = d.get("valid_rows")
|
||||
if isinstance(valid, (int, float)):
|
||||
return int(valid), int(total)
|
||||
processed = d.get("processed_rows")
|
||||
if isinstance(processed, (int, float)):
|
||||
return int(processed), int(total)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_celery_state(state: str | None) -> TaskStatus:
|
||||
raw = (state or "PENDING").upper()
|
||||
if raw in {"STARTED", "PROGRESS", "PROCESSING", "RETRY"}:
|
||||
return TaskStatus.ACTIVE
|
||||
if raw == "SUCCESS":
|
||||
return TaskStatus.COMPLETED
|
||||
if raw in {"FAILURE", "REVOKED", "REJECTED"}:
|
||||
return TaskStatus.FAILED
|
||||
return TaskStatus.PENDING
|
||||
|
||||
|
||||
def _extract_progress(result: AsyncResult, raw_state_upper: str) -> tuple[int | None, int | None, float | None, str | None]:
|
||||
payload = result.info if isinstance(result.info, dict) else {}
|
||||
current = payload.get("current")
|
||||
total = payload.get("total")
|
||||
message = payload.get("status")
|
||||
|
||||
if current is None and isinstance(result.result, dict):
|
||||
current = result.result.get("current")
|
||||
if total is None and isinstance(result.result, dict):
|
||||
total = result.result.get("total")
|
||||
if message is None and isinstance(result.result, dict):
|
||||
message = result.result.get("status")
|
||||
|
||||
percent = None
|
||||
if isinstance(current, (int, float)) and isinstance(total, (int, float)) and total > 0:
|
||||
percent = min(100.0, max(0.0, (float(current) / float(total)) * 100.0))
|
||||
|
||||
raw = (raw_state_upper or "PENDING").upper()
|
||||
if percent is None and raw not in _CELERY_TERMINAL_RAW:
|
||||
for src in (payload, result.result if isinstance(result.result, dict) else {}):
|
||||
if not isinstance(src, dict):
|
||||
continue
|
||||
pair = _progress_from_row_count_dict(src)
|
||||
if pair is None:
|
||||
continue
|
||||
cur_i, tot_i = pair
|
||||
current, total = cur_i, tot_i
|
||||
percent = min(100.0, max(0.0, (float(cur_i) / float(tot_i)) * 100.0))
|
||||
break
|
||||
|
||||
return (
|
||||
int(current) if isinstance(current, (int, float)) else None,
|
||||
int(total) if isinstance(total, (int, float)) else None,
|
||||
percent,
|
||||
str(message) if message is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _extract_failure(result: AsyncResult) -> tuple[str | None, str | None, str | None]:
|
||||
exception_type = None
|
||||
exception_message = None
|
||||
traceback_excerpt = None
|
||||
|
||||
err = result.result
|
||||
if isinstance(err, Exception):
|
||||
exception_type = type(err).__name__
|
||||
exception_message = str(err)
|
||||
elif isinstance(err, dict):
|
||||
exception_type = err.get("exc_type")
|
||||
exception_message = err.get("exc_message") or err.get("error") or err.get("message")
|
||||
elif err is not None:
|
||||
exception_message = str(err)
|
||||
|
||||
tb = getattr(result, "traceback", None)
|
||||
if isinstance(tb, str):
|
||||
traceback_excerpt = tb[-4000:]
|
||||
|
||||
return exception_type, exception_message, traceback_excerpt
|
||||
|
||||
|
||||
class TaskTrackerService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def register_dispatch(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
tenant_id: int,
|
||||
task_name: str,
|
||||
task_group: str,
|
||||
company_id: int | None = None,
|
||||
requested_by_user: str | None = None,
|
||||
task_origin: str | None = None,
|
||||
meta_payload: dict[str, Any] | None = None,
|
||||
) -> TaskRun:
|
||||
current = self.db.query(TaskRun).filter(TaskRun.task_id == task_id).first()
|
||||
if current:
|
||||
return current
|
||||
|
||||
row = TaskRun(
|
||||
task_id=task_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
requested_by_user=requested_by_user,
|
||||
task_name=task_name,
|
||||
task_group=task_group,
|
||||
task_origin=task_origin,
|
||||
status=TaskStatus.PENDING.value,
|
||||
celery_state_raw="PENDING",
|
||||
progress_current=0,
|
||||
progress_total=100,
|
||||
progress_percent=0.0,
|
||||
progress_message="Queued",
|
||||
retries=0,
|
||||
meta_payload=meta_payload,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
)
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
return row
|
||||
|
||||
def sync_task(self, task_run: TaskRun) -> TaskRun:
|
||||
from core.celery_app import celery_app
|
||||
async_result = celery_app.AsyncResult(task_run.task_id)
|
||||
raw_state = (async_result.state or "PENDING").upper()
|
||||
normalized = normalize_celery_state(raw_state)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
task_run.celery_state_raw = raw_state
|
||||
task_run.status = normalized.value
|
||||
task_run.retries = int(getattr(async_result, "retries", 0) or 0)
|
||||
|
||||
current, total, percent, message = _extract_progress(async_result, raw_state)
|
||||
if current is not None:
|
||||
task_run.progress_current = current
|
||||
if total is not None:
|
||||
task_run.progress_total = total
|
||||
if percent is not None:
|
||||
task_run.progress_percent = percent
|
||||
if message:
|
||||
task_run.progress_message = message
|
||||
|
||||
if normalized == TaskStatus.ACTIVE and task_run.started_at is None:
|
||||
task_run.started_at = now
|
||||
|
||||
if normalized == TaskStatus.COMPLETED:
|
||||
if task_run.started_at is None:
|
||||
task_run.started_at = now
|
||||
task_run.finished_at = now
|
||||
task_run.exception_type = None
|
||||
task_run.exception_message = None
|
||||
task_run.traceback_excerpt = None
|
||||
if isinstance(async_result.result, dict):
|
||||
task_run.result_summary = async_result.result
|
||||
else:
|
||||
task_run.result_summary = {"result": str(async_result.result)}
|
||||
# register_dispatch seeds progress_percent=0; Celery SUCCESS often has no current/total meta
|
||||
if percent is None:
|
||||
task_run.progress_percent = 100.0
|
||||
|
||||
if normalized == TaskStatus.FAILED:
|
||||
if task_run.started_at is None:
|
||||
task_run.started_at = now
|
||||
task_run.finished_at = now
|
||||
etype, emsg, tb = _extract_failure(async_result)
|
||||
task_run.exception_type = etype
|
||||
task_run.exception_message = emsg
|
||||
task_run.traceback_excerpt = tb
|
||||
|
||||
self.db.add(task_run)
|
||||
self.db.commit()
|
||||
self.db.refresh(task_run)
|
||||
return task_run
|
||||
|
||||
def sync_active_tasks(self, tenant_id: int | None, task_ids: list[str] | None = None) -> int:
|
||||
query = self.db.query(TaskRun).filter(
|
||||
TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value])
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(TaskRun.tenant_id == tenant_id)
|
||||
if task_ids:
|
||||
query = query.filter(TaskRun.task_id.in_(task_ids))
|
||||
rows = query.limit(200).all()
|
||||
for row in rows:
|
||||
self.sync_task(row)
|
||||
return len(rows)
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
status: list[str] | None = None,
|
||||
task_group: list[str] | None = None,
|
||||
task_name: list[str] | None = None,
|
||||
company_id: int | None = None,
|
||||
search: str | None = None,
|
||||
order: str = "desc",
|
||||
) -> tuple[list[TaskRun], int]:
|
||||
query = self.db.query(TaskRun)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(TaskRun.tenant_id == tenant_id)
|
||||
if status:
|
||||
query = query.filter(TaskRun.status.in_(status))
|
||||
if task_group:
|
||||
query = query.filter(TaskRun.task_group.in_(task_group))
|
||||
if task_name:
|
||||
query = query.filter(TaskRun.task_name.in_(task_name))
|
||||
if company_id is not None:
|
||||
# Tareas sin company_id (p. ej. reportes solo por tenant) deben seguir visibles
|
||||
query = query.filter(or_(TaskRun.company_id == company_id, TaskRun.company_id.is_(None)))
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(TaskRun.task_id.ilike(term))
|
||||
| (TaskRun.task_name.ilike(term))
|
||||
| (TaskRun.task_origin.ilike(term))
|
||||
| (TaskRun.exception_message.ilike(term))
|
||||
)
|
||||
|
||||
total = query.with_entities(func.count(TaskRun.id)).scalar() or 0
|
||||
order_expr = asc(TaskRun.updated_at) if order == "asc" else desc(TaskRun.updated_at)
|
||||
items = query.order_by(order_expr).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return items, total
|
||||
7
backend/api/v1/modules/core/tenants/__init__.py
Normal file
7
backend/api/v1/modules/core/tenants/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
119
backend/api/v1/modules/core/tenants/dto.py
Normal file
119
backend/api/v1/modules/core/tenants/dto.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
DTOs (Data Transfer Objects) para módulo de tenants
|
||||
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
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
|
||||
|
||||
model_config = ConfigDict(
|
||||
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
|
||||
|
||||
model_config = ConfigDict(
|
||||
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
|
||||
66
backend/api/v1/modules/core/tenants/models.py
Normal file
66
backend/api/v1/modules/core/tenants/models.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Modelos ORM para gestión de tenants
|
||||
"""
|
||||
|
||||
import enum
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from api.v1.common.base_models import TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Boolean, Column
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, relationship
|
||||
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
|
||||
class TenantType(enum.Enum):
|
||||
"""Tipo de tenant según tamaño y necesidades"""
|
||||
|
||||
SHARED = "shared" # BD compartida
|
||||
DEDICATED = "dedicated" # BD dedicada
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
"""
|
||||
Modelo de Tenant - Cliente/Organización en el sistema
|
||||
Cada tenant puede tener BD compartida o dedicada
|
||||
"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||
|
||||
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,
|
||||
server_default="SHARED",
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Keycloak realm asociado
|
||||
keycloak_realm = Column(String(255), 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, server_default="true", nullable=False)
|
||||
|
||||
# Relación con UserTenant
|
||||
user_relations: Mapped[List["UserTenant"]] = relationship(
|
||||
"UserTenant", back_populates="tenant"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant(id={self.id}, name={self.name}, type={self.type.value})>"
|
||||
131
backend/api/v1/modules/core/tenants/routes.py
Normal file
131
backend/api/v1/modules/core/tenants/routes.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Endpoints API para gestión de tenants
|
||||
"""
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, has_role
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
TenantCreateDTO,
|
||||
TenantListResponseDTO,
|
||||
TenantResponseDTO,
|
||||
TenantUpdateDTO,
|
||||
)
|
||||
from .service import TenantService
|
||||
|
||||
router = APIRouter(prefix="/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)
|
||||
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
|
||||
207
backend/api/v1/modules/core/tenants/service.py
Normal file
207
backend/api/v1/modules/core/tenants/service.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Capa de servicio para lógica de negocio de tenants
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import TenantCreateDTO, TenantResponseDTO, TenantUpdateDTO
|
||||
from .models import Tenant, TenantType
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
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()
|
||||
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)
|
||||
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")
|
||||
67
backend/api/v1/modules/core/user_tenant/dto.py
Normal file
67
backend/api/v1/modules/core/user_tenant/dto.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
DTOs para gestión de relaciones usuario-tenant
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AddUserToTenantRequestDTO(BaseModel):
|
||||
"""Request para agregar un usuario a un tenant"""
|
||||
|
||||
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
|
||||
tenant_id: int = Field(..., description="ID del tenant")
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
|
||||
|
||||
|
||||
class RemoveUserFromTenantRequestDTO(BaseModel):
|
||||
"""Request para eliminar un usuario de un tenant"""
|
||||
|
||||
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
|
||||
tenant_id: int = Field(..., description="ID del tenant")
|
||||
soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina")
|
||||
|
||||
|
||||
class UpdateUserRoleRequestDTO(BaseModel):
|
||||
"""Request para actualizar el rol de un usuario en un tenant"""
|
||||
|
||||
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
|
||||
tenant_id: int = Field(..., description="ID del tenant")
|
||||
role: str = Field(..., description="Nuevo rol del usuario")
|
||||
|
||||
|
||||
class UserTenantResponseDTO(BaseModel):
|
||||
"""Response con información de relación usuario-tenant"""
|
||||
|
||||
id: int
|
||||
keycloak_user_id: str
|
||||
tenant_id: int
|
||||
is_active: bool
|
||||
role: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TenantBasicInfoDTO(BaseModel):
|
||||
"""Información básica de un tenant"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
is_active: bool
|
||||
keycloak_realm: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserTenantsResponseDTO(BaseModel):
|
||||
"""Response con los tenants de un usuario"""
|
||||
|
||||
keycloak_user_id: str
|
||||
tenants: list[TenantBasicInfoDTO]
|
||||
91
backend/api/v1/modules/core/user_tenant/models.py
Normal file
91
backend/api/v1/modules/core/user_tenant/models.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Modelo de relación entre usuarios (Keycloak) y tenants
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
ForeignKeyConstraint,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
DateTime,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
|
||||
|
||||
class UserTenant(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""
|
||||
Relación muchos-a-muchos entre usuarios de Keycloak y tenants
|
||||
|
||||
Un usuario puede pertenecer a múltiples tenants
|
||||
Un tenant puede tener múltiples usuarios
|
||||
"""
|
||||
|
||||
__tablename__ = "user_tenants"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"keycloak_user_id", "tenant_id", "company_id", name="uq_user_tenant"
|
||||
),
|
||||
{"schema": "core", "extend_existing": True},
|
||||
)
|
||||
|
||||
# Primary Key
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
# ID del usuario en Keycloak (UUID string)
|
||||
keycloak_user_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True
|
||||
)
|
||||
|
||||
# Estado de la relación
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, server_default="true", nullable=False
|
||||
)
|
||||
|
||||
# Información adicional - Rol del usuario en este tenant (opcional)
|
||||
role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Campos de perfil de usuario
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True, comment="URL de la imagen de perfil"
|
||||
)
|
||||
workspace_user_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
|
||||
)
|
||||
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
|
||||
)
|
||||
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Última sincronización de perfil con Workspace",
|
||||
)
|
||||
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
|
||||
# se sincroniza al editar perfil desde Anexo76)
|
||||
first_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True, comment="Nombre (caché local de Keycloak)"
|
||||
)
|
||||
last_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True, comment="Apellido (caché local de Keycloak)"
|
||||
)
|
||||
phone: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, comment="Teléfono del usuario"
|
||||
)
|
||||
bio: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Biografía del usuario"
|
||||
)
|
||||
preferences: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, nullable=True, comment="Preferencias del usuario (tema, idioma, etc.)"
|
||||
)
|
||||
|
||||
# Relación con Tenant
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations")
|
||||
141
backend/api/v1/modules/core/user_tenant/routes.py
Normal file
141
backend/api/v1/modules/core/user_tenant/routes.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Rutas para gestión de relaciones usuario-tenant
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
AddUserToTenantRequestDTO,
|
||||
RemoveUserFromTenantRequestDTO,
|
||||
TenantBasicInfoDTO,
|
||||
UpdateUserRoleRequestDTO,
|
||||
UserTenantResponseDTO,
|
||||
UserTenantsResponseDTO,
|
||||
)
|
||||
from .service import UserTenantService
|
||||
|
||||
router = APIRouter(prefix="/user-tenants")
|
||||
|
||||
|
||||
@router.post("/add", response_model=UserTenantResponseDTO)
|
||||
def add_user_to_tenant(
|
||||
data: AddUserToTenantRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Agrega un usuario a un tenant
|
||||
|
||||
Requiere permisos de administrador
|
||||
"""
|
||||
service = UserTenantService(db)
|
||||
result = service.add_user_to_tenant(
|
||||
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/remove")
|
||||
def remove_user_from_tenant(
|
||||
data: RemoveUserFromTenantRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina un usuario de un tenant
|
||||
|
||||
Requiere permisos de administrador
|
||||
"""
|
||||
service = UserTenantService(db)
|
||||
service.remove_user_from_tenant(
|
||||
keycloak_user_id=data.keycloak_user_id,
|
||||
tenant_id=data.tenant_id,
|
||||
soft_delete=data.soft_delete,
|
||||
)
|
||||
return {"message": "User removed from tenant successfully"}
|
||||
|
||||
|
||||
@router.put("/update-role", response_model=UserTenantResponseDTO)
|
||||
def update_user_role(
|
||||
data: UpdateUserRoleRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza el rol de un usuario en un tenant
|
||||
|
||||
Requiere permisos de administrador
|
||||
"""
|
||||
service = UserTenantService(db)
|
||||
result = service.update_user_role_in_tenant(
|
||||
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO)
|
||||
def get_user_tenants(
|
||||
keycloak_user_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene todos los tenants a los que tiene acceso un usuario
|
||||
|
||||
Los usuarios solo pueden ver sus propios tenants, a menos que sean admin
|
||||
"""
|
||||
# Verificar que el usuario solo pueda ver sus propios tenants (excepto admin)
|
||||
if current_user.get("sub") != keycloak_user_id:
|
||||
# TODO: Verificar si es admin
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You can only view your own tenants"
|
||||
)
|
||||
|
||||
service = UserTenantService(db)
|
||||
tenants = service.get_user_tenants(keycloak_user_id)
|
||||
|
||||
return UserTenantsResponseDTO(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO])
|
||||
def get_tenant_users(
|
||||
tenant_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene todos los usuarios que tienen acceso a un tenant
|
||||
|
||||
Requiere permisos de administrador del tenant
|
||||
"""
|
||||
service = UserTenantService(db)
|
||||
user_tenants = service.get_tenant_users(tenant_id)
|
||||
return user_tenants
|
||||
|
||||
|
||||
@router.get("/check-access/{keycloak_user_id}/{tenant_id}")
|
||||
def check_user_access(
|
||||
keycloak_user_id: str,
|
||||
tenant_id: int,
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Verifica si un usuario tiene acceso a un tenant
|
||||
"""
|
||||
service = UserTenantService(db)
|
||||
has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id)
|
||||
|
||||
return {
|
||||
"keycloak_user_id": keycloak_user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"has_access": has_access,
|
||||
}
|
||||
225
backend/api/v1/modules/core/user_tenant/service.py
Normal file
225
backend/api/v1/modules/core/user_tenant/service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Servicio para gestionar relaciones entre usuarios y tenants
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..tenants.models import Tenant
|
||||
from .models import UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserTenantService:
|
||||
"""Servicio para gestionar acceso de usuarios a tenants"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def add_user_to_tenant(
|
||||
self, keycloak_user_id: str, tenant_id: int, role: Optional[str] = None
|
||||
) -> UserTenant:
|
||||
"""
|
||||
Agrega un usuario a un tenant
|
||||
|
||||
Args:
|
||||
keycloak_user_id: ID del usuario en Keycloak
|
||||
tenant_id: ID del tenant
|
||||
role: Rol opcional del usuario en este tenant
|
||||
|
||||
Returns:
|
||||
UserTenant creado
|
||||
"""
|
||||
# Verificar que el tenant existe
|
||||
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
# Verificar si la relación ya existe
|
||||
existing = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Si existe pero está inactiva, reactivarla
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
existing.role = role
|
||||
self.db.commit()
|
||||
self.db.refresh(existing)
|
||||
return existing
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="User already has access to this tenant"
|
||||
)
|
||||
|
||||
# Crear nueva relación
|
||||
user_tenant = UserTenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=tenant_id,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
self.db.add(user_tenant)
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
return user_tenant
|
||||
|
||||
def remove_user_from_tenant(
|
||||
self, keycloak_user_id: str, tenant_id: int, soft_delete: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Elimina un usuario de un tenant
|
||||
|
||||
Args:
|
||||
keycloak_user_id: ID del usuario en Keycloak
|
||||
tenant_id: ID del tenant
|
||||
soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente
|
||||
|
||||
Returns:
|
||||
True si se eliminó correctamente
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="User-tenant relationship not found"
|
||||
)
|
||||
|
||||
if soft_delete:
|
||||
user_tenant.is_active = False
|
||||
self.db.commit()
|
||||
else:
|
||||
self.db.delete(user_tenant)
|
||||
self.db.commit()
|
||||
|
||||
return True
|
||||
|
||||
def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]:
|
||||
"""
|
||||
Obtiene todos los tenants a los que tiene acceso un usuario
|
||||
|
||||
Args:
|
||||
keycloak_user_id: ID del usuario en Keycloak
|
||||
|
||||
Returns:
|
||||
Lista de tenants
|
||||
"""
|
||||
user_tenants = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
tenant_ids = [ut.tenant_id for ut in user_tenants]
|
||||
|
||||
tenants = (
|
||||
self.db.query(Tenant)
|
||||
.filter(and_(Tenant.id.in_(tenant_ids), Tenant.is_active))
|
||||
.all()
|
||||
)
|
||||
|
||||
return tenants
|
||||
|
||||
def get_tenant_users(self, tenant_id: int) -> List[UserTenant]:
|
||||
"""
|
||||
Obtiene todos los usuarios que tienen acceso a un tenant
|
||||
|
||||
Args:
|
||||
tenant_id: ID del tenant
|
||||
|
||||
Returns:
|
||||
Lista de relaciones UserTenant
|
||||
"""
|
||||
return (
|
||||
self.db.query(UserTenant)
|
||||
.filter(and_(UserTenant.tenant_id == tenant_id, UserTenant.is_active))
|
||||
.all()
|
||||
)
|
||||
|
||||
def user_has_access_to_tenant(self, keycloak_user_id: str, tenant_id: int) -> bool:
|
||||
"""
|
||||
Verifica si un usuario tiene acceso a un tenant
|
||||
|
||||
Args:
|
||||
keycloak_user_id: ID del usuario en Keycloak
|
||||
tenant_id: ID del tenant
|
||||
|
||||
Returns:
|
||||
True si tiene acceso, False en caso contrario
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return user_tenant is not None
|
||||
|
||||
def update_user_role_in_tenant(
|
||||
self, keycloak_user_id: str, tenant_id: int, role: str
|
||||
) -> UserTenant:
|
||||
"""
|
||||
Actualiza el rol de un usuario en un tenant
|
||||
|
||||
Args:
|
||||
keycloak_user_id: ID del usuario en Keycloak
|
||||
tenant_id: ID del tenant
|
||||
role: Nuevo rol
|
||||
|
||||
Returns:
|
||||
UserTenant actualizado
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="User-tenant relationship not found"
|
||||
)
|
||||
|
||||
user_tenant.role = role
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
return user_tenant
|
||||
3
backend/api/v1/modules/core/users/__init__.py
Normal file
3
backend/api/v1/modules/core/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de gestión de usuarios (Keycloak)
|
||||
"""
|
||||
105
backend/api/v1/modules/core/users/dto.py
Normal file
105
backend/api/v1/modules/core/users/dto.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
DTOs para gestión de usuarios de Keycloak
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class CreateUserRequestDTO(BaseModel):
|
||||
"""Request para crear un nuevo usuario en Keycloak"""
|
||||
|
||||
email: EmailStr = Field(..., description="Email del usuario")
|
||||
username: str = Field(
|
||||
..., min_length=3, max_length=50, description="Nombre de usuario"
|
||||
)
|
||||
first_name: str = Field(..., min_length=1, max_length=100, description="Nombre")
|
||||
last_name: str = Field(..., min_length=1, max_length=100, description="Apellido")
|
||||
password: str = Field(..., min_length=8, description="Contraseña temporal")
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
|
||||
enabled: bool = Field(True, description="Si el usuario está habilitado")
|
||||
email_verified: bool = Field(False, description="Si el email está verificado")
|
||||
|
||||
|
||||
class UpdateUserRequestDTO(BaseModel):
|
||||
"""Request para actualizar un usuario en Keycloak"""
|
||||
|
||||
first_name: Optional[str] = Field(None, max_length=100)
|
||||
last_name: Optional[str] = Field(None, max_length=100)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
enabled: Optional[bool] = None
|
||||
email_verified: Optional[bool] = None
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||
|
||||
# Campos de perfil
|
||||
avatar_url: Optional[str] = Field(
|
||||
None, max_length=500, description="URL del avatar"
|
||||
)
|
||||
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
|
||||
bio: Optional[str] = Field(None, description="Biografía")
|
||||
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
|
||||
|
||||
@field_validator("first_name", "last_name", "email")
|
||||
@classmethod
|
||||
def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Valida que si el string está presente, no esté vacío"""
|
||||
if v is not None and v.strip() == "":
|
||||
return None # Convertir strings vacíos a None
|
||||
return v
|
||||
|
||||
|
||||
class UserResponseDTO(BaseModel):
|
||||
"""Response con información de usuario de Keycloak"""
|
||||
|
||||
id: str = Field(..., description="ID de Keycloak del usuario")
|
||||
username: str
|
||||
email: str = Field(default="", description="Email del usuario")
|
||||
first_name: str = Field(default="", description="Nombre del usuario")
|
||||
last_name: str = Field(default="", description="Apellido del usuario")
|
||||
enabled: bool
|
||||
email_verified: bool
|
||||
created_timestamp: Optional[int] = None
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||
|
||||
# Campos de perfil
|
||||
avatar_url: Optional[str] = Field(None, description="URL del avatar")
|
||||
phone: Optional[str] = Field(None, description="Teléfono")
|
||||
bio: Optional[str] = Field(None, description="Biografía")
|
||||
preferences: Optional[dict] = Field(
|
||||
default_factory=dict, description="Preferencias"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserListResponseDTO(BaseModel):
|
||||
"""Response con lista de usuarios"""
|
||||
|
||||
users: List[UserResponseDTO]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class ChangePasswordRequestDTO(BaseModel):
|
||||
"""Request para cambiar contraseña de un usuario"""
|
||||
|
||||
password: str = Field(..., min_length=8, description="Nueva contraseña")
|
||||
temporary: bool = Field(
|
||||
True, description="Si es temporal (usuario debe cambiarla al login)"
|
||||
)
|
||||
|
||||
|
||||
class UserStatsDTO(BaseModel):
|
||||
"""Estadísticas de usuarios del tenant"""
|
||||
|
||||
total_users: int
|
||||
active_users: int
|
||||
inactive_users: int
|
||||
max_users_allowed: int
|
||||
users_available: int
|
||||
usage_percentage: float
|
||||
478
backend/api/v1/modules/core/users/routes.py
Normal file
478
backend/api/v1/modules/core/users/routes.py
Normal file
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
Rutas para gestión de usuarios de Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import public_user_avatar_api_path, user_avatar_key
|
||||
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
|
||||
from core.security import (
|
||||
get_current_user,
|
||||
is_hub_admin,
|
||||
resolve_hub_tenant_id_for_api,
|
||||
validate_access_to_resource,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..user_tenant.models import UserTenant
|
||||
from .dto import (
|
||||
ChangePasswordRequestDTO,
|
||||
CreateUserRequestDTO,
|
||||
UpdateUserRequestDTO,
|
||||
UserListResponseDTO,
|
||||
UserResponseDTO,
|
||||
UserStatsDTO,
|
||||
)
|
||||
from .service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
|
||||
@router.get("/stats", response_model=UserStatsDTO)
|
||||
async def get_user_statistics(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas de usuarios del tenant actual
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
return service.get_user_stats(
|
||||
access_token=token or None,
|
||||
hub_tenant_id=hub_tid,
|
||||
x_tenant_override=request.headers.get("X-Tenant-Override"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=UserListResponseDTO)
|
||||
async def list_users(
|
||||
request: Request,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
page: int = Query(1, ge=1, description="Número de página"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
|
||||
search: Optional[str] = Query(None, description="Término de búsqueda"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Lista todos los usuarios del tenant con paginación
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
result = await service.get_tenant_users(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search=search,
|
||||
access_token=token,
|
||||
hub_tenant_id=hub_tid,
|
||||
x_tenant_override=request.headers.get("X-Tenant-Override"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/avatar/{tenant_id}/{keycloak_user_id}")
|
||||
def get_user_avatar_image(
|
||||
tenant_id: int,
|
||||
keycloak_user_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Sirve la imagen de avatar (público para poder usarla en <img src> sin Bearer).
|
||||
El almacenamiento interno puede ser clave S3 o ruta bajo uploads/.
|
||||
"""
|
||||
ut = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ut or not ut.avatar_url:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
raw = ut.avatar_url
|
||||
if raw.startswith("tenants/"):
|
||||
try:
|
||||
data = get_object_bytes(raw)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
media = mimetypes.guess_type(raw)[0] or "image/jpeg"
|
||||
return Response(content=data, media_type=media)
|
||||
|
||||
rel = raw.lstrip("/")
|
||||
path = Path(rel)
|
||||
if not path.is_file():
|
||||
path = Path.cwd() / rel
|
||||
if not path.is_file():
|
||||
alt = Path("/app") / rel
|
||||
if alt.is_file():
|
||||
path = alt
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Avatar file not found")
|
||||
data = path.read_bytes()
|
||||
media = mimetypes.guess_type(str(path))[0] or "image/jpeg"
|
||||
return Response(content=data, media_type=media)
|
||||
|
||||
|
||||
# === Endpoints de Perfil del Usuario Actual ===
|
||||
|
||||
|
||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||
async def get_my_profile(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Obtiene el perfil completo del usuario actual
|
||||
"""
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||
|
||||
# Obtener user_tenant para crear servicio
|
||||
user_tenant = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
access_token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip() or None
|
||||
)
|
||||
return await service.get_current_user_profile(
|
||||
keycloak_user_id,
|
||||
current_user=current_user,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||
async def update_my_profile(
|
||||
request: Request,
|
||||
data: UpdateUserRequestDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Actualiza el perfil del usuario actual.
|
||||
Campos editables: first_name, last_name, phone.
|
||||
Email, username y otros campos de identidad solo se cambian desde el Hub.
|
||||
"""
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||
|
||||
# Obtener user_tenant
|
||||
user_tenant = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
|
||||
# y no debe mutarse localmente en Anexo76.
|
||||
if current_user.get("sub"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Avatar is managed by Workspace for this user",
|
||||
)
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
access_token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip() or None
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
return await service.update_current_user_profile(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
current_user=current_user,
|
||||
access_token=access_token,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
phone=data.phone,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/avatar")
|
||||
async def upload_my_avatar(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Sube un avatar para el usuario actual.
|
||||
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
|
||||
Retorna URL pública para <img src> (GET /users/avatar/...).
|
||||
"""
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
||||
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||
|
||||
user_tenant = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
|
||||
if ext not in _AVATAR_EXT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
|
||||
)
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > 2 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
|
||||
|
||||
tenant_id = user_tenant.tenant_id
|
||||
|
||||
try:
|
||||
if settings.use_s3_object_storage:
|
||||
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
|
||||
"tenants/"
|
||||
):
|
||||
delete_object_if_exists(str(user_tenant.avatar_url))
|
||||
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
|
||||
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
|
||||
put_object_bytes(key, contents, content_type=ct)
|
||||
user_tenant.avatar_url = key
|
||||
logger.info(
|
||||
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
|
||||
)
|
||||
else:
|
||||
upload_dir = Path("uploads/avatars")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{keycloak_user_id}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
|
||||
|
||||
db.add(user_tenant)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
|
||||
) from e
|
||||
|
||||
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
|
||||
return {"avatar_url": public_url}
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponseDTO)
|
||||
async def get_user_detail(
|
||||
user_id: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene información detallada de un usuario específico
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
return await service.get_user(user_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponseDTO, status_code=201)
|
||||
async def create_new_user(
|
||||
data: CreateUserRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Crea un nuevo usuario a través del Hub y lo asocia al tenant
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
user = await service.create_user(
|
||||
email=data.email,
|
||||
username=data.username,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
password=data.password,
|
||||
role=data.role,
|
||||
enabled=data.enabled,
|
||||
email_verified=data.email_verified,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponseDTO)
|
||||
async def update_user_detail(
|
||||
user_id: str,
|
||||
data: UpdateUserRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Actualiza información de un usuario
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
user = await service.update_user(
|
||||
user_id=user_id,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
email=data.email,
|
||||
enabled=data.enabled,
|
||||
email_verified=data.email_verified,
|
||||
role=data.role,
|
||||
avatar_url=data.avatar_url,
|
||||
phone=data.phone,
|
||||
bio=data.bio,
|
||||
preferences=data.preferences,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/{user_id}/tenant-count")
|
||||
async def get_user_tenant_count(
|
||||
user_id: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Retorna en cuántos tenants está registrado el usuario.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user, required_permissions=["user.view"]
|
||||
)
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
count = service.get_user_tenant_count(user_id)
|
||||
return {"tenant_count": count}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user_route(
|
||||
request: Request,
|
||||
user_id: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
soft_delete: bool = Query(
|
||||
True,
|
||||
description="Si es True, solo desactiva. Si es False, elimina permanentemente",
|
||||
),
|
||||
scope: str = Query(
|
||||
"current",
|
||||
description="'current' para borrar solo del tenant activo, 'all' para borrar de todos los tenants",
|
||||
),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Elimina un usuario del tenant.
|
||||
scope='current' (default): solo del tenant activo.
|
||||
scope='all': de todos los tenants en los que aparece el usuario.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
await service.delete_user(
|
||||
user_id,
|
||||
soft_delete=soft_delete,
|
||||
scope=scope,
|
||||
access_token=token or None,
|
||||
hub_tenant_id=hub_tid,
|
||||
)
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/change-password")
|
||||
async def change_user_password(
|
||||
user_id: str,
|
||||
data: ChangePasswordRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Cambia la contraseña de un usuario a través del Hub
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
await service.change_password(user_id, data.password, data.temporary)
|
||||
return {"message": "Password changed successfully"}
|
||||
808
backend/api/v1/modules/core/users/service.py
Normal file
808
backend/api/v1/modules/core/users/service.py
Normal file
@@ -0,0 +1,808 @@
|
||||
import logging
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
|
||||
from ..licenses.models import License, LicenseStatus
|
||||
from ..user_tenant.models import UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_valid_http_url(url: Optional[str]) -> bool:
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
parsed = urlparse(url.strip())
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
|
||||
if not user_tenant or not user_tenant.avatar_url:
|
||||
return None
|
||||
avatar_out = str(user_tenant.avatar_url)
|
||||
|
||||
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
|
||||
return avatar_out if _is_valid_http_url(avatar_out) else None
|
||||
|
||||
from core.s3_keys import public_user_avatar_api_path
|
||||
|
||||
# Entregamos siempre el endpoint público del backend para assets locales/S3.
|
||||
return public_user_avatar_api_path(
|
||||
user_tenant.tenant_id,
|
||||
user_tenant.keycloak_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_user(
|
||||
user_data: Dict[str, Any],
|
||||
role: Optional[str] = None,
|
||||
user_tenant: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normaliza los datos de usuario al formato esperado por el DTO.
|
||||
Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'.
|
||||
"""
|
||||
name_parts = (user_data.get("name") or "").split(" ", 1)
|
||||
# Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76
|
||||
local_first = getattr(user_tenant, "first_name", None) if user_tenant else None
|
||||
local_last = getattr(user_tenant, "last_name", None) if user_tenant else None
|
||||
|
||||
normalized = {
|
||||
"id": user_data.get("id") or user_data.get("sub"),
|
||||
"username": user_data.get("username") or user_data.get("preferred_username", ""),
|
||||
"email": user_data.get("email", ""),
|
||||
"first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""),
|
||||
"last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""),
|
||||
"enabled": user_data.get("enabled", True),
|
||||
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
|
||||
"created_timestamp": user_data.get("createdTimestamp"),
|
||||
"role": role,
|
||||
}
|
||||
|
||||
# Agregar campos de perfil si user_tenant está disponible
|
||||
if user_tenant:
|
||||
workspace_avatar = (
|
||||
user_tenant.workspace_avatar_url
|
||||
if _is_valid_http_url(user_tenant.workspace_avatar_url)
|
||||
else None
|
||||
)
|
||||
legacy_avatar = _legacy_avatar_public_url(user_tenant)
|
||||
avatar_out = workspace_avatar or legacy_avatar
|
||||
|
||||
normalized.update(
|
||||
{
|
||||
"avatar_url": avatar_out,
|
||||
"workspace_avatar_url": workspace_avatar,
|
||||
"legacy_avatar_url": legacy_avatar,
|
||||
"workspace_user_id": user_tenant.workspace_user_id,
|
||||
"phone": user_tenant.phone,
|
||||
"bio": user_tenant.bio,
|
||||
"preferences": user_tenant.preferences or {},
|
||||
}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Servicio para gestionar usuarios vía Hub"""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None, *, is_hub_admin: bool = False):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
self.company_id = company_id
|
||||
self.is_hub_admin = is_hub_admin
|
||||
|
||||
def _get_license(self) -> License:
|
||||
"""Obtiene la licencia del tenant actual"""
|
||||
license = (
|
||||
self.db.query(License).filter(License.tenant_id == self.tenant_id).first()
|
||||
)
|
||||
|
||||
if not license:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="License not found for this tenant"
|
||||
)
|
||||
|
||||
if license.status != LicenseStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"License is not active. Current status: {license.status.value}",
|
||||
)
|
||||
|
||||
# Verificar si la licencia está vigente
|
||||
now = datetime.now(license.expires_at.tzinfo)
|
||||
if license.expires_at < now:
|
||||
raise HTTPException(status_code=403, detail="License has expired")
|
||||
|
||||
return license
|
||||
|
||||
def _check_user_limit(self) -> None:
|
||||
"""Verifica si se puede crear un nuevo usuario según la licencia"""
|
||||
if self.is_hub_admin:
|
||||
return
|
||||
license = self._get_license()
|
||||
|
||||
# Contar usuarios activos del tenant
|
||||
active_users = (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
# max_users=NULL en BD indica licencia sin cuota (ilimitada).
|
||||
# Comparar con None lanzaría TypeError — salida temprana explícita.
|
||||
if license.max_users is None:
|
||||
return
|
||||
|
||||
if active_users >= license.max_users:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User limit reached. Your license allows {license.max_users} users. "
|
||||
f"Currently active: {active_users}. Please upgrade your license.",
|
||||
)
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
password: str,
|
||||
role: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
email_verified: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Crea un nuevo usuario a través del Hub y lo asocia localmente
|
||||
"""
|
||||
# Verificar límite de usuarios
|
||||
self._check_user_limit()
|
||||
|
||||
try:
|
||||
# Mandar al Hub para creación en Keycloak
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
hub_response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"password": password,
|
||||
"tenant_slug": "default", # TODO: Get real slug if needed
|
||||
}
|
||||
)
|
||||
|
||||
if hub_response.status_code != 201:
|
||||
logger.error(f"Hub registration failed: {hub_response.text}")
|
||||
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
|
||||
|
||||
user_data = hub_response.json()
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
# Obtener company_id — implementa con tu modelo de compañía si company_id es None.
|
||||
if not self.company_id:
|
||||
raise HTTPException(status_code=400, detail="company_id requerido")
|
||||
company_id = self.company_id
|
||||
|
||||
# Crear relación local
|
||||
user_tenant = UserTenant(
|
||||
keycloak_user_id=user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
company_id=company_id,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(user_tenant)
|
||||
self.db.commit()
|
||||
|
||||
return _normalize_user(user_data, role, user_tenant)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user: {str(e)}")
|
||||
self.db.rollback()
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
async def _fetch_hub_tenant_users_with_info(
|
||||
self,
|
||||
access_token: str,
|
||||
hub_tenant_id: int,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants)."""
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info"
|
||||
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
|
||||
if x_tenant_override and str(x_tenant_override).strip():
|
||||
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="No autorizado en el Hub")
|
||||
if response.status_code == 403:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub"
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Hub users-with-info error status=%s body=%s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="No se pudo obtener el catálogo de usuarios desde el Hub",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
if not isinstance(data, list):
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Respuesta inválida del Hub al listar usuarios"
|
||||
)
|
||||
return data
|
||||
|
||||
async def get_tenant_users(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: Optional[str] = None,
|
||||
*,
|
||||
access_token: str,
|
||||
hub_tenant_id: int,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y
|
||||
perfil extendido desde BD local (user_tenants / user_company_roles).
|
||||
"""
|
||||
try:
|
||||
from ..permissions.models import UserCompanyRole
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
if not access_token or not hub_tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token o tenant Hub requerido para listar usuarios",
|
||||
)
|
||||
|
||||
hub_rows = await self._fetch_hub_tenant_users_with_info(
|
||||
access_token, hub_tenant_id, x_tenant_override
|
||||
)
|
||||
|
||||
user_roles_query = (
|
||||
self.db.query(UserCompanyRole)
|
||||
.options(joinedload(UserCompanyRole.company_role))
|
||||
.filter(
|
||||
and_(
|
||||
UserCompanyRole.company_id == self.company_id,
|
||||
UserCompanyRole.tenant_id == self.tenant_id,
|
||||
UserCompanyRole.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
user_roles_map: Dict[str, List[str]] = {}
|
||||
for user_role in user_roles_query.all():
|
||||
uid = user_role.user_id
|
||||
if uid not in user_roles_map:
|
||||
user_roles_map[uid] = []
|
||||
user_roles_map[uid].append(user_role.company_role.name)
|
||||
|
||||
local_by_kc = {
|
||||
ut.keycloak_user_id: ut
|
||||
for ut in self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.company_id == self.company_id,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
}
|
||||
|
||||
needle = (search or "").strip().lower()
|
||||
filtered: List[Dict[str, Any]] = []
|
||||
for u in hub_rows:
|
||||
if not u.get("is_active", True):
|
||||
continue
|
||||
kc = u.get("keycloak_user_id")
|
||||
if not kc:
|
||||
continue
|
||||
# Filtrar usuarios soft-deleted localmente (is_active=False en user_tenants local)
|
||||
local_ut_check = local_by_kc.get(kc)
|
||||
if local_ut_check is not None and not local_ut_check.is_active:
|
||||
continue
|
||||
if needle:
|
||||
blob = " ".join(
|
||||
[
|
||||
str(u.get("email") or ""),
|
||||
str(u.get("username") or ""),
|
||||
str(u.get("first_name") or ""),
|
||||
str(u.get("last_name") or ""),
|
||||
]
|
||||
).lower()
|
||||
ut_loc = local_by_kc.get(kc)
|
||||
if ut_loc:
|
||||
blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower()
|
||||
if needle not in blob:
|
||||
continue
|
||||
filtered.append(u)
|
||||
|
||||
total = len(filtered)
|
||||
offset = (page - 1) * page_size
|
||||
page_rows = filtered[offset : offset + page_size]
|
||||
|
||||
users: List[Dict[str, Any]] = []
|
||||
for u in page_rows:
|
||||
kc = u["keycloak_user_id"]
|
||||
role_names = user_roles_map.get(kc, [])
|
||||
role_str = ", ".join(role_names) if role_names else u.get("role")
|
||||
local_ut = local_by_kc.get(kc)
|
||||
normalized_user = _normalize_user(
|
||||
{
|
||||
"id": kc,
|
||||
"username": u.get("username") or "",
|
||||
"email": u.get("email") or "",
|
||||
"firstName": u.get("first_name") or "",
|
||||
"lastName": u.get("last_name") or "",
|
||||
"enabled": u.get("is_active", True),
|
||||
"emailVerified": False,
|
||||
},
|
||||
role_str,
|
||||
local_ut,
|
||||
)
|
||||
users.append(normalized_user)
|
||||
|
||||
total_pages = max(1, (total + page_size - 1) // page_size) if total else 1
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tenant users: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error getting users: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def get_user(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Obtiene un usuario específico"""
|
||||
from ..permissions.models import UserCompanyRole
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Roles locales
|
||||
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
|
||||
and_(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == self.company_id,
|
||||
UserCompanyRole.tenant_id == self.tenant_id,
|
||||
UserCompanyRole.is_active == True
|
||||
)
|
||||
).all()
|
||||
roles = [ur.company_role.name for ur in user_roles]
|
||||
role_str = ", ".join(roles) if roles else None
|
||||
|
||||
# TODO: Call Hub if more info is needed
|
||||
return _normalize_user({"id": user_id}, role_str, user_tenant)
|
||||
|
||||
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Actualizar campos locales
|
||||
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
|
||||
if field in kwargs and kwargs[field] is not None:
|
||||
setattr(user_tenant, field, kwargs[field])
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
|
||||
|
||||
def get_user_tenant_count(self, user_id: str) -> int:
|
||||
"""Cuenta en cuántos tenants activos está registrado el usuario."""
|
||||
return (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
async def delete_user(
|
||||
self,
|
||||
user_id: str,
|
||||
soft_delete: bool = True,
|
||||
scope: str = "current",
|
||||
access_token: Optional[str] = None,
|
||||
hub_tenant_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Elimina/Desactiva usuario.
|
||||
scope='current': solo del tenant activo.
|
||||
scope='all': de todos los tenants (útil cuando el usuario pertenece a múltiples tenants).
|
||||
"""
|
||||
if scope == "all":
|
||||
rows = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(UserTenant.keycloak_user_id == user_id)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
now = datetime.utcnow()
|
||||
# Collect unique hub_tenant_ids to notify Hub for each tenant
|
||||
hub_tenant_ids = {row.tenant_id for row in rows}
|
||||
for row in rows:
|
||||
row.is_active = False
|
||||
if not soft_delete:
|
||||
row.deleted_at = now
|
||||
self.db.commit()
|
||||
# Propagate to Hub for every tenant the user belonged to
|
||||
if access_token:
|
||||
for tid in hub_tenant_ids:
|
||||
await self._hub_remove_user(user_id, tid, soft_delete, access_token)
|
||||
return
|
||||
|
||||
# scope == "current" (default)
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.company_id == self.company_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
# No local record — user exists in Hub but not synced locally yet.
|
||||
# Create tombstone so user is filtered from future listings.
|
||||
user_tenant = UserTenant(
|
||||
keycloak_user_id=user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
company_id=self.company_id,
|
||||
is_active=False,
|
||||
deleted_at=None if soft_delete else datetime.utcnow(),
|
||||
)
|
||||
self.db.add(user_tenant)
|
||||
self.db.commit()
|
||||
else:
|
||||
user_tenant.is_active = False
|
||||
if not soft_delete:
|
||||
user_tenant.deleted_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
# Propagate to Hub
|
||||
if access_token and hub_tenant_id:
|
||||
await self._hub_remove_user(user_id, hub_tenant_id, soft_delete, access_token)
|
||||
|
||||
async def _hub_remove_user(
|
||||
self,
|
||||
user_id: str,
|
||||
hub_tenant_id: int,
|
||||
soft_delete: bool,
|
||||
access_token: str,
|
||||
) -> None:
|
||||
"""Calls Hub POST /api/v1/hub/user-tenants/remove to sync the deletion."""
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
url = f"{base}/api/v1/hub/user-tenants/remove"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json={
|
||||
"keycloak_user_id": user_id,
|
||||
"tenant_id": hub_tenant_id,
|
||||
"soft_delete": soft_delete,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"Hub remove user-tenant returned %s for user %s tenant %s: %s",
|
||||
resp.status_code, user_id, hub_tenant_id, resp.text[:200],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Error calling Hub remove user-tenant: %s", exc)
|
||||
# Do not raise — local deletion already committed; Hub sync is best-effort.
|
||||
|
||||
async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None:
|
||||
"""Cambia contraseña vía Hub"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/change-password",
|
||||
json={"user_id": user_id, "password": password, "temporary": temporary}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error changing password: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error changing password")
|
||||
|
||||
def _count_active_user_tenants_local(self) -> int:
|
||||
return (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
def get_user_stats(
|
||||
self,
|
||||
access_token: Optional[str] = None,
|
||||
hub_tenant_id: Optional[int] = None,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license)
|
||||
con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token;
|
||||
inactivos y fallback de conteos en BD local.
|
||||
"""
|
||||
max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
|
||||
hub_max_ok = False
|
||||
active_users = 0
|
||||
active_from_hub = False
|
||||
|
||||
if access_token and hub_tenant_id:
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
|
||||
if x_tenant_override and str(x_tenant_override).strip():
|
||||
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
lic_resp = client.get(
|
||||
f"{base}/api/v1/auth/verify-license",
|
||||
headers=headers,
|
||||
)
|
||||
if lic_resp.status_code == 200:
|
||||
lic_body = lic_resp.json()
|
||||
if lic_body.get("valid"):
|
||||
raw_max = lic_body.get("max_users")
|
||||
# max_users=null → hub_admin sin cuota; None indica ilimitado
|
||||
max_users_allowed = int(raw_max) if raw_max is not None else None
|
||||
hub_max_ok = True
|
||||
|
||||
users_resp = client.get(
|
||||
f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info",
|
||||
headers=headers,
|
||||
)
|
||||
if users_resp.status_code == 200:
|
||||
payload = users_resp.json()
|
||||
if isinstance(payload, list):
|
||||
active_users = sum(
|
||||
1 for row in payload if row.get("is_active", True)
|
||||
)
|
||||
active_from_hub = True
|
||||
else:
|
||||
logger.warning(
|
||||
"Hub users-with-info stats: respuesta no lista"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Hub users-with-info stats status=%s",
|
||||
users_resp.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Hub stats (verify-license / users-with-info): %s", e)
|
||||
|
||||
if not hub_max_ok:
|
||||
license = self._get_license()
|
||||
max_users_allowed = license.max_users
|
||||
|
||||
if not active_from_hub:
|
||||
active_users = self._count_active_user_tenants_local()
|
||||
|
||||
inactive_users = (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == False,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
total_users = active_users + inactive_users
|
||||
# Cuando max_users_allowed es None la cuota es ilimitada (hub_admin)
|
||||
users_available = (
|
||||
max(0, max_users_allowed - active_users)
|
||||
if max_users_allowed is not None
|
||||
else None
|
||||
)
|
||||
usage_percentage = (
|
||||
(active_users / max_users_allowed * 100) if max_users_allowed else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"inactive_users": inactive_users,
|
||||
"max_users_allowed": max_users_allowed,
|
||||
"users_available": users_available,
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
async def get_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
current_user: Dict[str, Any] = None,
|
||||
access_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Obtiene el perfil completo del usuario actual"""
|
||||
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
|
||||
user_info = current_user or {"id": keycloak_user_id}
|
||||
|
||||
# Perfil "me": sincronización con cache corto (5 min).
|
||||
if access_token:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
||||
).first()
|
||||
|
||||
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
|
||||
|
||||
async def update_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
current_user: Dict[str, Any] = None,
|
||||
access_token: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Actualiza el perfil del usuario actual.
|
||||
- first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub.
|
||||
- phone: persiste solo localmente.
|
||||
"""
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Campos locales — incluye first_name/last_name como caché
|
||||
for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]:
|
||||
if field in kwargs and kwargs[field] is not None:
|
||||
setattr(user_tenant, field, kwargs[field])
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
|
||||
# Sincronizar nombre/apellido con Keycloak vía Hub (best-effort)
|
||||
first_name = kwargs.get("first_name")
|
||||
last_name = kwargs.get("last_name")
|
||||
if access_token and (first_name or last_name):
|
||||
await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token)
|
||||
|
||||
user_info = current_user or {"id": keycloak_user_id}
|
||||
return _normalize_user(user_info, user_tenant.role, user_tenant)
|
||||
|
||||
async def _hub_get_service_token(self) -> Optional[str]:
|
||||
"""Obtiene un token de la cuenta de servicio Hub para operaciones admin."""
|
||||
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||
return None
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/api/v1/auth/login",
|
||||
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("access_token")
|
||||
logger.warning("Hub service account login failed status=%s", resp.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("Hub service account login error: %s", exc)
|
||||
return None
|
||||
|
||||
async def _hub_update_user_profile(
|
||||
self,
|
||||
user_id: str,
|
||||
first_name: Optional[str],
|
||||
last_name: Optional[str],
|
||||
access_token: str,
|
||||
) -> None:
|
||||
"""Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea).
|
||||
|
||||
Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no
|
||||
tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en
|
||||
HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD.
|
||||
"""
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
url = f"{base}/api/v1/hub/admins/{user_id}"
|
||||
payload: Dict[str, Any] = {}
|
||||
if first_name:
|
||||
payload["first_name"] = first_name
|
||||
if last_name:
|
||||
payload["last_name"] = last_name
|
||||
if not payload:
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.patch(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if resp.status_code == 403:
|
||||
# El usuario no es Hub admin — reintentar con cuenta de servicio
|
||||
logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id)
|
||||
service_token = await self._hub_get_service_token()
|
||||
if service_token:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.patch(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {service_token}"},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status_code not in (200, 204):
|
||||
logger.warning(
|
||||
"Hub profile sync failed status=%s body=%s",
|
||||
resp.status_code,
|
||||
resp.text[:300],
|
||||
)
|
||||
else:
|
||||
logger.info("Hub profile sync OK user_id=%s", user_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc)
|
||||
|
||||
0
backend/api/v1/modules/example/__init__.py
Normal file
0
backend/api/v1/modules/example/__init__.py
Normal file
21
backend/api/v1/modules/example/dto.py
Normal file
21
backend/api/v1/modules/example/dto.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ItemCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ItemUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ItemResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
16
backend/api/v1/modules/example/models.py
Normal file
16
backend/api/v1/modules/example/models.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Item(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Modelo de ejemplo — renombra y ajusta a tu entidad de negocio."""
|
||||
|
||||
__tablename__ = "example_items"
|
||||
__table_args__ = {"schema": "public"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
65
backend/api/v1/modules/example/routes.py
Normal file
65
backend/api/v1/modules/example/routes.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from .dto import ItemCreate, ItemResponse, ItemUpdate
|
||||
from . import service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/items", response_model=list[ItemResponse])
|
||||
def list_items(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_items(db, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=ItemResponse)
|
||||
def get_item(
|
||||
item_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_item(db, item_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/items", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_item(
|
||||
payload: ItemCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_item(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}", response_model=ItemResponse)
|
||||
def update_item(
|
||||
item_id: int,
|
||||
payload: ItemUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_item(db, item_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_item(
|
||||
item_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_item(db, item_id, tenant_id, company_id)
|
||||
48
backend/api/v1/modules/example/service.py
Normal file
48
backend/api/v1/modules/example/service.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import ItemCreate, ItemUpdate
|
||||
from .models import Item
|
||||
|
||||
|
||||
def get_items(db: Session, tenant_id: int, company_id: int) -> list[Item]:
|
||||
return (
|
||||
db.query(Item)
|
||||
.filter(Item.tenant_id == tenant_id, Item.company_id == company_id, Item.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> Item:
|
||||
item = (
|
||||
db.query(Item)
|
||||
.filter(Item.id == item_id, Item.tenant_id == tenant_id, Item.company_id == company_id, Item.deleted_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item no encontrado")
|
||||
return item
|
||||
|
||||
|
||||
def create_item(db: Session, payload: ItemCreate, tenant_id: int, company_id: int) -> Item:
|
||||
item = Item(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def update_item(db: Session, item_id: int, payload: ItemUpdate, tenant_id: int, company_id: int) -> Item:
|
||||
item = get_item(db, item_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(item, field, value)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
|
||||
item = get_item(db, item_id, tenant_id, company_id)
|
||||
from datetime import datetime, timezone
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
20
backend/api/v1/router.py
Normal file
20
backend/api/v1/router.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Router principal de API v1
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .modules.core.router import router as core_router
|
||||
from .modules.example.routes import router as example_router
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(core_router)
|
||||
router.include_router(example_router, prefix="/example", tags=["example"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status():
|
||||
"""Health check de la API"""
|
||||
return {"status": "ok", "version": "1.0.0", "api": "v1"}
|
||||
Reference in New Issue
Block a user