chore: baseline plantilla-proyectos como base del CRM

This commit is contained in:
Aduanasoft
2026-07-14 09:03:52 -06:00
commit c3d0eedc8d
469 changed files with 69739 additions and 0 deletions

39
backend/.env.example Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
Generic single-database configuration.

363
backend/alembic/env.py Normal file
View 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()

View 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"}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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")

View 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")

View File

@@ -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")

View 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
View File

View File

View 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)

View 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)

View 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

View 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")

View 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

View File

View File

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

View 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")

View 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 []

View 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()

View File

@@ -0,0 +1,7 @@
"""
Módulo de dashboard para estadísticas y métricas empresariales
"""
from .routes import router
__all__ = ["router"]

View 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"
)

View 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={},
)

View 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={}
)

View 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}')>"

View 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

View 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

View 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.")

View 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()

View 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)

View 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

View 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
)

View 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,
)

View 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,
)

View 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

View 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)

View 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,
)

View 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)

View File

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

View 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

View 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})>"

View 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

View 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),
)

View 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")
```

View 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",
]

View 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)

View 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()

View 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)

View 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"
)

View 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()

File diff suppressed because it is too large Load Diff

View 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"
)

View 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

View 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()

View 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"])

View File

@@ -0,0 +1,4 @@
from .dispatch import track_and_dispatch
from .service import TaskTrackerService
__all__ = ["track_and_dispatch", "TaskTrackerService"]

View 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

View 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()
)

View 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]],
)

View 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]

View 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

View File

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

View 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

View 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})>"

View 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

View 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")

View 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]

View 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")

View 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,
}

View 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

View File

@@ -0,0 +1,3 @@
"""
Módulo de gestión de usuarios (Keycloak)
"""

View 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

View 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"}

View 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)

View 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

View 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)

View 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)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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