feat: plantilla base workspace SaaS
This commit is contained in:
39
backend/.env.example
Normal file
39
backend/.env.example
Normal file
@@ -0,0 +1,39 @@
|
||||
# Application
|
||||
APP_NAME=Mi Aplicación
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=True
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Auth local para desarrollo (sin Keycloak/Hub)
|
||||
# Cambia a True para entrar sin workspace. NUNCA en producción.
|
||||
DEV_LOCAL_AUTH=False
|
||||
DEV_LOCAL_AUTH_EMAIL=dev@local.test
|
||||
DEV_LOCAL_AUTH_NAME=Dev User
|
||||
DEV_LOCAL_AUTH_TENANT_ID=1
|
||||
DEV_LOCAL_AUTH_COMPANY_ID=1
|
||||
|
||||
# Database - Core
|
||||
CORE_DB_HOST=localhost
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=anexo76_core
|
||||
CORE_DB_USER=postgres
|
||||
CORE_DB_PASSWORD=postgres
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||
HUB_URL=https://hub.aduanasoft.com
|
||||
|
||||
# Factura COVE / VUCEM / DODA / API Ventanilla Única
|
||||
# Llave y IV AES-256-CBC para cifrar la clave FIEL.
|
||||
COVE_FIEL_HASH_KEY=
|
||||
COVE_FIEL_HASH_IV=
|
||||
# URL base del API de Ventanilla Única (COVE, Expediente y DODA comparten esta variable).
|
||||
COVE_API_URL=https://api.vu.aduanasoft.com
|
||||
# Verificación SSL para el API de VU (False en redes internas / dev, True en producción).
|
||||
COVE_API_VERIFY_SSL=False
|
||||
|
||||
# Sincronización (Hub & Spoke)
|
||||
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
CENTRAL_SERVER_URL=
|
||||
62
backend/Dockerfile
Normal file
62
backend/Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
postgresql-client \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Instalar dependencias para wkhtmltopdf y reportes PDF
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
xvfb \
|
||||
fontconfig \
|
||||
fonts-dejavu-core \
|
||||
libfontconfig1 \
|
||||
libxrender1 \
|
||||
libxtst6 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
ca-certificates \
|
||||
libjpeg62-turbo \
|
||||
libpng16-16 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Instalar wkhtmltopdf binario oficial con soporte para footers/headers
|
||||
# TARGETARCH permite amd64 y arm64 (Apple Silicon)
|
||||
ARG TARGETARCH
|
||||
RUN curl -k -L -o /tmp/wkhtmltox.deb "https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_${TARGETARCH}.deb" \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y /tmp/wkhtmltox.deb \
|
||||
&& rm /tmp/wkhtmltox.deb \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& wkhtmltopdf --version
|
||||
|
||||
|
||||
# Copiar requirements
|
||||
COPY requirements.txt .
|
||||
|
||||
# Instalar dependencias Python
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Entrypoint (espera Postgres/Keycloak; no montar desde el host)
|
||||
COPY docker-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Copiar código
|
||||
COPY . .
|
||||
|
||||
# Jenkins / CI pasan --build-arg APP_VERSION=…; debe quedar en ENV para runtime (API /version, OpenAPI, etc.)
|
||||
ARG APP_VERSION=dev-local
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
# Exponer puerto
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Comando por defecto
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
148
backend/alembic.ini
Normal file
148
backend/alembic.ini
Normal file
@@ -0,0 +1,148 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
|
||||
sqlalchemy.url = postgresql://${CORE_DB_USER}:${CORE_DB_PASSWORD}@${CORE_DB_HOST}:${CORE_DB_PORT}/${CORE_DB_NAME}
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
363
backend/alembic/env.py
Normal file
363
backend/alembic/env.py
Normal file
@@ -0,0 +1,363 @@
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from alembic import context
|
||||
from alembic.operations import ops
|
||||
from core.config import settings
|
||||
from core.database import Base
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
|
||||
def _strip_env_url(raw: str) -> str:
|
||||
"""Quita espacios/comillas típicos de secretos CI (.env, Gitea)."""
|
||||
url = raw.strip().strip('"').strip("'")
|
||||
return url
|
||||
|
||||
|
||||
def _normalize_alembic_sqlalchemy_url(url: str) -> str:
|
||||
"""Alembic usa el driver síncrono psycopg2; normaliza DSN típicos de app/tests."""
|
||||
url = _strip_env_url(url)
|
||||
if url.startswith("postgresql+asyncpg://"):
|
||||
return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1)
|
||||
if url.startswith("postgresql+psycopg2://"):
|
||||
return url
|
||||
if url.startswith("postgresql://"):
|
||||
return url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||
if url.startswith("postgres://"):
|
||||
return url.replace("postgres://", "postgresql+psycopg2://", 1)
|
||||
return url
|
||||
|
||||
|
||||
def _validate_sqlalchemy_url(url: str, env_key: str) -> None:
|
||||
"""Misma validación que create_engine; evita urlparse (falla con esquemas tipo postgresql+psycopg2)."""
|
||||
try:
|
||||
make_url(url)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"{env_key} no es una URL de SQLAlchemy válida. "
|
||||
"Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd"
|
||||
) from e
|
||||
|
||||
|
||||
def _reject_documentation_placeholder_host(url: str, source: str) -> None:
|
||||
"""
|
||||
Evita el error críptico de DNS: muchos ejemplos usan @host:5432 como texto literal.
|
||||
"""
|
||||
try:
|
||||
parsed = make_url(url)
|
||||
except Exception:
|
||||
return
|
||||
h = (parsed.host or "").strip().lower()
|
||||
if h == "host":
|
||||
raise RuntimeError(
|
||||
f"{source}: el hostname \"host\" es un placeholder de documentación, no un servidor real. "
|
||||
"Usa el host alcanzable desde el runner (IP, nombre DNS, servicio en docker-compose, "
|
||||
"o host.docker.internal si act corre en contenedor y Postgres en tu máquina)."
|
||||
)
|
||||
|
||||
|
||||
def get_database_url():
|
||||
"""Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini."""
|
||||
# CI / pytest: misma URL que los tests (secret TEST_DATABASE_URL) o DATABASE_URL explícita.
|
||||
# CRÍTICO: debe ser tupla con coma final si un solo elemento: ("X",) — si no, ("X") es str y el for
|
||||
# itera caracteres; env_key "_" + os.environ["_"] (común en shells) rompe con URL inválida.
|
||||
for env_key in ("TEST_DATABASE_URL", "DATABASE_URL"):
|
||||
raw = os.environ.get(env_key)
|
||||
if raw and raw.strip():
|
||||
normalized = _normalize_alembic_sqlalchemy_url(raw)
|
||||
_validate_sqlalchemy_url(normalized, env_key)
|
||||
_reject_documentation_placeholder_host(normalized, env_key)
|
||||
return normalized
|
||||
|
||||
# Construcción desde settings (CORE_DB_* en .env / entorno)
|
||||
host = settings.CORE_DB_HOST
|
||||
db = settings.CORE_DB_NAME
|
||||
user = settings.CORE_DB_USER
|
||||
password = settings.CORE_DB_PASSWORD
|
||||
port = settings.CORE_DB_PORT
|
||||
|
||||
if host and db and user and password:
|
||||
try:
|
||||
encoded_user = quote_plus(user)
|
||||
encoded_password = quote_plus(password)
|
||||
encoded_db = quote_plus(db)
|
||||
built = f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}"
|
||||
_reject_documentation_placeholder_host(built, "CORE_DB_HOST")
|
||||
return built
|
||||
except Exception as e:
|
||||
logger.error(f"Error al construir URL: {e}")
|
||||
|
||||
# Fallback al archivo de configuración
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
if not url or "${" in url or "%(" in url:
|
||||
raise RuntimeError(
|
||||
"No se ha configurado la cadena de conexión a PostgreSQL. "
|
||||
"Define TEST_DATABASE_URL o DATABASE_URL, o variables CORE_DB_*; "
|
||||
"sqlalchemy.url en alembic.ini con placeholders ${...} no está soportado."
|
||||
)
|
||||
|
||||
_reject_documentation_placeholder_host(url, "alembic.ini sqlalchemy.url")
|
||||
return url
|
||||
|
||||
|
||||
# Configurar la URL de la base de datos
|
||||
database_url = get_database_url()
|
||||
|
||||
# Debug: mostrar la URL (sin la contraseña)
|
||||
if os.environ.get("ALEMBIC_DEBUG"):
|
||||
# Ocultar la contraseña para el debug en la URL
|
||||
try:
|
||||
before, after = database_url.split("@", 1)
|
||||
if ":" in before:
|
||||
before = before.split(":", 1)[0] + ":***"
|
||||
debug_url = before + "@" + after
|
||||
except Exception:
|
||||
debug_url = "postgresql://***:***@***"
|
||||
logger.error("Error al ocultar la contraseña en la URL para debug.")
|
||||
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Ajusta la ruta para que puedas importar core y módulos
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
|
||||
# Configuración de Alembic
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def include_object(object_, name, type_, reflected, compare_to):
|
||||
"""
|
||||
Keep all objects in autogenerate.
|
||||
FK noise is cleaned in process_revision_directives.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def _fk_drop_signature(op_):
|
||||
if not isinstance(op_, ops.DropConstraintOp):
|
||||
return None
|
||||
if getattr(op_, "constraint_type", None) != "foreignkey":
|
||||
return None
|
||||
return (
|
||||
getattr(op_, "schema", None),
|
||||
getattr(op_, "table_name", None),
|
||||
getattr(op_, "constraint_name", None),
|
||||
)
|
||||
|
||||
|
||||
def _fk_create_signature(op_):
|
||||
if not isinstance(op_, ops.CreateForeignKeyOp):
|
||||
return None
|
||||
local_cols = tuple(getattr(op_, "local_cols", ()) or ())
|
||||
remote_cols = tuple(getattr(op_, "remote_cols", ()) or ())
|
||||
return (
|
||||
getattr(op_, "source_schema", None),
|
||||
getattr(op_, "source_table", None),
|
||||
getattr(op_, "referent_schema", None),
|
||||
getattr(op_, "referent_table", None),
|
||||
local_cols,
|
||||
remote_cols,
|
||||
)
|
||||
|
||||
|
||||
def _drop_to_create_match(drop_op, create_op):
|
||||
if not isinstance(drop_op, ops.DropConstraintOp):
|
||||
return False
|
||||
if not isinstance(create_op, ops.CreateForeignKeyOp):
|
||||
return False
|
||||
if getattr(drop_op, "constraint_type", None) != "foreignkey":
|
||||
return False
|
||||
|
||||
def _normalize_schema(value):
|
||||
# PostgreSQL reports default schema inconsistently as None/public.
|
||||
return "public" if value in (None, "") else value
|
||||
|
||||
# Prefer structural comparison using Alembic's reverse op when available.
|
||||
reverse_create = getattr(drop_op, "_reverse", None)
|
||||
if isinstance(reverse_create, ops.CreateForeignKeyOp):
|
||||
return (
|
||||
_normalize_schema(getattr(reverse_create, "source_schema", None))
|
||||
== _normalize_schema(getattr(create_op, "source_schema", None))
|
||||
and getattr(reverse_create, "source_table", None) == getattr(create_op, "source_table", None)
|
||||
and _normalize_schema(getattr(reverse_create, "referent_schema", None))
|
||||
== _normalize_schema(getattr(create_op, "referent_schema", None))
|
||||
and getattr(reverse_create, "referent_table", None) == getattr(create_op, "referent_table", None)
|
||||
and tuple(getattr(reverse_create, "local_cols", ()) or ())
|
||||
== tuple(getattr(create_op, "local_cols", ()) or ())
|
||||
and tuple(getattr(reverse_create, "remote_cols", ()) or ())
|
||||
== tuple(getattr(create_op, "remote_cols", ()) or ())
|
||||
)
|
||||
|
||||
# Fallback for older op payloads: compare source table/schema and name.
|
||||
return (
|
||||
_normalize_schema(getattr(drop_op, "schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None))
|
||||
and getattr(drop_op, "table_name", None) == getattr(create_op, "source_table", None)
|
||||
and getattr(drop_op, "constraint_name", None) == getattr(create_op, "constraint_name", None)
|
||||
)
|
||||
|
||||
|
||||
def _prune_fk_churn(container):
|
||||
if not hasattr(container, "ops"):
|
||||
return
|
||||
|
||||
# First recurse into nested containers.
|
||||
for op_ in list(container.ops):
|
||||
_prune_fk_churn(op_)
|
||||
|
||||
table_ops = container.ops
|
||||
kept_ops = []
|
||||
consumed_indexes = set()
|
||||
|
||||
for i, op_i in enumerate(table_ops):
|
||||
if i in consumed_indexes:
|
||||
continue
|
||||
|
||||
if isinstance(op_i, ops.DropConstraintOp) and getattr(op_i, "constraint_type", None) == "foreignkey":
|
||||
matched_j = None
|
||||
for j in range(i + 1, len(table_ops)):
|
||||
if j in consumed_indexes:
|
||||
continue
|
||||
op_j = table_ops[j]
|
||||
if _drop_to_create_match(op_i, op_j):
|
||||
matched_j = j
|
||||
break
|
||||
if matched_j is not None:
|
||||
# Drop + recreate same FK detected; remove both.
|
||||
consumed_indexes.add(i)
|
||||
consumed_indexes.add(matched_j)
|
||||
continue
|
||||
|
||||
kept_ops.append(op_i)
|
||||
|
||||
container.ops = kept_ops
|
||||
|
||||
|
||||
def process_revision_directives(context_, revision, directives):
|
||||
"""
|
||||
Remove autogenerate noise where Alembic emits drop/create for equivalent FKs.
|
||||
Real FK changes are preserved.
|
||||
"""
|
||||
if not directives:
|
||||
return
|
||||
script = directives[0]
|
||||
_prune_fk_churn(script.upgrade_ops)
|
||||
_prune_fk_churn(script.downgrade_ops)
|
||||
|
||||
|
||||
def import_models_from_dir(dir_path: str):
|
||||
"""Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/"""
|
||||
import sys
|
||||
|
||||
def _load_module(module_path: str):
|
||||
rel_path = os.path.relpath(module_path, BASE_DIR)
|
||||
module_name = rel_path.replace(os.sep, ".").replace(".py", "")
|
||||
# Skip if already loaded to avoid duplicate SQLAlchemy table registrations
|
||||
if module_name in sys.modules:
|
||||
return
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# Register in sys.modules before exec so transitive imports resolve correctly
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
for root, dirs, files in os.walk(dir_path):
|
||||
# Importar archivos models.py directos
|
||||
if "models.py" in files:
|
||||
try:
|
||||
_load_module(os.path.join(root, "models.py"))
|
||||
except Exception as e:
|
||||
logger.warning(f"No se pudo importar {os.path.join(root, 'models.py')}: {e}")
|
||||
|
||||
# Importar todos los archivos .py en directorios llamados "models"
|
||||
if os.path.basename(root) == "models":
|
||||
for file in files:
|
||||
if file.endswith(".py") and not file.startswith("__"):
|
||||
try:
|
||||
_load_module(os.path.join(root, file))
|
||||
except Exception as e:
|
||||
logger.warning(f"No se pudo importar {os.path.join(root, file)}: {e}")
|
||||
|
||||
|
||||
# Importar todos los models dentro de api/v1/modules y api/v1/modules/uploads
|
||||
modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules")
|
||||
import_models_from_dir(modules_dir)
|
||||
|
||||
# Tablas declaradas fuera de models.py / carpeta models/ (autogenerate)
|
||||
# Agrega aquí imports de models que no estén en archivos models.py estándar.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
include_schemas=True,
|
||||
include_object=include_object,
|
||||
process_revision_directives=process_revision_directives,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
include_schemas=True,
|
||||
include_object=include_object,
|
||||
process_revision_directives=process_revision_directives,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
24
backend/alembic/versions/8c9bad3da37f_seed_initial_data.py
Normal file
24
backend/alembic/versions/8c9bad3da37f_seed_initial_data.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""seed_initial_data
|
||||
|
||||
Revision ID: 8c9bad3da37f
|
||||
Revises: 9db46c604463
|
||||
Create Date: 2026-05-01 22:01:50.174319
|
||||
|
||||
Nota: la migración original sembraba catálogos de referencia de Anexo 76.
|
||||
En la plantilla este paso es un no-op — agrega tus seeds aquí si los necesitas.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "8c9bad3da37f"
|
||||
down_revision: Union[str, None] = "9db46c604463"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
5091
backend/alembic/versions/9db46c604463_initial_schema.py
Normal file
5091
backend/alembic/versions/9db46c604463_initial_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
"""add first_name and last_name to user_tenants
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 8c9bad3da37f
|
||||
Create Date: 2026-05-06 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: Union[str, None] = "8c9bad3da37f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column("first_name", sa.String(100), nullable=True,
|
||||
comment="Nombre (caché local de Keycloak)"),
|
||||
schema="core",
|
||||
)
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column("last_name", sa.String(100), nullable=True,
|
||||
comment="Apellido (caché local de Keycloak)"),
|
||||
schema="core",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user_tenants", "last_name", schema="core")
|
||||
op.drop_column("user_tenants", "first_name", schema="core")
|
||||
78
backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py
Normal file
78
backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""add invite_tokens table
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-05-06 12:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"invite_tokens",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("tenant_slug", sa.String(100), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("role", sa.String(50), nullable=False, server_default="user"),
|
||||
sa.Column("created_by", sa.String(255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("product_ids", sa.JSON(), nullable=True),
|
||||
sa.Column("company_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
schema="core",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_invite_tokens_token_hash",
|
||||
"invite_tokens",
|
||||
["token_hash"],
|
||||
unique=True,
|
||||
schema="core",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_invite_tokens_tenant_slug",
|
||||
"invite_tokens",
|
||||
["tenant_slug"],
|
||||
schema="core",
|
||||
)
|
||||
op.add_column(
|
||||
'invite_tokens',
|
||||
sa.Column('hub_invite_token', sa.String(length=255), nullable=True),
|
||||
schema='core',
|
||||
)
|
||||
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_core_invite_tokens_tenant_slug",
|
||||
table_name="invite_tokens",
|
||||
schema="core",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_core_invite_tokens_token_hash",
|
||||
table_name="invite_tokens",
|
||||
schema="core",
|
||||
)
|
||||
op.drop_table("invite_tokens", schema="core")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add workspace profile fields to user_tenants
|
||||
|
||||
Revision ID: c3d4e5f6a7b
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-05-08 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c3d4e5f6a7b"
|
||||
down_revision: Union[str, None] = "b2c3d4e5f6a7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column(
|
||||
"workspace_user_id",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="User ID (sub) proveniente de Workspace",
|
||||
),
|
||||
schema="core",
|
||||
)
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column(
|
||||
"workspace_avatar_url",
|
||||
sa.String(length=500),
|
||||
nullable=True,
|
||||
comment="Avatar URL sincronizado desde Workspace",
|
||||
),
|
||||
schema="core",
|
||||
)
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column(
|
||||
"workspace_profile_synced_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Última sincronización de perfil con Workspace",
|
||||
),
|
||||
schema="core",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user_tenants", "workspace_profile_synced_at", schema="core")
|
||||
op.drop_column("user_tenants", "workspace_avatar_url", schema="core")
|
||||
op.drop_column("user_tenants", "workspace_user_id", schema="core")
|
||||
54
backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py
Normal file
54
backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""add invite codes
|
||||
|
||||
Revision ID: g2h3i4j5k6l7
|
||||
Revises: c3d4e5f6a7b
|
||||
Create Date: 2026-06-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "g2h3i4j5k6l7"
|
||||
down_revision: str = "c3d4e5f6a7b"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"invite_codes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=16), nullable=False),
|
||||
sa.Column("tenant_slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=True),
|
||||
sa.Column("role", sa.String(length=50), nullable=False, server_default="user"),
|
||||
sa.Column("max_uses", sa.Integer(), nullable=True),
|
||||
sa.Column("uses_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="core",
|
||||
)
|
||||
op.create_index("ix_core_invite_codes_id", "invite_codes", ["id"], schema="core")
|
||||
op.create_index(
|
||||
"ix_core_invite_codes_code", "invite_codes", ["code"], unique=True, schema="core"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_invite_codes_tenant_slug", "invite_codes", ["tenant_slug"], schema="core"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_invite_codes_company_id", "invite_codes", ["company_id"], schema="core"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_core_invite_codes_company_id", table_name="invite_codes", schema="core")
|
||||
op.drop_index("ix_core_invite_codes_tenant_slug", table_name="invite_codes", schema="core")
|
||||
op.drop_index("ix_core_invite_codes_code", table_name="invite_codes", schema="core")
|
||||
op.drop_index("ix_core_invite_codes_id", table_name="invite_codes", schema="core")
|
||||
op.drop_table("invite_codes", schema="core")
|
||||
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"}
|
||||
41
backend/core/__init__.py
Normal file
41
backend/core/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Core module - Configuración y utilidades centrales de la aplicación
|
||||
"""
|
||||
|
||||
from .config import settings
|
||||
from .database import (
|
||||
Base,
|
||||
get_async_core_db,
|
||||
get_core_db,
|
||||
get_tenant_db,
|
||||
init_async_db,
|
||||
init_db,
|
||||
scoped_async_core_db,
|
||||
scoped_core_db,
|
||||
set_rls_context,
|
||||
)
|
||||
from .security import (
|
||||
get_current_active_user,
|
||||
get_current_user,
|
||||
get_tenant_from_token,
|
||||
has_role,
|
||||
verify_token,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"settings",
|
||||
"Base",
|
||||
"get_core_db",
|
||||
"get_async_core_db",
|
||||
"get_tenant_db",
|
||||
"scoped_core_db",
|
||||
"scoped_async_core_db",
|
||||
"set_rls_context",
|
||||
"init_db",
|
||||
"init_async_db",
|
||||
"verify_token",
|
||||
"get_current_user",
|
||||
"get_current_active_user",
|
||||
"has_role",
|
||||
"get_tenant_from_token",
|
||||
]
|
||||
126
backend/core/celery_app.py
Normal file
126
backend/core/celery_app.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
import logging
|
||||
from celery import Celery
|
||||
from celery.signals import task_postrun, task_prerun
|
||||
|
||||
from core.database import reset_rls_context_tokens, rls_company_var, rls_tenant_var
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
valkey_url = settings.VALKEY_URL
|
||||
print(f"DEBUG: Celery Broker URL: {valkey_url}")
|
||||
logger.info(
|
||||
"Initializing Celery app app_version=%s environment=%s broker=%s",
|
||||
settings.APP_VERSION,
|
||||
settings.ENVIRONMENT,
|
||||
valkey_url,
|
||||
)
|
||||
|
||||
# Configurar broker y backend explícitamente en el constructor
|
||||
celery_app = Celery(
|
||||
"app_tasks",
|
||||
broker=valkey_url,
|
||||
backend=valkey_url,
|
||||
)
|
||||
celery_app.set_default()
|
||||
|
||||
|
||||
_RLS_TOKENS_ATTR = "_rls_context_tokens"
|
||||
|
||||
|
||||
def _coerce_int(value) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@task_prerun.connect
|
||||
def _set_rls_context_from_task(task_id=None, task=None, args=None, kwargs=None, **_):
|
||||
"""Fija las ContextVars de RLS para la ejecución de la tarea.
|
||||
|
||||
Las rutas propagan ``tenant_id`` / ``company_id`` vía Celery headers en
|
||||
:func:`track_and_dispatch`. Aquí los materializamos en ContextVars para
|
||||
que cualquier sesión que se abra durante la tarea (incluidos los helpers
|
||||
``scoped_core_db`` y llamadas directas a ``CoreSessionLocal()``) aplique
|
||||
``SET LOCAL`` automáticamente.
|
||||
"""
|
||||
headers = {}
|
||||
request = getattr(task, "request", None) if task is not None else None
|
||||
if request is not None:
|
||||
headers = getattr(request, "headers", None) or {}
|
||||
|
||||
tenant_id = _coerce_int(headers.get("rls_tenant_id"))
|
||||
company_id = _coerce_int(headers.get("rls_company_id"))
|
||||
if tenant_id is None:
|
||||
logger.warning(
|
||||
"Celery task_prerun missing rls_tenant_id task=%s task_id=%s headers=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
headers,
|
||||
)
|
||||
logger.info(
|
||||
"Celery task_prerun RLS context task=%s task_id=%s tenant_id=%s company_id=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
tenant_id,
|
||||
company_id,
|
||||
)
|
||||
|
||||
token_t = rls_tenant_var.set(tenant_id)
|
||||
token_c = rls_company_var.set(company_id)
|
||||
setattr(task, _RLS_TOKENS_ATTR, (token_t, token_c))
|
||||
|
||||
|
||||
@task_postrun.connect
|
||||
def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
||||
"""Restaura las ContextVars al terminar la tarea (evita fuga entre tareas
|
||||
cuando un worker reutiliza el mismo hilo)."""
|
||||
tokens = getattr(task, _RLS_TOKENS_ATTR, None) if task is not None else None
|
||||
if tokens is None:
|
||||
return
|
||||
token_t, token_c = tokens
|
||||
logger.info(
|
||||
"Celery task_postrun clearing RLS context task=%s task_id=%s tenant_id=%s company_id=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
rls_tenant_var.get(),
|
||||
rls_company_var.get(),
|
||||
)
|
||||
reset_rls_context_tokens(token_t, token_c)
|
||||
delattr(task, _RLS_TOKENS_ATTR)
|
||||
|
||||
celery_app.conf.update(
|
||||
include=[
|
||||
"api.v1.modules.core.help_center.tasks",
|
||||
# Agrega aquí las tareas de tu proyecto:
|
||||
# "api.v1.modules.example.tasks",
|
||||
]
|
||||
)
|
||||
|
||||
# Configuraciones adicionales
|
||||
celery_app.conf.update(
|
||||
task_track_started=True,
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="America/Mexico_City",
|
||||
enable_utc=True,
|
||||
)
|
||||
|
||||
celery_app.conf.beat_schedule = {
|
||||
"sync-from-hub-every-minute": {
|
||||
"task": "sync_from_hub_task",
|
||||
"schedule": 60.0, # Run every 60 seconds
|
||||
},
|
||||
"cleanup-orphan-layout-imports-hourly": {
|
||||
"task": "cleanup_orphan_layout_imports",
|
||||
"schedule": 3600.0,
|
||||
},
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
celery_app.start()
|
||||
149
backend/core/config.py
Normal file
149
backend/core/config.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Configuración centralizada de la aplicación usando Pydantic Settings
|
||||
"""
|
||||
|
||||
from typing import List, Literal
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuración de la aplicación"""
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "Mi Aplicación"
|
||||
# Sobreescribible con APP_VERSION (Dockerfile/Jenkins: build-arg + ENV) o entorno en runtime
|
||||
APP_VERSION: str = "dev-local"
|
||||
DEBUG: bool = True
|
||||
ENVIRONMENT: str = "development"
|
||||
|
||||
# Auth local para desarrollo (sin Keycloak/Hub)
|
||||
# Nunca activar en producción.
|
||||
DEV_LOCAL_AUTH: bool = False
|
||||
DEV_LOCAL_AUTH_EMAIL: str = "dev@local.test"
|
||||
DEV_LOCAL_AUTH_NAME: str = "Dev User"
|
||||
DEV_LOCAL_AUTH_TENANT_ID: int = 1
|
||||
DEV_LOCAL_AUTH_COMPANY_ID: int = 1
|
||||
|
||||
# Database - Core (Shared)
|
||||
CORE_DB_HOST: str = "postgres"
|
||||
CORE_DB_PORT: int = 5432
|
||||
CORE_DB_NAME: str = "app_core"
|
||||
CORE_DB_USER: str = "postgres"
|
||||
CORE_DB_PASSWORD: str = "postgres"
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "change-this-secret-key-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Valkey / Redis
|
||||
VALKEY_URL: str = "redis://valkey:6379/0"
|
||||
PERMISSION_CACHE_ENABLED: bool = True
|
||||
PERMISSION_CACHE_TTL_SECONDS: int = 300
|
||||
|
||||
# Synchronization
|
||||
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||
SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||
HUB_URL: str = "http://localhost:8001"
|
||||
# Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil)
|
||||
HUB_API_BASE_URL: str = ""
|
||||
HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000
|
||||
# Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak)
|
||||
HUB_ADMIN_EMAIL: str = ""
|
||||
HUB_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
||||
APP_PUBLIC_URL: str = "http://localhost:3000"
|
||||
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v and isinstance(v, str):
|
||||
v = v.strip().strip('"').strip("'")
|
||||
# Evitar que solo espacios en .env se conviertan en "/" (rompe httpx: falta protocolo).
|
||||
if not v:
|
||||
return ""
|
||||
if not v.endswith("/"):
|
||||
v += "/"
|
||||
return v
|
||||
return v
|
||||
|
||||
# External APIs
|
||||
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
|
||||
COVE_API_URL: str = "https://api.vu.aduanasoft.com"
|
||||
COVE_API_VERIFY_SSL: bool = False
|
||||
COVE_FIEL_HASH_KEY: str = ""
|
||||
COVE_FIEL_HASH_IV: str = ""
|
||||
SITAR_API_USER: str = ""
|
||||
SITAR_API_PASSWORD: str = ""
|
||||
# SMTP Email Configuration
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_NAME: str = "Mi Aplicación"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
# CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis
|
||||
CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio"
|
||||
S3_ENDPOINT_URL: str = "http://minio:9000"
|
||||
S3_ACCESS_KEY: str = ""
|
||||
S3_SECRET_KEY: str = ""
|
||||
S3_BUCKET: str = "app"
|
||||
S3_REGION: str = "us-east-1"
|
||||
S3_USE_SSL: bool = False
|
||||
# Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis)
|
||||
S3_FILE_STORAGE: bool = True
|
||||
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=[".env", "../.env"],
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
@property
|
||||
def core_database_url(self) -> str:
|
||||
"""URL de conexión a la base de datos core"""
|
||||
return f"postgresql://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
|
||||
|
||||
@property
|
||||
def async_core_database_url(self) -> str:
|
||||
"""URL de conexión asíncrona a la base de datos core"""
|
||||
return f"postgresql+asyncpg://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> List[str]:
|
||||
"""Lista de orígenes CORS permitidos"""
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
@property
|
||||
def use_s3_object_storage(self) -> bool:
|
||||
"""
|
||||
Usar MinIO para logos, certificados y Help (mismo bucket que CSV).
|
||||
True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo.
|
||||
"""
|
||||
return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE
|
||||
|
||||
@property
|
||||
def hub_api_base_url(self) -> str:
|
||||
"""
|
||||
Base URL para endpoints /v1 del Workspace/Hub.
|
||||
Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api.
|
||||
"""
|
||||
custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/")
|
||||
if custom:
|
||||
return custom
|
||||
return f"{self.HUB_URL.rstrip('/')}/api"
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = Settings()
|
||||
10
backend/core/context.py
Normal file
10
backend/core/context.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None)
|
||||
|
||||
def get_user_context() -> Optional[Dict[str, Any]]:
|
||||
return _user_context.get()
|
||||
|
||||
def set_user_context(user: Dict[str, Any]) -> None:
|
||||
_user_context.set(user)
|
||||
278
backend/core/database.py
Normal file
278
backend/core/database.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Configuración de base de datos con soporte multi-tenant
|
||||
- Base de datos compartida (core_db) para tenants pequeños/medianos
|
||||
- Bases de datos dedicadas para clientes enterprise
|
||||
|
||||
Row-Level Security (RLS):
|
||||
Para respetar el aislamiento por tenant/company definido en PostgreSQL,
|
||||
cada sesión fija las GUCs ``app.tenant_id`` y ``app.company_id`` vía
|
||||
``SET LOCAL`` al inicio de cada transacción. El listener
|
||||
``after_begin`` aplica el contexto guardado en ``Session.info``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import AsyncGenerator, Dict, Generator, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
core_engine = create_engine(
|
||||
settings.core_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
CoreSessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=core_engine)
|
||||
|
||||
async_core_engine = create_async_engine(
|
||||
settings.async_core_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
AsyncCoreSessionLocal = async_sessionmaker(
|
||||
async_core_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
_tenant_engines: Dict[str, any] = {}
|
||||
|
||||
|
||||
RLS_TENANT_KEY = "rls_tenant_id"
|
||||
RLS_COMPANY_KEY = "rls_company_id"
|
||||
|
||||
rls_tenant_var: ContextVar[Optional[int]] = ContextVar("rls_tenant_id", default=None)
|
||||
rls_company_var: ContextVar[Optional[int]] = ContextVar("rls_company_id", default=None)
|
||||
|
||||
|
||||
def _apply_rls_context(connection, tenant_id: Optional[int], company_id: Optional[int]) -> None:
|
||||
"""Ejecuta ``SET LOCAL`` en la transacción activa para fijar el contexto RLS."""
|
||||
tenant_value = "" if tenant_id is None else str(int(tenant_id))
|
||||
company_value = "" if company_id is None else str(int(company_id))
|
||||
connection.execute(
|
||||
text(
|
||||
"SELECT set_config('app.tenant_id', :t, true), "
|
||||
"set_config('app.company_id', :c, true)"
|
||||
),
|
||||
{"t": tenant_value, "c": company_value},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_context(session) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Selecciona tenant_id/company_id desde ``session.info`` y, si faltan,
|
||||
desde las ContextVars (usadas por tareas Celery vía task_prerun)."""
|
||||
tenant_id = session.info.get(RLS_TENANT_KEY)
|
||||
company_id = session.info.get(RLS_COMPANY_KEY)
|
||||
if tenant_id is None:
|
||||
tenant_id = rls_tenant_var.get()
|
||||
if company_id is None:
|
||||
company_id = rls_company_var.get()
|
||||
return tenant_id, company_id
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_begin")
|
||||
def _after_begin(session: Session, transaction, connection) -> None: # type: ignore[no-untyped-def]
|
||||
"""Aplica ``SET LOCAL`` en cada transacción nueva.
|
||||
|
||||
Cubre sesiones síncronas y asíncronas porque ``AsyncSession`` envuelve
|
||||
internamente una ``Session`` que hereda de esta clase.
|
||||
"""
|
||||
tenant_id, company_id = _resolve_context(session)
|
||||
if tenant_id is None and company_id is None:
|
||||
return
|
||||
_apply_rls_context(connection, tenant_id, company_id)
|
||||
|
||||
|
||||
def set_rls_context(
|
||||
session: Session,
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Guarda el contexto RLS en la sesión y, si hay transacción abierta, lo aplica.
|
||||
|
||||
Útil para endpoints que validan acceso a una compañía específica después de
|
||||
crear la sesión (por ejemplo rutas que reciben ``company_id`` en el path).
|
||||
"""
|
||||
session.info[RLS_TENANT_KEY] = tenant_id
|
||||
session.info[RLS_COMPANY_KEY] = company_id
|
||||
if session.in_transaction():
|
||||
_apply_rls_context(session.connection(), tenant_id, company_id)
|
||||
|
||||
|
||||
def reset_rls_context_tokens(token_t, token_c) -> None:
|
||||
"""Restaura ContextVars de RLS de forma segura entre hilos/tareas asyncio.
|
||||
|
||||
``ContextVar.reset`` exige que el token se cree y restaure en el mismo contexto
|
||||
lógico; en rutas FastAPI async + dependencias síncronas con ``yield`` (thread
|
||||
pool) el ``finally`` puede ejecutarse en otro contexto y lanzar ``ValueError``
|
||||
(mensaje: "was created in a different Context"). En ese caso degradamos a
|
||||
``set(None)``, igual que ``task_postrun`` en ``core/celery_app.py``.
|
||||
"""
|
||||
try:
|
||||
rls_tenant_var.reset(token_t)
|
||||
rls_company_var.reset(token_c)
|
||||
except (ValueError, RuntimeError):
|
||||
rls_tenant_var.set(None)
|
||||
rls_company_var.set(None)
|
||||
|
||||
|
||||
def _extract_rls_context(request: Optional[Request]) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Recupera ``tenant_id`` / ``company_id`` del estado del request (o de cookies)."""
|
||||
if request is None:
|
||||
return None, None
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
company_id = getattr(request.state, "company_id", None)
|
||||
if company_id is None:
|
||||
cookie_value = request.cookies.get("active_company_id")
|
||||
if cookie_value:
|
||||
try:
|
||||
company_id = int(cookie_value)
|
||||
except (TypeError, ValueError):
|
||||
company_id = None
|
||||
return tenant_id, company_id
|
||||
|
||||
|
||||
def get_core_db(request: Request = None) -> Generator[Session, None, None]:
|
||||
"""Dependency para obtener sesión síncrona con contexto RLS.
|
||||
|
||||
FastAPI inyecta ``Request`` automáticamente; los llamadores existentes que
|
||||
escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin
|
||||
cambios porque ``Request`` se resuelve en la capa de dependencia.
|
||||
|
||||
No se escriben las ContextVars de RLS aquí: las dependencias síncronas con
|
||||
``yield`` se ejecutan vía ``contextmanager_in_threadpool`` (hilo worker) y
|
||||
mezclar ``ContextVar.set`` / ``reset`` entre ese hilo y el bucle asyncio
|
||||
provoca ``ValueError: ... was created in a different Context``. El aislamiento
|
||||
RLS se aplica con ``session.info`` (véase ``after_begin`` y audit listeners).
|
||||
"""
|
||||
tenant_id, company_id = _extract_rls_context(request)
|
||||
db = CoreSessionLocal()
|
||||
db.info[RLS_TENANT_KEY] = tenant_id
|
||||
db.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency async para obtener sesión con contexto RLS."""
|
||||
tenant_id, company_id = _extract_rls_context(request)
|
||||
prev_tenant = rls_tenant_var.get()
|
||||
prev_company = rls_company_var.get()
|
||||
rls_tenant_var.set(tenant_id)
|
||||
rls_company_var.set(company_id)
|
||||
try:
|
||||
async with AsyncCoreSessionLocal() as session:
|
||||
session.info[RLS_TENANT_KEY] = tenant_id
|
||||
session.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
finally:
|
||||
rls_tenant_var.set(prev_tenant)
|
||||
rls_company_var.set(prev_company)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def scoped_core_db(
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> Generator[Session, None, None]:
|
||||
"""Abre una sesión síncrona con contexto RLS explícito.
|
||||
|
||||
Pensado para tareas Celery, comandos de mantenimiento o cualquier camino
|
||||
fuera del ciclo de request HTTP. El contexto se aplica con ``SET LOCAL``
|
||||
en cada transacción.
|
||||
"""
|
||||
db = CoreSessionLocal()
|
||||
db.info[RLS_TENANT_KEY] = tenant_id
|
||||
db.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def scoped_async_core_db(
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Variante async de :func:`scoped_core_db`."""
|
||||
async with AsyncCoreSessionLocal() as session:
|
||||
session.info[RLS_TENANT_KEY] = tenant_id
|
||||
session.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def get_tenant_engine(tenant_id: int, db_config: dict):
|
||||
"""Obtiene o crea un engine para un tenant con BD dedicada."""
|
||||
if tenant_id not in _tenant_engines:
|
||||
db_url = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['name']}"
|
||||
_tenant_engines[tenant_id] = create_engine(
|
||||
db_url, pool_pre_ping=True, pool_size=5, max_overflow=10
|
||||
)
|
||||
return _tenant_engines[tenant_id]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_tenant_db(
|
||||
tenant_id: int, db_config: Optional[dict] = None
|
||||
) -> Generator[Session, None, None]:
|
||||
"""Context manager para obtener sesión de BD de un tenant específico.
|
||||
|
||||
Si ``db_config`` es ``None`` usa la BD core compartida y aplica RLS con
|
||||
el ``tenant_id`` recibido. Si el tenant tiene BD dedicada, el aislamiento
|
||||
es físico y no se fija contexto RLS (no hay columna ``tenant_id``).
|
||||
"""
|
||||
if db_config is None:
|
||||
db = CoreSessionLocal()
|
||||
db.info[RLS_TENANT_KEY] = tenant_id
|
||||
else:
|
||||
engine = get_tenant_engine(tenant_id, db_config)
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Inicializa las tablas de la base de datos core."""
|
||||
try:
|
||||
Base.metadata.create_all(bind=core_engine, checkfirst=True)
|
||||
except ProgrammingError as e:
|
||||
if "already exists" in str(e):
|
||||
logger.warning(
|
||||
f"Algunas tablas ya existen en la base de datos: {e}")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def init_async_db():
|
||||
"""Inicializa las tablas de la base de datos core (async)."""
|
||||
async with async_core_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
118
backend/core/email.py
Normal file
118
backend/core/email.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Email service for sending reports via SMTP.
|
||||
"""
|
||||
import aiosmtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from typing import List
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service for sending emails with attachments."""
|
||||
|
||||
@staticmethod
|
||||
async def send_report_email(
|
||||
recipient_email: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
csv_content: str,
|
||||
filename: str
|
||||
) -> bool:
|
||||
"""
|
||||
Send a report email with CSV attachment.
|
||||
|
||||
Args:
|
||||
recipient_email: Email address of recipient
|
||||
subject: Email subject line
|
||||
body_text: Plain text email body
|
||||
csv_content: CSV file content as string
|
||||
filename: Name for the CSV attachment
|
||||
|
||||
Returns:
|
||||
bool: True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||
msg['To'] = recipient_email
|
||||
msg['Subject'] = subject
|
||||
|
||||
# Email body
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">
|
||||
Reporte de Facturas
|
||||
</h2>
|
||||
<p>{body_text}</p>
|
||||
<p style="margin-top: 20px;">
|
||||
El reporte se encuentra adjunto en formato CSV.
|
||||
</p>
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 12px; color: #6b7280;">
|
||||
Este es un correo generado automáticamente. Por favor no responder.
|
||||
</p>
|
||||
<p style="font-size: 12px; color: #6b7280;">
|
||||
Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
msg.attach(MIMEText(html_body, 'html'))
|
||||
|
||||
# CSV attachment with UTF-8 BOM for Excel compatibility
|
||||
attachment = MIMEBase('text', 'csv')
|
||||
csv_bytes = b'\xef\xbb\xbf' + csv_content.encode('utf-8')
|
||||
attachment.set_payload(csv_bytes)
|
||||
encoders.encode_base64(attachment)
|
||||
attachment.add_header(
|
||||
'Content-Disposition',
|
||||
f'attachment; filename="{filename}"'
|
||||
)
|
||||
msg.attach(attachment)
|
||||
|
||||
# Create SSL context that ignores certificate errors
|
||||
import ssl
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Send email
|
||||
if settings.SMTP_PORT == 465:
|
||||
# Port 465 uses implicit SSL
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=True, # Implicit SSL
|
||||
tls_context=context
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(msg)
|
||||
else:
|
||||
# Port 587 uses STARTTLS
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
tls_context=context
|
||||
) as smtp:
|
||||
await smtp.starttls(tls_context=context)
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(msg)
|
||||
|
||||
logger.info(f"Email sent successfully to {recipient_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient_email}: {str(e)}")
|
||||
return False
|
||||
357
backend/core/error_handlers.py
Normal file
357
backend/core/error_handlers.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Manejadores globales de excepciones para FastAPI
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import Request, status, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
from .config import settings
|
||||
from .exceptions import BaseAPIException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cors_headers(request: Request) -> Dict[str, str]:
|
||||
"""CORS headers for error responses so browser does not block on 4xx/5xx."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin or origin not in settings.cors_origins_list:
|
||||
return {}
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
}
|
||||
|
||||
|
||||
async def base_exception_handler(
|
||||
request: Request,
|
||||
exc: BaseAPIException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para todas las excepciones personalizadas de la API
|
||||
"""
|
||||
logger.warning(
|
||||
f"API Exception: {exc.error_code} - {exc.message}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
|
||||
# Log detailed errors if they exist
|
||||
if hasattr(exc, "errors") and exc.errors:
|
||||
logger.warning(f"Validation errors details: {exc.errors}")
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=jsonable_encoder(exc.to_dict()),
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
# Mapa de campos técnicos a nombres legibles en español
|
||||
_FIELD_LABELS: Dict[str, str] = {
|
||||
"broker_key": "Clave del Agente",
|
||||
"license": "Patente",
|
||||
"tax_id": "RFC",
|
||||
"personal_id": "CURP",
|
||||
"email": "Correo Electrónico",
|
||||
"phone": "Teléfono",
|
||||
"fax": "Fax",
|
||||
"contact": "Nombre de Contacto",
|
||||
"name": "Nombre / Razón Social",
|
||||
"address": "Dirección",
|
||||
"postal_code": "Código Postal",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado",
|
||||
"country": "País",
|
||||
# Partes (A76)
|
||||
"unit_cost": "Costo Unitario",
|
||||
"unit_weight": "Peso Unitario",
|
||||
"sector": "Sector",
|
||||
"fraction_type": "Tipo de tarifa",
|
||||
}
|
||||
|
||||
_FIELD_PATTERN_MESSAGES: Dict[str, str] = {
|
||||
"broker_key": "La Clave del Agente solo puede contener letras y números (máx. 5 caracteres).",
|
||||
"license": "La Patente debe ser un número entre 1 y 9999 (no puede ser 0 ni contener letras).",
|
||||
"tax_id": "El RFC no tiene el formato correcto. Ejemplo válido: XAXX010101000.",
|
||||
"personal_id": "La CURP no tiene el formato correcto. Debe tener 18 caracteres alfanuméricos.",
|
||||
"email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.",
|
||||
"phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).",
|
||||
"contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.",
|
||||
# Partes (A76)
|
||||
"sector": "El Sector solo puede contener números, máximo 8 dígitos (sin espacios ni caracteres especiales).",
|
||||
}
|
||||
|
||||
|
||||
def _friendly_message(field_key: str, error_type: str) -> str:
|
||||
"""Devuelve un mensaje de error legible en español según el campo y tipo de error."""
|
||||
if error_type in ("greater_than_equal",):
|
||||
if field_key in ("unit_cost", "unit_weight"):
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' no puede ser negativo."
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser mayor o igual a 0."
|
||||
if error_type in ("string_pattern_mismatch", "value_error"):
|
||||
return _FIELD_PATTERN_MESSAGES.get(
|
||||
field_key,
|
||||
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.",
|
||||
)
|
||||
if error_type in ("decimal_parsing", "decimal_type", "float_parsing", "float_type", "int_parsing", "int_type"):
|
||||
return (
|
||||
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser numérico. "
|
||||
"Si no aplica, déjelo vacío."
|
||||
)
|
||||
if error_type in ("literal_error",):
|
||||
if field_key == "fraction_type":
|
||||
return "El campo 'Tipo de tarifa' es inválido. Seleccione una opción predefinida."
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene una opción inválida."
|
||||
if error_type == "string_too_long":
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida."
|
||||
if error_type == "string_too_short":
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es demasiado corto."
|
||||
if error_type in ("missing", "value_error.missing"):
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es obligatorio."
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor inválido."
|
||||
|
||||
|
||||
async def validation_exception_handler(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores de validación de Pydantic/FastAPI.
|
||||
Devuelve mensajes legibles en español.
|
||||
"""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
|
||||
field = ".".join(loc_parts)
|
||||
field_key = loc_parts[-1] if loc_parts else ""
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"field": field,
|
||||
"message": _friendly_message(field_key, error["type"]),
|
||||
"type": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
print(f"DEBUG REQUEST VALIDATION ERRORS: {errors}")
|
||||
logger.warning(
|
||||
f"Validation Error en {request.url.path}",
|
||||
extra={"errors": errors},
|
||||
)
|
||||
|
||||
summary = (
|
||||
errors[0]["message"]
|
||||
if len(errors) == 1
|
||||
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": summary,
|
||||
"status_code": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
import traceback
|
||||
|
||||
async def inner_validation_exception_handler(
|
||||
request: Request,
|
||||
exc: ValidationError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes).
|
||||
"""
|
||||
traceback.print_exc()
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
|
||||
field = ".".join(loc_parts)
|
||||
field_key = loc_parts[-1] if loc_parts else ""
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"field": field,
|
||||
"message": _friendly_message(field_key, error["type"]),
|
||||
"type": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
print(f"DEBUG VALIDATION ERRORS: {errors}")
|
||||
logger.warning(
|
||||
f"Inner Validation Error en {request.url.path}",
|
||||
extra={"errors": errors},
|
||||
)
|
||||
|
||||
summary = (
|
||||
errors[0]["message"]
|
||||
if len(errors) == 1
|
||||
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": summary,
|
||||
"status_code": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
async def integrity_error_handler(
|
||||
request: Request,
|
||||
exc: IntegrityError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores de integridad de la base de datos
|
||||
"""
|
||||
logger.error(
|
||||
f"Database Integrity Error: {str(exc.orig)}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
|
||||
orig_msg = str(exc.orig).lower()
|
||||
|
||||
# Check for unique/duplicate key violations (English and Spanish)
|
||||
if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]):
|
||||
error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)."
|
||||
# Check for foreign key violations (English and Spanish)
|
||||
elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]):
|
||||
error_message = "Referencia inválida a otro registro. Verifica las categorías y catálogos seleccionados."
|
||||
# Check for not null violations (English and Spanish)
|
||||
elif any(kw in orig_msg for kw in ["not null", "no nulo", "valor nulo"]):
|
||||
error_message = "Falta un campo requerido. Asegúrate de llenar todos los datos obligatorios."
|
||||
else:
|
||||
error_message = "Error de integridad en la base de datos"
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={
|
||||
"error": "DATABASE_INTEGRITY_ERROR",
|
||||
"message": error_message,
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
async def sqlalchemy_error_handler(
|
||||
request: Request,
|
||||
exc: SQLAlchemyError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores generales de SQLAlchemy
|
||||
"""
|
||||
logger.error(
|
||||
f"Database Error: {str(exc)}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": f"Error en la operación de base de datos: {str(exc)}",
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
async def http_exception_handler(
|
||||
request: Request,
|
||||
exc: HTTPException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para HTTPException de FastAPI
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": exc.detail,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para excepciones no capturadas
|
||||
"""
|
||||
logger.error(
|
||||
f"Unhandled Exception: {str(exc)}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "INTERNAL_SERVER_ERROR",
|
||||
"message": f"Error interno del servidor: {str(exc)}",
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def register_exception_handlers(app) -> None:
|
||||
"""
|
||||
Registra todos los manejadores de excepciones en la aplicación FastAPI
|
||||
|
||||
Args:
|
||||
app: Instancia de FastAPI
|
||||
"""
|
||||
app.add_exception_handler(BaseAPIException, base_exception_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(ValidationError, inner_validation_exception_handler)
|
||||
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
||||
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
300
backend/core/exceptions.py
Normal file
300
backend/core/exceptions.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Sistema centralizado de excepciones personalizadas
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class BaseAPIException(Exception):
|
||||
"""Excepción base para todas las excepciones de la API"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
errors: Optional[List[Dict[str, Any]]] = None,
|
||||
error_code: Optional[str] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.errors = errors or []
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convierte la excepción a un diccionario para respuesta JSON"""
|
||||
response = {
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"status_code": self.status_code,
|
||||
}
|
||||
if self.errors:
|
||||
response["errors"] = self.errors
|
||||
return response
|
||||
|
||||
|
||||
class ValidationException(BaseAPIException):
|
||||
"""Excepción para errores de validación"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Error de validación",
|
||||
errors: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
errors=errors,
|
||||
error_code="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class DuplicateResourceException(BaseAPIException):
|
||||
"""Excepción cuando se intenta crear un recurso duplicado"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
identifier: str,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
self.resource = resource
|
||||
self.identifier = identifier
|
||||
final_message = (
|
||||
message or f"{resource} con identificador '{identifier}' ya existe"
|
||||
)
|
||||
super().__init__(
|
||||
message=final_message,
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
error_code="DUPLICATE_RESOURCE",
|
||||
)
|
||||
|
||||
|
||||
class ResourceNotFoundException(BaseAPIException):
|
||||
"""Excepción cuando no se encuentra un recurso"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
identifier: str,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
self.resource = resource
|
||||
self.identifier = identifier
|
||||
final_message = (
|
||||
message or f"{resource} con identificador '{identifier}' no encontrado"
|
||||
)
|
||||
super().__init__(
|
||||
message=final_message,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
error_code="RESOURCE_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedException(BaseAPIException):
|
||||
"""Excepción para errores de autenticación"""
|
||||
|
||||
def __init__(self, message: str = "No autorizado"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error_code="UNAUTHORIZED",
|
||||
)
|
||||
|
||||
|
||||
class ForbiddenException(BaseAPIException):
|
||||
"""Excepción para errores de permisos"""
|
||||
|
||||
def __init__(self, message: str = "Acceso prohibido"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
error_code="FORBIDDEN",
|
||||
)
|
||||
|
||||
|
||||
class BusinessRuleException(BaseAPIException):
|
||||
"""Excepción para errores de reglas de negocio"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
errors: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
errors=errors,
|
||||
error_code="BUSINESS_RULE_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class DatabaseException(BaseAPIException):
|
||||
"""Excepción para errores de base de datos"""
|
||||
|
||||
def __init__(self, message: str = "Error en la base de datos"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
error_code="DATABASE_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class ErrorCollector:
|
||||
"""
|
||||
Colector de errores para acumular múltiples errores de validación
|
||||
antes de lanzar una excepción
|
||||
|
||||
Uso:
|
||||
collector = ErrorCollector()
|
||||
|
||||
if not valid_email:
|
||||
collector.add_error("email", "Email inválido", "INVALID_EMAIL")
|
||||
|
||||
if not valid_phone:
|
||||
collector.add_error("phone", "Teléfono inválido", "INVALID_PHONE")
|
||||
|
||||
collector.raise_if_errors() # Lanza ValidationException si hay errores
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._errors: List[Dict[str, Any]] = []
|
||||
|
||||
def add_error(
|
||||
self,
|
||||
field: str,
|
||||
message: str,
|
||||
solution: Optional[List[str]],
|
||||
code: Optional[str] = None,
|
||||
value: Optional[Any] = None,
|
||||
) -> "ErrorCollector":
|
||||
"""
|
||||
Agrega un error al colector
|
||||
|
||||
Args:
|
||||
field: Campo donde ocurrió el error (ej: "invoice_number", "email")
|
||||
message: Mensaje descriptivo del error
|
||||
code: Código opcional del error (ej: "REQUIRED", "INVALID_FORMAT")
|
||||
value: Valor que causó el error (opcional)
|
||||
|
||||
Returns:
|
||||
Self para permitir encadenamiento
|
||||
"""
|
||||
error = {
|
||||
"field": field,
|
||||
"message": message,
|
||||
}
|
||||
if solution:
|
||||
error["solution"] = solution
|
||||
if code:
|
||||
error["code"] = code
|
||||
if value is not None:
|
||||
error["value"] = value
|
||||
|
||||
self._errors.append(error)
|
||||
return self
|
||||
|
||||
def add_field_error(
|
||||
self,
|
||||
field: str,
|
||||
message: str,
|
||||
code: str = "INVALID",
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de campo"""
|
||||
return self.add_error(field, message, solution=None, code=code)
|
||||
|
||||
def add_required_error(self, field: str) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de campo requerido"""
|
||||
return self.add_error(
|
||||
field, f"El campo '{field}' es requerido", solution=None, code="REQUIRED"
|
||||
)
|
||||
|
||||
def add_duplicate_error(
|
||||
self,
|
||||
field: str,
|
||||
value: Any,
|
||||
message: Optional[str] = None,
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de duplicado"""
|
||||
final_message = (
|
||||
message or f"El valor '{value}' ya existe para el campo '{field}'"
|
||||
)
|
||||
return self.add_error(
|
||||
field, final_message, solution=None, code="DUPLICATE", value=value
|
||||
)
|
||||
|
||||
def add_invalid_format_error(
|
||||
self,
|
||||
field: str,
|
||||
expected_format: str,
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de formato inválido"""
|
||||
return self.add_error(
|
||||
field,
|
||||
f"Formato inválido. Se esperaba: {expected_format}",
|
||||
solution=None,
|
||||
code="INVALID_FORMAT",
|
||||
)
|
||||
|
||||
def add_range_error(
|
||||
self,
|
||||
field: str,
|
||||
min_value: Optional[Any] = None,
|
||||
max_value: Optional[Any] = None,
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de rango"""
|
||||
if min_value is not None and max_value is not None:
|
||||
message = f"El valor debe estar entre {min_value} y {max_value}"
|
||||
elif min_value is not None:
|
||||
message = f"El valor debe ser mayor o igual a {min_value}"
|
||||
elif max_value is not None:
|
||||
message = f"El valor debe ser menor o igual a {max_value}"
|
||||
else:
|
||||
message = "Valor fuera de rango"
|
||||
|
||||
return self.add_error(field, message, solution=None, code="OUT_OF_RANGE")
|
||||
|
||||
def has_errors(self) -> bool:
|
||||
"""Verifica si hay errores acumulados"""
|
||||
return len(self._errors) > 0
|
||||
|
||||
def get_errors(self) -> List[Dict[str, Any]]:
|
||||
"""Obtiene la lista de errores"""
|
||||
return self._errors.copy()
|
||||
|
||||
def get_error_count(self) -> int:
|
||||
"""Obtiene el número de errores"""
|
||||
return len(self._errors)
|
||||
|
||||
def clear(self) -> "ErrorCollector":
|
||||
"""Limpia todos los errores"""
|
||||
self._errors.clear()
|
||||
return self
|
||||
|
||||
def raise_if_errors(
|
||||
self,
|
||||
message: str = "Se encontraron errores de validación",
|
||||
) -> None:
|
||||
"""
|
||||
Lanza ValidationException si hay errores acumulados
|
||||
|
||||
Args:
|
||||
message: Mensaje principal de la excepción
|
||||
|
||||
Raises:
|
||||
ValidationException: Si hay errores acumulados
|
||||
"""
|
||||
if self.has_errors():
|
||||
raise ValidationException(message=message, errors=self._errors)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Permite usar el colector en contextos booleanos"""
|
||||
return self.has_errors()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Permite usar len() en el colector"""
|
||||
return self.get_error_count()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ErrorCollector(errors={self.get_error_count()})"
|
||||
332
backend/core/middleware.py
Normal file
332
backend/core/middleware.py
Normal file
@@ -0,0 +1,332 @@
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .security import get_tenant_from_token, verify_token, get_active_system
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def _is_token_issue_message(*values: str | None) -> bool:
|
||||
text = " ".join(_normalize_text(v) for v in values if v)
|
||||
if not text:
|
||||
return False
|
||||
|
||||
token_markers = ["token", "jwt", "bearer", "access"]
|
||||
invalid_markers = [
|
||||
"invalido", "inválido", "invalid", "not valid", "malformed", "signature", "unauthorized"
|
||||
]
|
||||
expired_markers = ["expirado", "expirada", "expired", "has expired", "caducado", "vencido"]
|
||||
|
||||
has_token_context = any(marker in text for marker in token_markers)
|
||||
has_invalid_marker = any(marker in text for marker in invalid_markers)
|
||||
has_expired_marker = any(marker in text for marker in expired_markers)
|
||||
|
||||
return (has_expired_marker and has_token_context) or (has_token_context and has_invalid_marker)
|
||||
|
||||
|
||||
def _extract_company_id(request: Request) -> Optional[int]:
|
||||
"""Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie.
|
||||
|
||||
El frontend guarda la compañía activa en la cookie ``active_company_id``
|
||||
(ver ``frontend/src/lib/stores/company.svelte.ts``). El header es la
|
||||
ruta explícita para clientes no-browser.
|
||||
"""
|
||||
header_value = request.headers.get("X-Company-Id")
|
||||
raw = header_value or request.cookies.get("active_company_id")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para extraer tenant_id y user_info del token.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
|
||||
public_prefixes = [
|
||||
"/api/v1/auth",
|
||||
"/api/v1/status",
|
||||
"/api/health",
|
||||
"/api/",
|
||||
"/uploads",
|
||||
"/api/v1/core/help-center",
|
||||
"/api/v1/core/users/avatar",
|
||||
]
|
||||
|
||||
path = request.url.path
|
||||
|
||||
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": "Missing or invalid authorization header",
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
try:
|
||||
user_info = await verify_token(token)
|
||||
tenant_id = get_tenant_from_token(user_info)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.user_info = user_info
|
||||
request.state.company_id = _extract_company_id(request)
|
||||
request.state.active_system = get_active_system(request)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Tenant validation error: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": "Invalid authentication",
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware que valida la licencia contra el Hub de Aduanasoft.
|
||||
El Hub siempre es requerido — tanto en SaaS como en self-hosted.
|
||||
Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
# En modo local (DEV_LOCAL_AUTH) no hay Hub — saltar validación de licencia.
|
||||
if settings.DEV_LOCAL_AUTH:
|
||||
return await call_next(request)
|
||||
|
||||
exempt_paths = [
|
||||
"/api/docs", "/api/redoc", "/openapi.json",
|
||||
"/api/v1/auth", "/api/v1/status", "/api/health",
|
||||
"/api/v1/core/help-center",
|
||||
"/api/v1/core/users/avatar",
|
||||
]
|
||||
|
||||
is_exempt = any(
|
||||
request.url.path == path or (path != "/" and request.url.path.startswith(path))
|
||||
for path in exempt_paths
|
||||
)
|
||||
|
||||
if is_exempt:
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
# Permitimos pasar para que TenantMiddleware maneje el 401
|
||||
return await call_next(request)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
tenant_override = request.headers.get("X-Tenant-Override")
|
||||
if not tenant_override:
|
||||
# Fallback para flujos SSO cuando el override no viaja en header.
|
||||
tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub")
|
||||
|
||||
# TenantMiddleware (corre antes) ya resolvió el token y dejó tenant en user_info.
|
||||
# Sin esto, Swagger/curl sin cookies SSO llaman verify-license sin contexto y el Hub
|
||||
# puede devolver 401 aunque /auth/me con el mismo Bearer responda 200.
|
||||
if not tenant_override:
|
||||
user_info = getattr(request.state, "user_info", None)
|
||||
if isinstance(user_info, dict):
|
||||
tid = user_info.get("tenant_id")
|
||||
if tid is not None and str(tid).strip() != "":
|
||||
tenant_override = str(tid)
|
||||
|
||||
hub_headers = {"Authorization": f"Bearer {token}"}
|
||||
if tenant_override:
|
||||
hub_headers["X-Tenant-Override"] = str(tenant_override)
|
||||
logger.info("[license] tenant override propagated to Hub: %s", tenant_override)
|
||||
|
||||
# Solo la petición HTTP al Hub va en try: los errores de rutas (p. ej. ContextVar RLS)
|
||||
# deben propagarse y no etiquetarse como fallo de licencia.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/verify-license",
|
||||
headers=hub_headers
|
||||
)
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_OFFLINE",
|
||||
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Hub verify-license request failed: %s", e)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": "Error interno al contactar el servicio de licencias.",
|
||||
"status_code": 500,
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
# Endpoint no existe en este Hub — dejar pasar
|
||||
return await call_next(request)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Hub verify-license JSON parse failed: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_ERROR",
|
||||
"message": "Respuesta inválida del servidor de licencias.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
|
||||
# Escenario 1: sin licencia asignada o licencia inactiva
|
||||
if not data.get("valid", False):
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
detail = data.get("detail")
|
||||
reason = data.get("reason")
|
||||
# Si el Hub reporta token inválido/expirado, devolver 401 para que
|
||||
# el frontend dispare el auto-refresh (solo se activa con 401/403, no 402).
|
||||
if _is_token_issue_message(message, detail, reason):
|
||||
logger.warning(
|
||||
"[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s",
|
||||
message,
|
||||
detail,
|
||||
reason,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": message,
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"[license] licencia invalida para tenant=%s | message=%s",
|
||||
data.get("tenant_slug"),
|
||||
message,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_ERROR",
|
||||
"message": message,
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
|
||||
# Escenario 2: licencia vencida (verificación local de expires_at)
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
logger.warning(
|
||||
"[license] licencia expirada para tenant=%s | expires_at=%s",
|
||||
data.get("tenant_slug"),
|
||||
expires_at_str,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_EXPIRED",
|
||||
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
|
||||
|
||||
request.state.license_info = data
|
||||
return await call_next(request)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token inválido o expirado.",
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 403:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "FORBIDDEN",
|
||||
"message": "El Tenant no tiene permisos en el Hub central.",
|
||||
"status_code": 403,
|
||||
}
|
||||
)
|
||||
|
||||
logger.error(f"Hub error status: {response.status_code}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_ERROR",
|
||||
"message": "Error en el servidor de licencias.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para logging de performance.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
start_time = time.time()
|
||||
excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"]
|
||||
|
||||
if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths):
|
||||
return await call_next(request)
|
||||
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"Status: {response.status_code} "
|
||||
f"Duration: {process_time:.3f}s"
|
||||
)
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
15
backend/core/paths.py
Normal file
15
backend/core/paths.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Rutas base y resolución de paths para layouts (importación CSV, temp, errors).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# Raíz del backend (directorio que contiene api/, core/, etc.)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def layout_path(*parts: str) -> str:
|
||||
"""Construye una ruta absoluta bajo backend/layouts/."""
|
||||
p = BASE_DIR / "layouts"
|
||||
for part in parts:
|
||||
p = p / part
|
||||
return str(p)
|
||||
427
backend/core/s3_keys.py
Normal file
427
backend/core/s3_keys.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Convención de claves S3/MinIO para objetos persistidos.
|
||||
|
||||
Todas las cargas que usen ``put_object_bytes`` deben obtener la clave mediante
|
||||
funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP).
|
||||
|
||||
Árbol canónico
|
||||
--------------
|
||||
|
||||
**Multi-tenant** (datos de clientes), siempre bajo ``tenants/{tenant_id}/``:
|
||||
|
||||
- ``tenants/{tid}/users/{keycloak_sub}/``
|
||||
Perfil de usuario (avatar). Ver ``tenant_user_prefix``, ``user_avatar_key``.
|
||||
|
||||
- ``tenants/{tid}/companies/{company_id}/``
|
||||
Recursos ligados a una empresa:
|
||||
|
||||
- ``.../doda/{doda_id}/report/doda_report.pdf`` — reporte DODA en PDF. ``doda_report_pdf_key``.
|
||||
- ``.../branding/{filename}`` — logo. ``company_logo_key``.
|
||||
- ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación.
|
||||
``company_certificate_key``.
|
||||
- ``.../imports/csv/{job_type}/{job_id}.csv`` — CSV de layouts (import jobs).
|
||||
``csv_import_key`` (usado por ``storage_s3.s3_key_for_csv_import``).
|
||||
- ``.../customs_brokers/{broker_id}/certificates/`` — CER del VU (``.cer``).
|
||||
``customs_broker_vu_certificate_key``.
|
||||
- ``.../customs_brokers/{broker_id}/keys/`` — llave privada VU (``.key``).
|
||||
``customs_broker_vu_private_key_key``.
|
||||
- ``.../customs_brokers/{broker_id}/cove/`` — archivos COVE (xml, zip, etc.).
|
||||
``customs_broker_vu_cove_key``.
|
||||
|
||||
**Sistema global** (no por tenant):
|
||||
|
||||
- ``system/help/{carpeta opcional}/{archivo}`` — biblioteca de ayuda (imágenes, PDFs, vídeos).
|
||||
``help_asset_key``, ``global_system_prefix``. Lectura HTTP mapea a este prefijo.
|
||||
|
||||
**Legado / migración**:
|
||||
|
||||
- ``imports/csv/{job_type}/{job_id}.csv`` — sin tenant/company. Solo ``legacy_csv_import_key``
|
||||
(cleanup o compatibilidad).
|
||||
|
||||
Constantes públicas
|
||||
-------------------
|
||||
|
||||
``SYSTEM_HELP_PREFIX`` — prefijo literal ``system/help/`` para lecturas y utilidades
|
||||
que no pasan por ``help_asset_key``.
|
||||
"""
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
# Segmentos permitidos en claves (evita path traversal)
|
||||
_SAFE_SEGMENT = re.compile(r"^[a-zA-Z0-9._\-]+$")
|
||||
|
||||
# Prefijo fijo para objetos de Help Center (debe coincidir con help_asset_key / GET /files/)
|
||||
SYSTEM_HELP_PREFIX = "system/help/"
|
||||
|
||||
|
||||
def _segment(value: Union[int, str], label: str) -> str:
|
||||
s = str(value).strip()
|
||||
if not s or "/" in s or ".." in s:
|
||||
raise ValueError(f"invalid {label} segment")
|
||||
if not _SAFE_SEGMENT.match(s):
|
||||
raise ValueError(f"invalid {label} characters")
|
||||
return s
|
||||
|
||||
|
||||
def tenant_company_prefix(tenant_id: Union[int, str], company_id: int) -> str:
|
||||
"""Prefijo `tenants/{tid}/companies/{cid}/` (termina en /)."""
|
||||
tid = _segment(tenant_id, "tenant_id")
|
||||
cid = _segment(company_id, "company_id")
|
||||
return f"tenants/{tid}/companies/{cid}/"
|
||||
|
||||
|
||||
def tenant_user_prefix(tenant_id: Union[int, str], keycloak_user_id: str) -> str:
|
||||
"""Prefijo `tenants/{tid}/users/{keycloak_sub}/` (avatar de perfil, sin company)."""
|
||||
tid = _segment(tenant_id, "tenant_id")
|
||||
kid = _segment(keycloak_user_id, "keycloak_user_id")
|
||||
return f"tenants/{tid}/users/{kid}/"
|
||||
|
||||
|
||||
def user_avatar_key(
|
||||
tenant_id: Union[int, str],
|
||||
keycloak_user_id: str,
|
||||
ext: str,
|
||||
) -> str:
|
||||
ext = ext.lower() if ext.startswith(".") else f".{ext}"
|
||||
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
|
||||
if ext not in allowed:
|
||||
raise ValueError("invalid avatar extension")
|
||||
return f"{tenant_user_prefix(tenant_id, keycloak_user_id)}avatar{ext}"
|
||||
|
||||
|
||||
def public_user_avatar_api_path(tenant_id: int, keycloak_user_id: str) -> str:
|
||||
"""Ruta GET pública para servir la imagen (sin host)."""
|
||||
return f"/api/v1/core/users/avatar/{tenant_id}/{keycloak_user_id}"
|
||||
|
||||
|
||||
def global_system_prefix(subpath: str = "help") -> str:
|
||||
"""Prefijo bajo `system/` para contenido global (p. ej. help). Termina en /."""
|
||||
sub = subpath.strip().strip("/")
|
||||
if not sub:
|
||||
return SYSTEM_HELP_PREFIX
|
||||
parts = sub.split("/")
|
||||
for p in parts:
|
||||
_segment(p, "system_subpath")
|
||||
return f"system/{sub}/"
|
||||
|
||||
|
||||
def customs_broker_vu_prefix(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
Prefijo para el bloque VU del agente aduanal.
|
||||
|
||||
Forma: ``tenants/{tid}/companies/{cid}/customs_brokers/{broker_id}/``
|
||||
"""
|
||||
bid = _segment(str(broker_id), "broker_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}customs_brokers/{bid}/"
|
||||
|
||||
|
||||
def customs_broker_vu_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""CER del VU bajo ``.../customs_brokers/{id}/certificates/vu_cer_{timestamp}.cer``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".cer":
|
||||
raise ValueError("VU certificate must be .cer")
|
||||
base = f"vu_cer_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}certificates/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_private_key_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""Llave privada del VU bajo ``.../customs_brokers/{id}/keys/vu_key_{timestamp}.key``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".key":
|
||||
raise ValueError("VU private key must be .key")
|
||||
base = f"vu_key_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}keys/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_cove_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Archivos COVE bajo ``.../customs_brokers/{id}/cove/cove_{timestamp}_{filename}``.
|
||||
Extensiones típicas: .xml, .zip, .txt, .pdf, .json
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
fn = safe_filename(original_filename)
|
||||
parts = fn.rsplit(".", 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError("COVE file must have an extension")
|
||||
ext = "." + parts[1].lower()
|
||||
allowed = (".xml", ".zip", ".txt", ".pdf", ".json")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"COVE extension not allowed: {ext}")
|
||||
base = f"cove_{ts}_{fn}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}cove/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_doda_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""CER DODA bajo ``.../customs_brokers/{id}/doda/certificates/doda_cer_{timestamp}.cer``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".cer":
|
||||
raise ValueError("DODA certificate must be .cer")
|
||||
base = f"doda_cer_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/certificates/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_doda_private_key_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""Llave DODA bajo ``.../customs_brokers/{id}/doda/keys/doda_key_{timestamp}.key``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".key":
|
||||
raise ValueError("DODA private key must be .key")
|
||||
base = f"doda_key_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/keys/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_doda_cove_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Archivos DODA XML bajo ``.../customs_brokers/{id}/doda/cove/doda_cove_{timestamp}_{filename}``.
|
||||
Extensiones permitidas: .xml, .zip, .txt, .pdf, .json
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
fn = safe_filename(original_filename)
|
||||
parts = fn.rsplit(".", 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError("DODA file must have an extension")
|
||||
ext = "." + parts[1].lower()
|
||||
allowed = (".xml", ".zip", ".txt", ".pdf", ".json")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"DODA extension not allowed: {ext}")
|
||||
base = f"doda_cove_{ts}_{fn}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/cove/{base}"
|
||||
|
||||
|
||||
def job_type_segment(job_type: str) -> str:
|
||||
if job_type == "" or job_type == "invoice":
|
||||
return "invoice"
|
||||
return job_type
|
||||
|
||||
|
||||
def csv_import_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
job_type: str,
|
||||
job_id: str,
|
||||
) -> str:
|
||||
_segment(job_id, "job_id")
|
||||
return (
|
||||
f"{tenant_company_prefix(tenant_id, company_id)}"
|
||||
f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv"
|
||||
)
|
||||
|
||||
|
||||
def legacy_csv_import_key(job_type: str, job_id: str) -> str:
|
||||
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
|
||||
_segment(job_id, "job_id")
|
||||
return f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv"
|
||||
|
||||
|
||||
def safe_filename(filename: str) -> str:
|
||||
"""Nombre de archivo final sin separadores."""
|
||||
base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if not base or ".." in base:
|
||||
raise ValueError("invalid filename")
|
||||
return base
|
||||
|
||||
|
||||
def company_logo_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
filename: str,
|
||||
) -> str:
|
||||
fn = safe_filename(filename)
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}"
|
||||
|
||||
|
||||
def doda_report_pdf_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
doda_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
Reporte DODA en PDF bajo ``.../doda/{doda_id}/report/doda_report.pdf`` (clave estable).
|
||||
"""
|
||||
did = _segment(doda_id, "doda_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
|
||||
|
||||
|
||||
def company_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
certificate_type: str,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
ct = _segment(certificate_type.replace(".", "_"), "certificate_type")
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if file_ext.startswith(".") else f".{file_ext}"
|
||||
if ext not in (".cer", ".key"):
|
||||
raise ValueError("certificate file must be .cer or .key")
|
||||
base = f"{ct}_{ts}{ext}"
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}"
|
||||
|
||||
|
||||
def expediente_archivo_document_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
expediente_id: int,
|
||||
timestamp: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Archivo del expediente bajo ``.../expediente_archivos/{id}/documents/expediente_{timestamp}_{filename}``.
|
||||
Extensiones permitidas: .pdf, .xml, .png, .jpg, .jpeg, .json, .txt, .zip
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
eid = _segment(expediente_id, "expediente_id")
|
||||
fn = safe_filename(original_filename)
|
||||
parts = fn.rsplit(".", 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError("expediente file must have an extension")
|
||||
ext = "." + parts[1].lower()
|
||||
allowed = (".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"expediente file extension not allowed: {ext}")
|
||||
base = f"expediente_{ts}_{fn}"
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/documents/{base}"
|
||||
|
||||
|
||||
def expediente_archivo_artifact_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
expediente_id: int,
|
||||
artifact_type: str,
|
||||
timestamp: str,
|
||||
) -> str:
|
||||
"""
|
||||
Artefacto de digitalización bajo ``.../expediente_archivos/{id}/artifacts/{type}_{timestamp}.{ext}``.
|
||||
artifact_type: acuse | envio_xml | respuesta_xml | consulta_envio_xml | consulta_respuesta_xml
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
eid = _segment(expediente_id, "expediente_id")
|
||||
at = _segment(artifact_type, "artifact_type")
|
||||
ext = ".pdf" if artifact_type == "acuse" else ".xml"
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}"
|
||||
|
||||
|
||||
def cove_xml_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
invoice_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
XML de COVE devuelto por Ventanilla Única, bajo
|
||||
``.../invoices/{invoice_id}/cove/cove.xml`` (clave estable por factura).
|
||||
"""
|
||||
iid = _segment(invoice_id, "invoice_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/cove.xml"
|
||||
|
||||
|
||||
def cove_acuse_pdf_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
invoice_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
PDF de Acuse de COVE bajo
|
||||
``.../invoices/{invoice_id}/cove/acuse_cove.pdf`` (clave estable por factura).
|
||||
"""
|
||||
iid = _segment(invoice_id, "invoice_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/acuse_cove.pdf"
|
||||
|
||||
|
||||
def help_asset_key(folder: str, new_filename: str) -> str:
|
||||
"""
|
||||
folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/
|
||||
"""
|
||||
folder = folder.strip().strip("/")
|
||||
fn = safe_filename(new_filename)
|
||||
if folder:
|
||||
for p in folder.split("/"):
|
||||
_segment(p, "help_folder")
|
||||
return f"{global_system_prefix('help')}{folder}/{fn}"
|
||||
return f"{global_system_prefix('help')}{fn}"
|
||||
|
||||
|
||||
def help_s3_key_to_public_relative_path(key: str) -> str:
|
||||
"""Parte tras `system/help/` para el path del endpoint público."""
|
||||
if not key.startswith(SYSTEM_HELP_PREFIX):
|
||||
raise ValueError("key is not under system/help/")
|
||||
return key[len(SYSTEM_HELP_PREFIX) :]
|
||||
|
||||
|
||||
def help_public_api_path(relative_under_help: str) -> str:
|
||||
"""URL de lectura pública bajo el router help-center (sin host)."""
|
||||
rel = relative_under_help.lstrip("/")
|
||||
return f"/api/v1/core/help-center/files/{rel}"
|
||||
|
||||
|
||||
def signature_photo_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
signature_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""
|
||||
Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``.
|
||||
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp
|
||||
"""
|
||||
sid = _segment(signature_id, "signature_id")
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"signature photo extension not allowed: {ext}")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}"
|
||||
|
||||
|
||||
def system_help_object_key(relative_path: str) -> str:
|
||||
"""
|
||||
Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...).
|
||||
``relative_path`` no debe empezar por / ni contener '..'.
|
||||
"""
|
||||
rel = relative_path.strip().lstrip("/")
|
||||
if ".." in rel or not rel:
|
||||
raise ValueError("invalid help object path")
|
||||
return f"{SYSTEM_HELP_PREFIX}{rel}"
|
||||
737
backend/core/security.py
Normal file
737
backend/core/security.py
Normal file
@@ -0,0 +1,737 @@
|
||||
"""
|
||||
Utilidades de seguridad y autenticación con Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .config import settings
|
||||
from .database import get_core_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens)
|
||||
token_cache = TTLCache(maxsize=1000, ttl=60)
|
||||
|
||||
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
||||
_synced_tenant_ids: Set[int] = set()
|
||||
|
||||
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
||||
# (mismo slug, diferente id).
|
||||
_tenant_id_aliases: Dict[int, int] = {}
|
||||
# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub.
|
||||
_tenant_id_hub_by_local: Dict[int, int] = {}
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
def get_active_system(request: Request) -> Optional[str]:
|
||||
"""Sistema activo: header ``X-Active-System`` o cookie ``active_system``."""
|
||||
return request.headers.get("x-active-system") or request.cookies.get("active_system") or None
|
||||
|
||||
|
||||
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Verifica un token JWT llamando al Hub central.
|
||||
Si DEV_LOCAL_AUTH=True y el token es HS256 local, lo verifica sin Hub.
|
||||
"""
|
||||
cache_key = (token, tenant_id_override)
|
||||
if cache_key in token_cache:
|
||||
return token_cache[cache_key]
|
||||
|
||||
# Shortcut para tokens de desarrollo local
|
||||
if settings.DEV_LOCAL_AUTH:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") == "HS256":
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("dev_local"):
|
||||
token_cache[cache_key] = payload
|
||||
return payload
|
||||
except JWTError as e:
|
||||
raise HTTPException(status_code=401, detail=f"Dev token inválido: {e}")
|
||||
|
||||
try:
|
||||
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||
if tenant_id_override:
|
||||
headers["X-Tenant-Override"] = tenant_id_override
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/me",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
token_cache[cache_key] = user_info
|
||||
return user_info
|
||||
|
||||
logger.error(f"Hub token verification failed with status {response.status_code}")
|
||||
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Hub unreachable or error during token verification: {str(e)}")
|
||||
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token verification: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Authentication error")
|
||||
|
||||
|
||||
def _ensure_user_tenant_for_company(
|
||||
db: Session, keycloak_user_id: str, tenant_id: int, company_id: int
|
||||
) -> None:
|
||||
"""Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant)."""
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
existing = (
|
||||
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
|
||||
db.commit()
|
||||
return
|
||||
db.add(
|
||||
UserTenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_company_exists(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_name: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
STUB — implementa este método con el modelo de compañía de tu proyecto.
|
||||
|
||||
Debe garantizar que exista al menos una empresa para el tenant y que el
|
||||
usuario del token (hub_user["sub"]) tenga un registro en user_tenants.
|
||||
"""
|
||||
logger.debug(
|
||||
"_ensure_company_exists: no implementado en la plantilla (tenant_id=%s)", tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _repair_user_company_link_if_needed(
|
||||
db: Session,
|
||||
tenant_id_effective: int,
|
||||
hub_user: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""STUB — implementa con el modelo de compañía de tu proyecto."""
|
||||
if not hub_user or not hub_user.get("sub"):
|
||||
return
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
# Sin modelo de compañía en la plantilla, no hay empresa que verificar.
|
||||
# Implementa esta función cuando definas tu tabla de compañías.
|
||||
|
||||
|
||||
def _ensure_tenant_synced(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_slug: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Garantiza que el tenant del Hub exista en core.tenants local.
|
||||
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
|
||||
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
||||
"""
|
||||
if tenant_id in _synced_tenant_ids:
|
||||
effective = int(_tenant_id_aliases.get(tenant_id, tenant_id))
|
||||
_repair_user_company_link_if_needed(db, effective, hub_user)
|
||||
return effective
|
||||
|
||||
try:
|
||||
# Importación local para evitar imports circulares
|
||||
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
||||
|
||||
name = " ".join(word.capitalize() for word in tenant_slug.replace("-", " ").split())
|
||||
|
||||
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if existing:
|
||||
# Update name/slug/keycloak_realm if they differ (Hub is source of truth)
|
||||
if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug:
|
||||
existing.slug = tenant_slug
|
||||
existing.name = name
|
||||
existing.keycloak_realm = tenant_slug
|
||||
db.commit()
|
||||
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
# Garantizar empresa aunque el tenant ya existiera
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
# Crear el tenant local con los datos disponibles del token.
|
||||
# El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug.
|
||||
tenant = Tenant(
|
||||
id=tenant_id,
|
||||
name=name,
|
||||
slug=tenant_slug,
|
||||
type=TenantType.SHARED,
|
||||
keycloak_realm=tenant_slug,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
||||
# Crear la empresa correspondiente al tenant recién sincronizado
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
except IntegrityError:
|
||||
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
||||
db.rollback()
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
# Si el slug ya existe con diferente id, el tenant real del Hub no está registrado aún.
|
||||
# Logueamos el conflicto para depuración; el sistema continuará con tenant_id vacío.
|
||||
stale = db.query(Tenant).filter(Tenant.slug == tenant_slug).first()
|
||||
if stale and stale.id != tenant_id:
|
||||
logger.error(
|
||||
f"Conflicto de tenant: JWT dice id={tenant_id} slug='{tenant_slug}', "
|
||||
f"pero core.tenants tiene id={stale.id} mismo slug. "
|
||||
f"Elimine el registro obsoleto con: "
|
||||
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
||||
)
|
||||
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
||||
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
||||
_tenant_id_aliases[tenant_id] = int(stale.id)
|
||||
_tenant_id_hub_by_local[int(stale.id)] = int(tenant_id)
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user)
|
||||
return int(stale.id)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
return tenant_id
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: Session = Depends(get_core_db),
|
||||
request: Request = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Dependency para obtener el usuario actual desde el token JWT.
|
||||
Auto-sincroniza el tenant en core.tenants si fue creado en el Hub
|
||||
pero aún no existe en la BD local.
|
||||
|
||||
Uso en FastAPI:
|
||||
current_user: dict = Depends(get_current_user)
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server
|
||||
# desde la cookie sso_tenant_id, flujo SSO relay multi-tenant)
|
||||
tenant_override = request.headers.get('X-Tenant-Override') if request else None
|
||||
|
||||
logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}")
|
||||
|
||||
user_info = await verify_token(token, tenant_id_override=tenant_override)
|
||||
# Copia local para poder normalizar tenant_id sin mutar el objeto cacheado
|
||||
user_info = dict(user_info)
|
||||
|
||||
# Modo local: el token ya trae todo. Saltar sincronización con el Hub.
|
||||
if settings.DEV_LOCAL_AUTH and user_info.get("dev_local"):
|
||||
return user_info
|
||||
|
||||
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if tenant_id and tenant_slug:
|
||||
effective_tenant_id = _ensure_tenant_synced(
|
||||
db, int(tenant_id), str(tenant_slug), hub_user=user_info
|
||||
)
|
||||
if effective_tenant_id != int(tenant_id):
|
||||
logger.warning(
|
||||
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
||||
)
|
||||
user_info["tenant_id"] = effective_tenant_id
|
||||
|
||||
# Rehidratación de sesión: sincronización no bloqueante de avatar/perfil
|
||||
# con cache corto para evitar llamadas excesivas al Hub.
|
||||
try:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
db,
|
||||
access_token=token,
|
||||
keycloak_user_id=user_info.get("sub"),
|
||||
tenant_id=user_info.get("tenant_id"),
|
||||
workspace_profile=user_info,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc)
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Dependency para obtener usuario activo (puede incluir validaciones adicionales)
|
||||
"""
|
||||
# Aquí se pueden agregar validaciones adicionales
|
||||
# Por ejemplo, verificar si el usuario está activo en la BD
|
||||
return current_user
|
||||
|
||||
|
||||
def has_role(required_role: str):
|
||||
"""
|
||||
Decorator/Dependency para verificar roles de usuario
|
||||
|
||||
Uso:
|
||||
@router.get("/admin")
|
||||
async def admin_endpoint(user = Depends(has_role("admin"))):
|
||||
...
|
||||
"""
|
||||
|
||||
async def role_checker(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
user_roles = collect_user_role_names(current_user)
|
||||
|
||||
if required_role not in user_roles:
|
||||
logger.warning(
|
||||
"Role denied. Required: %s. User has: %s",
|
||||
required_role,
|
||||
sorted(user_roles),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"User does not have required role: {required_role}",
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
|
||||
"""
|
||||
Extrae el tenant_id del token JWT
|
||||
|
||||
El tenant_id puede estar en diferentes lugares según configuración de Keycloak:
|
||||
- En claims personalizados
|
||||
- En el realm
|
||||
- En atributos del usuario
|
||||
"""
|
||||
# Intentar obtener de claims personalizados
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
if not tenant_id:
|
||||
# Intentar obtener de atributos
|
||||
tenant_id = user_info.get("attributes", {}).get("tenant_id")
|
||||
|
||||
if tenant_id:
|
||||
return int(tenant_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_hub_tenant_id_for_api(
|
||||
local_tenant_id: Optional[int], x_tenant_override: Optional[str]
|
||||
) -> int:
|
||||
"""
|
||||
ID de tenant en Hub (client_tenants) para llamadas a la API del Hub.
|
||||
|
||||
Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local,
|
||||
usa el mapeo inverso registrado en _ensure_tenant_synced.
|
||||
"""
|
||||
if x_tenant_override and str(x_tenant_override).strip().isdigit():
|
||||
return int(str(x_tenant_override).strip())
|
||||
if local_tenant_id is None:
|
||||
return 0
|
||||
lid = int(local_tenant_id)
|
||||
return int(_tenant_id_hub_by_local.get(lid, lid))
|
||||
|
||||
|
||||
def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optional[int]:
|
||||
"""
|
||||
tenant_id efectivo del usuario: claims del token vía get_tenant_from_token,
|
||||
luego fallback a ``tenant_id`` plano del Hub (puede venir como lista).
|
||||
|
||||
Contrato Hub: no es obligatorio que todo usuario tenga ``tenant_id`` en /auth/me;
|
||||
el acceso por compañía puede basarse solo en RBAC local (ver ``user_has_app_company_membership``).
|
||||
"""
|
||||
tid = get_tenant_from_token(current_user)
|
||||
if tid is not None:
|
||||
return int(tid)
|
||||
raw = current_user.get("tenant_id")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, list) and raw:
|
||||
raw = raw[0]
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_hub_admin(current_user: Dict[str, Any]) -> bool:
|
||||
"""True si el Hub atestigua que el usuario es hub_admin (super-admin global)."""
|
||||
return bool(current_user.get("is_hub_admin"))
|
||||
|
||||
|
||||
def _has_local_super_admin_role(
|
||||
db: "Session", user_id: Optional[str], company_id: Optional[int]
|
||||
) -> bool:
|
||||
"""
|
||||
True si el usuario tiene el rol local ``super_admin`` activo en la compañía.
|
||||
|
||||
Sustituye al antiguo bypass por rol ``admin`` del realm Keycloak para
|
||||
autorización: la fuente de verdad es la BD local (``core.user_company_roles``
|
||||
+ ``core.company_roles``), no claims del JWT. La promoción automática de
|
||||
admins de Keycloak a ``super_admin`` local sigue ocurriendo en el endpoint
|
||||
``/permissions/me`` (bootstrap), por lo que un admin del realm que entre
|
||||
al sistema sigue obteniendo el bypass sin coordinación manual.
|
||||
"""
|
||||
if not user_id or not company_id:
|
||||
return False
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import (
|
||||
CompanyRole,
|
||||
UserCompanyRole,
|
||||
)
|
||||
|
||||
return (
|
||||
db.query(UserCompanyRole)
|
||||
.join(CompanyRole, CompanyRole.id == UserCompanyRole.company_role_id)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.is_active == True,
|
||||
CompanyRole.code == "super_admin",
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
except Exception as exc:
|
||||
# Un fallo de BD aquí no debe escalar a acceso silenciosamente:
|
||||
# se loguea y se trata como "no es super_admin" (deniega bypass).
|
||||
logger.warning(
|
||||
"has_local_super_admin_role_failed",
|
||||
extra={
|
||||
"op": "has_local_super_admin_role",
|
||||
"user_id": user_id,
|
||||
"company_id": company_id,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def resolve_tenant_id_required(
|
||||
current_user: Dict[str, Any],
|
||||
db: Optional["Session"] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Retorna el tenant_id efectivo o lanza 400.
|
||||
Hub admin sin tenant_id en token: resuelve desde la empresa si company_id está disponible,
|
||||
o retorna None como sentinel de acceso global (sin filtro de tenant).
|
||||
"""
|
||||
tid = get_tenant_from_token(current_user)
|
||||
if tid is not None:
|
||||
return int(tid)
|
||||
raw = current_user.get("tenant_id")
|
||||
if isinstance(raw, list) and raw:
|
||||
raw = raw[0]
|
||||
if raw is not None:
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID in token")
|
||||
|
||||
if is_hub_admin(current_user):
|
||||
# Sin modelo de compañía en la plantilla → acceso global sin filtro de tenant.
|
||||
# Implementa la consulta a tu tabla de compañías si necesitas resolución exacta.
|
||||
return None
|
||||
|
||||
raise HTTPException(status_code=400, detail="Tenant ID not found in user data")
|
||||
|
||||
|
||||
def user_has_app_company_membership(
|
||||
db: Session, user_id: str, company_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
True si el usuario tiene fila activa en RBAC de la app o en core.user_tenants
|
||||
para esa compañía (independiente del tenant en el JWT).
|
||||
"""
|
||||
if not user_id:
|
||||
return False
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import (
|
||||
UserCompanyPermission,
|
||||
UserCompanyRole,
|
||||
)
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
if (
|
||||
db.query(UserCompanyRole)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return True
|
||||
if (
|
||||
db.query(UserCompanyPermission)
|
||||
.filter(
|
||||
UserCompanyPermission.user_id == user_id,
|
||||
UserCompanyPermission.company_id == company_id,
|
||||
UserCompanyPermission.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return True
|
||||
if (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.company_id == company_id,
|
||||
UserTenant.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error checking app company membership: %s", e)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def collect_company_ids_from_app_membership(
|
||||
db: Session, user_id: str
|
||||
) -> Set[int]:
|
||||
"""IDs de compañía donde el usuario tiene rol, permiso directo o user_tenants."""
|
||||
ids: Set[int] = set()
|
||||
if not user_id:
|
||||
return ids
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import (
|
||||
UserCompanyPermission,
|
||||
UserCompanyRole,
|
||||
)
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
for (cid,) in (
|
||||
db.query(UserCompanyRole.company_id)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.is_active == True, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
):
|
||||
ids.add(int(cid))
|
||||
for (cid,) in (
|
||||
db.query(UserCompanyPermission.company_id)
|
||||
.filter(
|
||||
UserCompanyPermission.user_id == user_id,
|
||||
UserCompanyPermission.is_active == True, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
):
|
||||
ids.add(int(cid))
|
||||
for (cid,) in (
|
||||
db.query(UserTenant.company_id)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.is_active == True, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
):
|
||||
ids.add(int(cid))
|
||||
except Exception as e:
|
||||
logger.error("Error collecting company ids from membership: %s", e)
|
||||
return ids
|
||||
|
||||
|
||||
def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]:
|
||||
"""
|
||||
Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me
|
||||
vía verify_token). Si no hay lista no vacía, se unen realm_access y
|
||||
resource_access del JWT Keycloak clásico.
|
||||
"""
|
||||
names: Set[str] = set()
|
||||
hub_roles = current_user.get("roles")
|
||||
if isinstance(hub_roles, list):
|
||||
names.update(str(r) for r in hub_roles if r is not None)
|
||||
|
||||
if names:
|
||||
return names
|
||||
|
||||
realm = current_user.get("realm_access")
|
||||
if isinstance(realm, dict):
|
||||
names.update(str(r) for r in (realm.get("roles") or []) if r is not None)
|
||||
for client in (current_user.get("resource_access") or {}).values():
|
||||
if isinstance(client, dict):
|
||||
names.update(str(r) for r in (client.get("roles") or []) if r is not None)
|
||||
return names
|
||||
|
||||
|
||||
def validate_company_access(
|
||||
db: Session, company_id: int, current_user: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Valida acceso a la compañía: (1) tenant del token/Hub alineado con la empresa, o
|
||||
(2) membership en la app (RBAC / user_tenants) para ese ``company_id``.
|
||||
|
||||
El contrato con el Hub puede no incluir ``tenant_id`` para todos los usuarios;
|
||||
en ese caso el acceso se basa en asignaciones en PostgreSQL.
|
||||
"""
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if user_id and user_has_app_company_membership(db, str(user_id), company_id):
|
||||
return True
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
# Sin modelo de compañía en la plantilla → delega solo en user_has_app_company_membership.
|
||||
# Implementa la consulta a tu tabla de compañías para validación estricta.
|
||||
return True
|
||||
|
||||
|
||||
def validate_access_to_resource(
|
||||
db: Session,
|
||||
company_id: int,
|
||||
current_user: Dict[str, Any],
|
||||
required_permissions: Optional[list[str]] = None,
|
||||
require_all: bool = True,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Valida que el usuario tenga acceso a un recurso específico basado en company_id
|
||||
y regresa el tenant_id. Opcionalmente verifica permisos.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
company_id: company_id asociado al recurso
|
||||
current_user: Información del usuario actual desde el token
|
||||
required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos.
|
||||
require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO.
|
||||
|
||||
Returns:
|
||||
tenant_id si el usuario tiene acceso
|
||||
|
||||
Raises:
|
||||
HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos
|
||||
"""
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
|
||||
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
|
||||
# Se reemplazó el antiguo "admin" in realm_access.roles para que la
|
||||
# autorización deje de depender de claims del JWT.
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
is_global_admin = is_hub_admin(current_user) or _has_local_super_admin_role(
|
||||
db, user_id, company_id
|
||||
)
|
||||
|
||||
# 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap
|
||||
# Detectamos si no se requieren permisos (típico de /me)
|
||||
is_me_endpoint = required_permissions is None
|
||||
|
||||
if not is_global_admin and not is_me_endpoint:
|
||||
if not validate_company_access(db, company_id, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied to this company")
|
||||
|
||||
# Sin modelo de compañía en la plantilla no se puede resolver tenant_id desde company.
|
||||
# Implementa esta lógica cuando definas tu tabla de compañías.
|
||||
|
||||
# Si aún no hay tenant_id y no es admin, error 400
|
||||
if not tenant_id and not is_global_admin and not is_me_endpoint:
|
||||
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
|
||||
|
||||
# Verificar permisos locales
|
||||
if required_permissions:
|
||||
if is_global_admin:
|
||||
# hub_admin / super_admin local: siempre debe tener tenant_id resuelto
|
||||
# cuando se exigen permisos; retornar 1 silenciosamente sería acceso
|
||||
# al tenant equivocado.
|
||||
if tenant_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se pudo resolver el tenant_id para la empresa especificada",
|
||||
)
|
||||
return int(tenant_id)
|
||||
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
permission_service = PermissionService(db)
|
||||
|
||||
has_access = False
|
||||
if require_all:
|
||||
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
||||
|
||||
# 🛡️ MEJORA DEV: Auto-bootstrap si falla el acceso en desarrollo
|
||||
if not has_access and settings.ENVIRONMENT == "development":
|
||||
try:
|
||||
# Si el usuario no tiene roles asignados, intentamos el bootstrap
|
||||
# bootstrap_super_admin solo asigna el rol si no tiene ninguno (o es admin)
|
||||
permission_service.bootstrap_super_admin(user_id, company_id)
|
||||
# Re-validar
|
||||
if require_all:
|
||||
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
||||
|
||||
if has_access:
|
||||
logger.info(
|
||||
"Auto-bootstrap exitoso para user_id=%s company_id=%s", user_id, company_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error en auto-bootstrap de seguridad user_id=%s company_id=%s: %s",
|
||||
user_id, company_id, e,
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# Nunca sustituir tenant_id=None/0 silenciosamente — un valor inválido aquí
|
||||
# significaría acceso al tenant equivocado. Si llegamos aquí sin tenant_id
|
||||
# válido para un usuario no-admin, es un estado inconsistente que debe fallar.
|
||||
if not isinstance(tenant_id, int) or tenant_id <= 0:
|
||||
if not is_global_admin:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se pudo determinar el tenant_id para la empresa especificada",
|
||||
)
|
||||
return tenant_id # puede ser None solo para hub_admin sin required_permissions (acceso global)
|
||||
226
backend/core/storage_s3.py
Normal file
226
backend/core/storage_s3.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Cliente S3 (MinIO): bucket, objetos genéricos, presign, imports CSV.
|
||||
|
||||
Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_key`` vía
|
||||
``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from core.config import settings
|
||||
from core.s3_keys import csv_import_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _client():
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.S3_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.S3_SECRET_KEY,
|
||||
region_name=settings.S3_REGION,
|
||||
use_ssl=settings.S3_USE_SSL,
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def should_ensure_s3_bucket() -> bool:
|
||||
return settings.use_s3_object_storage
|
||||
|
||||
|
||||
def ensure_s3_bucket() -> None:
|
||||
"""Crea el bucket si no existe (idempotente)."""
|
||||
if not should_ensure_s3_bucket():
|
||||
return
|
||||
bucket = settings.S3_BUCKET
|
||||
client = _client()
|
||||
try:
|
||||
client.head_bucket(Bucket=bucket)
|
||||
logger.info("S3 bucket %s exists", bucket)
|
||||
return
|
||||
except ClientError as e:
|
||||
code = e.response.get("Error", {}).get("Code", "")
|
||||
if code not in ("404", "NoSuchBucket", "403"):
|
||||
logger.warning("head_bucket %s: %s", bucket, e)
|
||||
try:
|
||||
if settings.S3_REGION == "us-east-1":
|
||||
client.create_bucket(Bucket=bucket)
|
||||
else:
|
||||
client.create_bucket(
|
||||
Bucket=bucket,
|
||||
CreateBucketConfiguration={"LocationConstraint": settings.S3_REGION},
|
||||
)
|
||||
logger.info("S3 bucket %s created", bucket)
|
||||
except ClientError as e:
|
||||
logger.error("create_bucket %s failed: %s", bucket, e)
|
||||
raise
|
||||
|
||||
|
||||
# Alias para código existente
|
||||
def ensure_csv_import_bucket() -> None:
|
||||
ensure_s3_bucket()
|
||||
|
||||
|
||||
def put_object_bytes(key: str, body: bytes, content_type: str = "application/octet-stream") -> None:
|
||||
_client().put_object(
|
||||
Bucket=settings.S3_BUCKET,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
|
||||
def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> None:
|
||||
put_object_bytes(key, body, content_type=content_type)
|
||||
|
||||
|
||||
def get_object_bytes(key: str) -> bytes:
|
||||
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
return resp["Body"].read()
|
||||
|
||||
|
||||
def delete_object_if_exists(key: str) -> None:
|
||||
try:
|
||||
_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
except ClientError as e:
|
||||
logger.warning("delete_object %s: %s", key, e)
|
||||
|
||||
|
||||
def delete_objects_with_prefix(prefix: str, batch_size: int = 1000) -> None:
|
||||
"""
|
||||
Elimina en cascada todos los objetos cuyo Key empieza con `prefix`.
|
||||
|
||||
Pensado para limpiar recursos ligados a una entidad (por ejemplo,
|
||||
todos los objetos de una compañía bajo `tenants/{tid}/companies/{cid}/`).
|
||||
"""
|
||||
if not settings.use_s3_object_storage:
|
||||
return
|
||||
|
||||
client = _client()
|
||||
continuation_token: Optional[str] = None
|
||||
|
||||
while True:
|
||||
params: Dict[str, Any] = {
|
||||
"Bucket": settings.S3_BUCKET,
|
||||
"Prefix": prefix,
|
||||
"MaxKeys": max(1, min(int(batch_size), 1000)),
|
||||
}
|
||||
if continuation_token:
|
||||
params["ContinuationToken"] = continuation_token
|
||||
|
||||
try:
|
||||
resp = client.list_objects_v2(**params)
|
||||
except ClientError as e:
|
||||
logger.warning("list_objects_v2 for prefix %s failed: %s", prefix, e)
|
||||
break
|
||||
|
||||
contents = resp.get("Contents") or []
|
||||
if not contents:
|
||||
break
|
||||
|
||||
to_delete = [{"Key": obj.get("Key")} for obj in contents if obj.get("Key")]
|
||||
if to_delete:
|
||||
try:
|
||||
client.delete_objects(
|
||||
Bucket=settings.S3_BUCKET,
|
||||
Delete={"Objects": to_delete, "Quiet": True},
|
||||
)
|
||||
except ClientError as e:
|
||||
logger.warning(
|
||||
"delete_objects_with_prefix %s (batch_size=%s) failed: %s",
|
||||
prefix,
|
||||
len(to_delete),
|
||||
e,
|
||||
)
|
||||
|
||||
if not resp.get("IsTruncated"):
|
||||
break
|
||||
|
||||
continuation_token = resp.get("NextContinuationToken")
|
||||
|
||||
|
||||
def object_exists(key: str) -> bool:
|
||||
try:
|
||||
_client().head_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
|
||||
def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str:
|
||||
sec = expires_in if expires_in is not None else settings.S3_PRESIGNED_EXPIRES_SECONDS
|
||||
return _client().generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.S3_BUCKET, "Key": key},
|
||||
ExpiresIn=sec,
|
||||
)
|
||||
|
||||
|
||||
def list_objects_tree(
|
||||
prefix: str,
|
||||
delimiter: str = "/",
|
||||
max_keys: int = 100,
|
||||
continuation_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Lista objetos/prefijos como árbol virtual.
|
||||
|
||||
Retorna:
|
||||
- ``prefixes``: subcarpetas (CommonPrefixes)
|
||||
- ``objects``: objetos directos bajo ``prefix``
|
||||
- ``next_continuation_token`` y ``is_truncated`` para paginación
|
||||
"""
|
||||
params: Dict[str, Any] = {
|
||||
"Bucket": settings.S3_BUCKET,
|
||||
"Prefix": prefix,
|
||||
"Delimiter": delimiter,
|
||||
"MaxKeys": max(1, min(int(max_keys), 500)),
|
||||
}
|
||||
if continuation_token:
|
||||
params["ContinuationToken"] = continuation_token
|
||||
|
||||
resp = _client().list_objects_v2(**params)
|
||||
common_prefixes: List[str] = [
|
||||
p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix")
|
||||
]
|
||||
objects: List[Dict[str, Any]] = []
|
||||
for obj in resp.get("Contents") or []:
|
||||
key = obj.get("Key")
|
||||
if not key:
|
||||
continue
|
||||
if key == prefix:
|
||||
# Marcador de carpeta (objeto vacío con mismo nombre del prefijo).
|
||||
continue
|
||||
objects.append(
|
||||
{
|
||||
"key": key,
|
||||
"size": int(obj.get("Size", 0) or 0),
|
||||
"last_modified": obj.get("LastModified"),
|
||||
"etag": obj.get("ETag"),
|
||||
"storage_class": obj.get("StorageClass"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"prefixes": common_prefixes,
|
||||
"objects": objects,
|
||||
"next_continuation_token": resp.get("NextContinuationToken"),
|
||||
"is_truncated": bool(resp.get("IsTruncated")),
|
||||
}
|
||||
|
||||
|
||||
def s3_key_for_csv_import(
|
||||
tenant_id,
|
||||
company_id: int,
|
||||
job_type: str,
|
||||
job_id: str,
|
||||
) -> str:
|
||||
return csv_import_key(tenant_id, company_id, job_type, job_id)
|
||||
80
backend/core/workspace_profile_client.py
Normal file
80
backend/core/workspace_profile_client.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkspaceProfileClient:
|
||||
"""Cliente para consultar perfil del usuario en Workspace Hub (/v1/auth/me)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
retries: int = 2,
|
||||
transport: Optional[httpx.BaseTransport] = None,
|
||||
):
|
||||
self.base_url = (base_url or settings.hub_api_base_url).rstrip("/")
|
||||
self.timeout_s = max(0.1, float(timeout_ms or settings.HUB_PROFILE_SYNC_TIMEOUT_MS) / 1000.0)
|
||||
self.retries = max(0, int(retries))
|
||||
self.transport = transport
|
||||
|
||||
async def get_me(self, access_token: str) -> dict[str, Any]:
|
||||
if not access_token:
|
||||
raise ValueError("access_token is required")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
url = f"{self.base_url}/v1/auth/me"
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout_s,
|
||||
transport=self.transport,
|
||||
) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid workspace profile payload")
|
||||
return payload
|
||||
|
||||
if response.status_code in (401, 403, 404):
|
||||
# Errores de autenticación/autorización o endpoint no disponible:
|
||||
# no vale la pena reintentar.
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Workspace profile request failed with status {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
# Reintentar solo para errores transitorios 5xx.
|
||||
if response.status_code >= 500 and attempt < self.retries:
|
||||
await asyncio.sleep(0.15 * (attempt + 1))
|
||||
continue
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Workspace profile request failed with status {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
last_error = exc
|
||||
if attempt >= self.retries:
|
||||
break
|
||||
await asyncio.sleep(0.15 * (attempt + 1))
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
break
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise RuntimeError("Workspace profile request failed")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user