chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
113
.env.example
Normal file
113
.env.example
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# ==================================
|
||||||
|
# ANEXO76 - Variables de Entorno
|
||||||
|
# ==================================
|
||||||
|
|
||||||
|
# ----- PostgreSQL App -----
|
||||||
|
POSTGRES_APP_PASSWORD=postgres
|
||||||
|
|
||||||
|
# ----- PostgreSQL Keycloak -----
|
||||||
|
POSTGRES_KEYCLOAK_PASSWORD=postgres
|
||||||
|
|
||||||
|
# ----- Keycloak Admin -----
|
||||||
|
KEYCLOAK_ADMIN=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=admin
|
||||||
|
|
||||||
|
# ----- Keycloak Configuración -----
|
||||||
|
KEYCLOAK_REALM=master
|
||||||
|
KEYCLOAK_CLIENT_ID=app-backend
|
||||||
|
KEYCLOAK_CLIENT_SECRET=dev-secret
|
||||||
|
KEYCLOAK_FRONTEND_CLIENT_ID=app-frontend
|
||||||
|
|
||||||
|
# ----- Backend -----
|
||||||
|
DEBUG=True
|
||||||
|
ENVIRONMENT=development
|
||||||
|
CORE_DB_HOST=postgres-a76
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_NAME=anexo76_core
|
||||||
|
CORE_DB_USER=postgres
|
||||||
|
CORE_DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# Lista de orígenes permitidos (CORS)
|
||||||
|
# Ejemplo para Hub: http://localhost:5173,http://100.78.6.108:5174,http://100.78.6.108:8001
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ----- Frontend -----
|
||||||
|
NODE_ENV=development
|
||||||
|
VITE_API_URL=http://localhost:8000/api
|
||||||
|
INTERNAL_API_URL=http://backend:8000/api
|
||||||
|
|
||||||
|
# ⚠️ ADVERTENCIA PRODUCCIÓN — ORIGIN, VITE_HUB_URL y APP_PUBLIC_URL
|
||||||
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Docker Compose carga ESTE archivo (.env raíz) automáticamente cuando
|
||||||
|
# se ejecuta: docker compose -f docker-compose.prod.yml up
|
||||||
|
#
|
||||||
|
# Si estas variables tienen valores localhost aquí, PISARÁN los defaults
|
||||||
|
# de docker-compose.prod.yml y causarán que el login falle en producción
|
||||||
|
# (redirect_uri y KC URL apuntarán a localhost).
|
||||||
|
#
|
||||||
|
# Para dev local (docker-compose.yml): dejar localhost.
|
||||||
|
# Para producción (docker-compose.prod.yml): asegurarse que el servidor
|
||||||
|
# NO tenga este .env raíz con valores localhost, O usar:
|
||||||
|
# docker compose --env-file .env.prod -f docker-compose.prod.yml up
|
||||||
|
#
|
||||||
|
# Variables críticas para producción:
|
||||||
|
# ORIGIN=https://anexo76-dev.aduanasoft.com ← determina url.origin en SvelteKit
|
||||||
|
# VITE_HUB_URL=https://workspace.aduanasoft.com
|
||||||
|
# APP_PUBLIC_URL=https://anexo76-dev.aduanasoft.com
|
||||||
|
ORIGIN=http://localhost:5173
|
||||||
|
VITE_HUB_URL=http://localhost:3001
|
||||||
|
APP_PUBLIC_URL=http://localhost:5173
|
||||||
|
VITE_KEYCLOAK_REALM=master
|
||||||
|
VITE_KEYCLOAK_URL=http://localhost:8080/kcauth
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=app-frontend
|
||||||
|
|
||||||
|
#------ Celery / Valkey ----------
|
||||||
|
VALKEY_URL=redis://valkey:6379/0
|
||||||
|
PERMISSION_CACHE_ENABLED=true
|
||||||
|
PERMISSION_CACHE_TTL_SECONDS=300
|
||||||
|
|
||||||
|
# ----- MinIO (S3-compatible) -----
|
||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
|
MINIO_API_PORT=9100
|
||||||
|
MINIO_CONSOLE_PORT=9101
|
||||||
|
|
||||||
|
# ----- Imports CSV (layouts_csv): redis | minio -----
|
||||||
|
CSV_IMPORT_STORAGE=minio
|
||||||
|
S3_ENDPOINT_URL=http://minio:9000
|
||||||
|
S3_ACCESS_KEY=minioadmin
|
||||||
|
S3_SECRET_KEY=minioadmin
|
||||||
|
S3_BUCKET=anexo76
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_USE_SSL=false
|
||||||
|
# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí
|
||||||
|
# (use_s3_object_storage = minio CSV o S3_FILE_STORAGE=true).
|
||||||
|
S3_FILE_STORAGE=true
|
||||||
|
S3_PRESIGNED_EXPIRES_SECONDS=3600
|
||||||
|
|
||||||
|
COVE_FIEL_HASH_KEY=
|
||||||
|
COVE_FIEL_HASH_IV=
|
||||||
|
COVE_API_URL=https://api.vu.aduanasoft.com
|
||||||
|
COVE_API_VERIFY_SSL=False
|
||||||
|
|
||||||
|
# ----- Sitar API -----
|
||||||
|
SITAR_API_URL=http://api.sitar.aduanasoft.com
|
||||||
|
SITAR_API_USER=user_sitar_api
|
||||||
|
SITAR_API_PASSWORD=password123
|
||||||
|
|
||||||
|
# ==================================
|
||||||
|
# CONFIGURACIÓN DE SINCRONIZACIÓN
|
||||||
|
# ==================================
|
||||||
|
# Hub IP: 100.78.6.108
|
||||||
|
|
||||||
|
# URL del Hub para sincronización (Solo si es CLIENTE)
|
||||||
|
# Ejemplo: http://100.78.6.108:8001/api/v1/core/help-center/sync/
|
||||||
|
CENTRAL_SERVER_URL=
|
||||||
|
|
||||||
|
# UUID único de este cliente (Opcional, se genera uno si está vacío)
|
||||||
|
|
||||||
|
# Token de seguridad compartido (Debe ser IDÉNTICO en Hub y Clientes)
|
||||||
|
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||||
|
|
||||||
|
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||||
|
SPOKE_URLS=""
|
||||||
203
.gitea/workflows/build.yml
Normal file
203
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
name: Build Producción & Push a Harbor
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- development
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Desplegar dev'
|
||||||
|
required: true
|
||||||
|
default: 'development'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- development
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Ejecuta la suite de tests del backend antes de construir imágenes.
|
||||||
|
# Si falla, no se ejecuta build (ni push ni deploy).
|
||||||
|
test:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout código
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# No usamos actions/setup-python: en runners self-hosted (Debian) suele fallar
|
||||||
|
# al descargar versiones desde el manifest. Se usa python3 del sistema + venv.
|
||||||
|
# En Debian/Ubuntu hace falta el paquete python3-venv (ensurepip); si falla el venv,
|
||||||
|
# se intenta instalar con apt (root en act/containers, sudo en servidor típico).
|
||||||
|
- name: Instalar dependencias y ejecutar tests del backend
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
if [ -z "$TEST_DATABASE_URL" ]; then
|
||||||
|
echo "::error::Define el secret TEST_DATABASE_URL en el repo (Gitea → Ajustes → Secretos)."
|
||||||
|
echo "Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd (host real, no el texto \"host\")"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 --version
|
||||||
|
VENV="$GITHUB_WORKSPACE/.pytest-venv"
|
||||||
|
install_python3_venv() {
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo "::error::No hay apt-get. Instala manualmente el paquete equivalente a python3-venv."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y python3-venv
|
||||||
|
else
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y python3-venv
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
if ! python3 -m venv "$VENV" 2>/dev/null; then
|
||||||
|
echo "venv falló (ensurepip no disponible). Instalando python3-venv..."
|
||||||
|
install_python3_venv
|
||||||
|
python3 -m venv "$VENV"
|
||||||
|
fi
|
||||||
|
. "$VENV/bin/activate"
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r "$GITHUB_WORKSPACE/backend/requirements.txt"
|
||||||
|
cd "$GITHUB_WORKSPACE/backend"
|
||||||
|
export TEST_DATABASE_URL="$TEST_DATABASE_URL"
|
||||||
|
alembic upgrade head
|
||||||
|
pytest -q tests -v -ra -s
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout código
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Necesario para obtener el historial completo de git
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Generar versión automática
|
||||||
|
# ------------------------
|
||||||
|
- name: Generar versión automática
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# Obtener año y mes actual
|
||||||
|
YEAR=$(date +%y)
|
||||||
|
MONTH=$(date +%m)
|
||||||
|
|
||||||
|
# Detectar rama actual usando variables de Gitea
|
||||||
|
BRANCH="${GITHUB_REF##*/}"
|
||||||
|
|
||||||
|
if [ "$BRANCH" = "development" ]; then
|
||||||
|
# Para development: YY.MM.1.<short-git-hash>
|
||||||
|
SHORT_HASH=$(git rev-parse --short=8 HEAD)
|
||||||
|
VERSION="${YEAR}.${MONTH}.${{ secrets.VERSION_MAYOR }}.${SHORT_HASH}"
|
||||||
|
elif [ "$BRANCH" = "main" ]; then
|
||||||
|
# Para main: YY.MM.0.<commit-count>
|
||||||
|
# Intentar obtener el último tag
|
||||||
|
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -z "$LAST_TAG" ]; then
|
||||||
|
# No hay tags, usar commit count total (primera ejecución)
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
echo "⚠️ No se encontraron tags. Usando commit count total: ${COMMIT_COUNT}"
|
||||||
|
else
|
||||||
|
# Hay tags, contar commits desde el último tag
|
||||||
|
COMMIT_COUNT=$(git rev-list ${LAST_TAG}..HEAD --count)
|
||||||
|
# Si es 0, significa que estamos en el mismo commit del tag, incrementar
|
||||||
|
if [ "$COMMIT_COUNT" -eq 0 ]; then
|
||||||
|
COMMIT_COUNT=1
|
||||||
|
fi
|
||||||
|
echo "📌 Último tag: ${LAST_TAG}"
|
||||||
|
echo "🔢 Commits desde el último tag: ${COMMIT_COUNT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="${YEAR}.${MONTH}.${{ secrets.VERSION_MAYOR }}.${COMMIT_COUNT}"
|
||||||
|
else
|
||||||
|
# Fallback para otras ramas
|
||||||
|
SHORT_HASH=$(git rev-parse --short=8 HEAD)
|
||||||
|
VERSION="${YEAR}.${MONTH}.99.${SHORT_HASH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Guardar versión en output para usarla en steps posteriores
|
||||||
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "BRANCH=${BRANCH}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Mostrar en logs
|
||||||
|
echo "📦 Versión generada: ${VERSION}"
|
||||||
|
echo "🌿 Rama: ${BRANCH}"
|
||||||
|
|
||||||
|
- name: Login a Harbor
|
||||||
|
run: |
|
||||||
|
echo '${{ secrets.HARBOR_PASSWORD }}' | docker login \
|
||||||
|
dev.aduanasoft.com \
|
||||||
|
-u '${{ secrets.HARBOR_USERNAME }}' \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Backend
|
||||||
|
# ------------------------
|
||||||
|
- name: Build backend
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg APP_VERSION=${{ steps.version.outputs.VERSION }} \
|
||||||
|
-t dev.aduanasoft.com/anexo76/backend:latest \
|
||||||
|
-f ./backend/Dockerfile \
|
||||||
|
./backend
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Frontend
|
||||||
|
# ------------------------
|
||||||
|
- name: Build frontend
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_API_URL=https://anexo76-dev.aduanasoft.com/api/ \
|
||||||
|
--build-arg VITE_KEYCLOAK_URL=https://anexo76-dev.aduanasoft.com/kcauth/ \
|
||||||
|
--build-arg INTERNAL_API_URL=http://backend:3467/api/ \
|
||||||
|
-t dev.aduanasoft.com/anexo76/frontend:latest \
|
||||||
|
-f ./frontend/Dockerfile.prod \
|
||||||
|
./frontend
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Push imágenes
|
||||||
|
# ------------------------
|
||||||
|
- name: Push backend
|
||||||
|
run: |
|
||||||
|
docker push dev.aduanasoft.com/anexo76/backend:latest
|
||||||
|
|
||||||
|
- name: Push frontend
|
||||||
|
run: |
|
||||||
|
docker push dev.aduanasoft.com/anexo76/frontend:latest
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Crear tag de versión (solo para main)
|
||||||
|
# ------------------------
|
||||||
|
- name: Crear tag de versión
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
VERSION=${{ steps.version.outputs.VERSION }}
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git config user.email "actions@gitea.local"
|
||||||
|
git tag -a "v${VERSION}" -m "Release version ${VERSION}"
|
||||||
|
git push origin "v${VERSION}"
|
||||||
|
echo "✅ Tag v${VERSION} creado y pusheado"
|
||||||
|
|
||||||
|
# ------------------------
|
||||||
|
# Deploy a Development
|
||||||
|
# ------------------------
|
||||||
|
- name: Deploy a servidor de desarrollo
|
||||||
|
if: github.ref == 'refs/heads/development'
|
||||||
|
run: |
|
||||||
|
eval $(ssh-agent -s)
|
||||||
|
ssh-add <(echo "${{ secrets.DEV_SERVER_SSH_KEY }}")
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new \
|
||||||
|
${{ secrets.DEV_SERVER_USER }}@${{ secrets.DEV_SERVER_HOST }} \
|
||||||
|
'cd ~/projects/anexo76 && \
|
||||||
|
echo '"'"'${{ secrets.HARBOR_PASSWORD }}'"'"' | docker login dev.aduanasoft.com -u '"'"'${{ secrets.HARBOR_USERNAME }}'"'"' --password-stdin && \
|
||||||
|
docker compose -f docker-compose.prod.yml pull && \
|
||||||
|
docker compose -f docker-compose.prod.yml up -d && \
|
||||||
|
docker image prune -f'
|
||||||
|
ssh-agent -k
|
||||||
85
.gitignore
vendored
Normal file
85
.gitignore
vendored
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
.mypy_cache/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib64/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Environment (no subir: cada quien puede usar puertos distintos vía .env)
|
||||||
|
.env
|
||||||
|
.env.e2e.generated
|
||||||
|
.env.local
|
||||||
|
backend/.env
|
||||||
|
frontend/.env
|
||||||
|
backend/SCRIPTS/
|
||||||
|
.cursor/
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
postgres_app_data/
|
||||||
|
postgres_keycloak_data/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
backend/app_data/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Node (para frontend)
|
||||||
|
**/node_modules/
|
||||||
|
**/.svelte-kit/
|
||||||
|
.npm
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.dockerignore
|
||||||
|
postgres-data/
|
||||||
|
backend/uploads/
|
||||||
|
backend/layouts/imports/
|
||||||
|
docker-compose.yml
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
backend/celerybeat-schedule
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat-schedule.*
|
||||||
|
celerybeat.pid
|
||||||
|
backend/api/v1/modules/reports/generated/
|
||||||
452
Jenkinsfile
vendored
Normal file
452
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
// Pipeline CI/CD — Aduanasoft Anexo76
|
||||||
|
// Ejecuta: tests+security (paralelo) → docker build/push (paralelo) → E2E → deploy dev → smoke test
|
||||||
|
//
|
||||||
|
// Credenciales requeridas en Jenkins:
|
||||||
|
// - harbor-credentials : username/password para dev.aduanasoft.com
|
||||||
|
// - a76-public-url-dev : Secret Text — URL pública base (ej. https://anexo76-dev.aduanasoft.com)
|
||||||
|
// - dev-server-user : Secret Text — usuario SSH del servidor dev
|
||||||
|
// - dev-server-host : Secret Text — hostname/IP del servidor dev
|
||||||
|
// - dev-server-ssh : SSH Username with Private Key — llave privada para deploy
|
||||||
|
// - a76-e2e-credentials : Username with password — usuario de prueba en Workspace
|
||||||
|
// - a76-sitar-api-url : Secret Text — URL del API SITAR (provee TC para "Consultar DOF")
|
||||||
|
// - a76-sitar-credentials : Username with password — credenciales del API SITAR
|
||||||
|
//
|
||||||
|
// El agente Jenkins solo necesita: Docker CLI
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
HARBOR_REGISTRY = 'dev.aduanasoft.com'
|
||||||
|
IMAGE_BACKEND = "${HARBOR_REGISTRY}/anexo76/backend"
|
||||||
|
IMAGE_FRONTEND = "${HARBOR_REGISTRY}/anexo76/frontend"
|
||||||
|
PYTHON_IMAGE = 'python:3.12-slim'
|
||||||
|
// Debe coincidir con @playwright/test del frontend (ver frontend/pnpm-lock.yaml)
|
||||||
|
PLAYWRIGHT_IMAGE = 'mcr.microsoft.com/playwright:v1.56.1-noble'
|
||||||
|
DEPLOY_PATH = '~/projects/anexo76'
|
||||||
|
// URL de Keycloak/Workspace — Keycloak vive en Workspace, no en el servidor de Anexo76
|
||||||
|
KC_URL = 'https://workspace.aduanasoft.com/kcauth'
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
timeout(time: 45, unit: 'MINUTES')
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
|
||||||
|
// ── Tests + Security (4 trabajos en paralelo) ─────────────────────────
|
||||||
|
// Backend tests, frontend Vitest, pip-audit y npm audit son independientes:
|
||||||
|
// todos en un solo bloque parallel — la stage tarda el máximo de los 4,
|
||||||
|
// no la suma. pip y pnpm cachean en volumes persistentes del agente
|
||||||
|
// (jenkins-a76-pip-cache, jenkins-a76-pnpm-store) para reusar deps
|
||||||
|
// entre builds (~30-60s menos cada uno cuando lockfiles no cambian).
|
||||||
|
stage('Tests & Security') {
|
||||||
|
parallel {
|
||||||
|
|
||||||
|
stage('Test — Backend') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
C="a76-test-py-${BUILD_NUMBER}"
|
||||||
|
DB_C="a76-test-db-${BUILD_NUMBER}"
|
||||||
|
NET="a76-test-net-${BUILD_NUMBER}"
|
||||||
|
DB_NAME="anexo76_test"
|
||||||
|
DB_USER="anexo76"
|
||||||
|
DB_PASS="anexo76"
|
||||||
|
TEST_DB_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_C}:5432/${DB_NAME}"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
||||||
|
docker rm -f "$DB_C" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker network rm "$NET" >/dev/null 2>&1 || true
|
||||||
|
docker network create "$NET"
|
||||||
|
|
||||||
|
docker rm -f "$DB_C" >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name "$DB_C" \
|
||||||
|
--network "$NET" \
|
||||||
|
-e POSTGRES_DB="$DB_NAME" \
|
||||||
|
-e POSTGRES_USER="$DB_USER" \
|
||||||
|
-e POSTGRES_PASSWORD="$DB_PASS" \
|
||||||
|
postgres:16-alpine
|
||||||
|
|
||||||
|
# Esperar a que Postgres esté listo
|
||||||
|
READY=0
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker exec "$DB_C" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
|
||||||
|
READY=1; break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$READY" != "1" ]; then
|
||||||
|
echo "ERROR: Postgres no quedó listo en 60s."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker exec "$DB_C" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -c "
|
||||||
|
CREATE SCHEMA IF NOT EXISTS core;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS a24;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS a76;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS public;
|
||||||
|
"
|
||||||
|
|
||||||
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
||||||
|
# Volume persistente para el cache de pip (~/.cache/pip):
|
||||||
|
# primer build lo puebla, siguientes reusan wheels si requirements.txt no cambió.
|
||||||
|
docker run -d --name "$C" --network "$NET" \
|
||||||
|
-v jenkins-a76-pip-cache:/root/.cache/pip \
|
||||||
|
"$PYTHON_IMAGE" sleep infinity
|
||||||
|
docker cp "$WORKSPACE/backend/." "$C:/app"
|
||||||
|
|
||||||
|
docker exec \
|
||||||
|
-e TEST_DATABASE_URL="$TEST_DB_URL" \
|
||||||
|
-w /app "$C" bash -lc '
|
||||||
|
set -euxo pipefail
|
||||||
|
pip install --quiet -r requirements.txt
|
||||||
|
|
||||||
|
# Prueba de migraciones: upgrade → downgrade → upgrade
|
||||||
|
# Verifica que todos los down() sean reversibles
|
||||||
|
echo "--- alembic upgrade head ---"
|
||||||
|
alembic upgrade head
|
||||||
|
echo "--- alembic downgrade base ---"
|
||||||
|
alembic downgrade base
|
||||||
|
echo "--- alembic upgrade head (restaurar para tests) ---"
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# --cov=api,core: mide solo código fuente (excluye alembic, tests, layouts)
|
||||||
|
# setup.cfg contiene los patrones omit — ver backend/setup.cfg
|
||||||
|
# --cov-fail-under=25: umbral real actual (deuda técnica documentada)
|
||||||
|
# objetivo según estándares: 80% — incrementar conforme se agreguen tests
|
||||||
|
pytest tests/ \
|
||||||
|
--cov=api \
|
||||||
|
--cov=core \
|
||||||
|
--cov-fail-under=25 \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-report=xml \
|
||||||
|
--junitxml=test-results.xml \
|
||||||
|
-v -ra
|
||||||
|
'
|
||||||
|
docker cp "$C:/app/test-results.xml" "$WORKSPACE/backend/test-results.xml"
|
||||||
|
docker cp "$C:/app/coverage.xml" "$WORKSPACE/backend/coverage.xml"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit allowEmptyResults: true, testResults: 'backend/test-results.xml'
|
||||||
|
archiveArtifacts artifacts: 'backend/coverage.xml', allowEmptyArchive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Test — Frontend (Vitest)') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
C="a76-test-fe-${BUILD_NUMBER}"
|
||||||
|
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
||||||
|
# Volume persistente para el store de pnpm (content-addressable):
|
||||||
|
# primer build lo puebla, siguientes reusan paquetes si pnpm-lock.yaml no cambió.
|
||||||
|
# Path estándar de pnpm@9 en Linux como root: /root/.local/share/pnpm/store
|
||||||
|
docker run -d --name "$C" \
|
||||||
|
-v jenkins-a76-pnpm-store:/root/.local/share/pnpm/store \
|
||||||
|
"$PLAYWRIGHT_IMAGE" sleep infinity
|
||||||
|
docker exec "$C" mkdir -p /workspace
|
||||||
|
docker cp "$WORKSPACE/." "$C:/workspace"
|
||||||
|
|
||||||
|
docker exec \
|
||||||
|
-e CI=true \
|
||||||
|
-e NODE_ENV=test \
|
||||||
|
-e "JENKINS_URL=${JENKINS_URL}" \
|
||||||
|
-e JENKINS_VITEST_FULL=1 \
|
||||||
|
-w /workspace/frontend \
|
||||||
|
"$C" bash -lc '
|
||||||
|
set -euxo pipefail
|
||||||
|
npm install -g pnpm@9 --quiet
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm run i18n:compile
|
||||||
|
pnpm run test:unit -- --run
|
||||||
|
'
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('pip-audit') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
C="a76-audit-py-${BUILD_NUMBER}"
|
||||||
|
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name "$C" \
|
||||||
|
-v jenkins-a76-pip-cache:/root/.cache/pip \
|
||||||
|
"$PYTHON_IMAGE" sleep infinity
|
||||||
|
docker cp "$WORKSPACE/backend/requirements.txt" "$C:/requirements.txt"
|
||||||
|
|
||||||
|
docker exec "$C" bash -lc '
|
||||||
|
pip install --quiet pip-audit
|
||||||
|
pip-audit -r /requirements.txt -f json -o /pip-audit-report.json || true
|
||||||
|
'
|
||||||
|
docker cp "$C:/pip-audit-report.json" "$WORKSPACE/pip-audit-report.json"
|
||||||
|
'''
|
||||||
|
archiveArtifacts artifacts: 'pip-audit-report.json', allowEmptyArchive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('npm audit') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
set -euo pipefail
|
||||||
|
C="a76-audit-fe-${BUILD_NUMBER}"
|
||||||
|
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker rm -f "$C" >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name "$C" "$PLAYWRIGHT_IMAGE" sleep infinity
|
||||||
|
docker cp "$WORKSPACE/frontend/package.json" "$C:/package.json"
|
||||||
|
docker cp "$WORKSPACE/frontend/pnpm-lock.yaml" "$C:/pnpm-lock.yaml" 2>/dev/null || true
|
||||||
|
|
||||||
|
docker exec -w / "$C" bash -lc '
|
||||||
|
npm audit --audit-level=high || true
|
||||||
|
'
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generación de versión ─────────────────────────────────────────────
|
||||||
|
stage('Generate version') {
|
||||||
|
when {
|
||||||
|
anyOf { branch 'main'; branch 'development' }
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def year = sh(returnStdout: true, script: 'date +%y').trim()
|
||||||
|
def month = sh(returnStdout: true, script: 'date +%m').trim()
|
||||||
|
def shortHash = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim()
|
||||||
|
|
||||||
|
if (env.BRANCH_NAME == 'main') {
|
||||||
|
def lastTag = sh(returnStdout: true,
|
||||||
|
script: 'git describe --tags --abbrev=0 2>/dev/null || true').trim()
|
||||||
|
def commitCount = lastTag
|
||||||
|
? sh(returnStdout: true, script: "git rev-list ${lastTag}..HEAD --count").trim()
|
||||||
|
: sh(returnStdout: true, script: 'git rev-list --count HEAD').trim()
|
||||||
|
if (commitCount == '0') commitCount = '1'
|
||||||
|
env.APP_VERSION = "${year}.${month}.1.${commitCount}"
|
||||||
|
} else {
|
||||||
|
env.APP_VERSION = "${year}.${month}.1.${shortHash}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "VERSION: ${env.APP_VERSION}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Docker Build + Push (backend y frontend en paralelo) ──────────────
|
||||||
|
// Antes era secuencial: ~6-10 min totales. Ahora cada imagen builda+pushea
|
||||||
|
// en su propio substage en paralelo → tarda el máximo de los dos.
|
||||||
|
// BuildKit inline cache + --cache-from reusan las layers de :latest cuando
|
||||||
|
// pip-lock / pnpm-lock no cambiaron (~1-3 min menos por imagen).
|
||||||
|
stage('Docker Build') {
|
||||||
|
when {
|
||||||
|
anyOf { branch 'main'; branch 'development' }
|
||||||
|
}
|
||||||
|
parallel {
|
||||||
|
|
||||||
|
stage('Build & Push — Backend') {
|
||||||
|
steps {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(
|
||||||
|
credentialsId: 'harbor-credentials',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASS'
|
||||||
|
)
|
||||||
|
]) {
|
||||||
|
sh """
|
||||||
|
set -euo pipefail
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin
|
||||||
|
|
||||||
|
# Pull de :latest para alimentar --cache-from (silencioso si no existe)
|
||||||
|
docker pull ${IMAGE_BACKEND}:latest >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker build --progress=plain \\
|
||||||
|
--cache-from ${IMAGE_BACKEND}:latest \\
|
||||||
|
--build-arg BUILDKIT_INLINE_CACHE=1 \\
|
||||||
|
--build-arg APP_VERSION=${env.APP_VERSION} \\
|
||||||
|
-t ${IMAGE_BACKEND}:${env.APP_VERSION} \\
|
||||||
|
-t ${IMAGE_BACKEND}:latest \\
|
||||||
|
-f backend/Dockerfile \\
|
||||||
|
backend/
|
||||||
|
|
||||||
|
docker push ${IMAGE_BACKEND}:${env.APP_VERSION}
|
||||||
|
docker push ${IMAGE_BACKEND}:latest
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push — Frontend') {
|
||||||
|
steps {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(
|
||||||
|
credentialsId: 'harbor-credentials',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASS'
|
||||||
|
),
|
||||||
|
string(credentialsId: 'a76-public-url-dev', variable: 'A76_URL')
|
||||||
|
]) {
|
||||||
|
sh """
|
||||||
|
set -euo pipefail
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
echo "\${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} -u "\${HARBOR_USER}" --password-stdin
|
||||||
|
|
||||||
|
docker pull ${IMAGE_FRONTEND}:latest >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker build --progress=plain \\
|
||||||
|
--cache-from ${IMAGE_FRONTEND}:latest \\
|
||||||
|
--build-arg BUILDKIT_INLINE_CACHE=1 \\
|
||||||
|
--build-arg VITE_API_URL=\${A76_URL}/api/ \\
|
||||||
|
--build-arg VITE_KEYCLOAK_URL=${KC_URL} \\
|
||||||
|
--build-arg VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend \\
|
||||||
|
--build-arg INTERNAL_API_URL=http://backend:3467/api/ \\
|
||||||
|
-t ${IMAGE_FRONTEND}:${env.APP_VERSION} \\
|
||||||
|
-t ${IMAGE_FRONTEND}:latest \\
|
||||||
|
-f frontend/Dockerfile.prod \\
|
||||||
|
frontend/
|
||||||
|
|
||||||
|
docker push ${IMAGE_FRONTEND}:${env.APP_VERSION}
|
||||||
|
docker push ${IMAGE_FRONTEND}:latest
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── E2E (Playwright) — DESACTIVADO TEMPORALMENTE ───────────────────────
|
||||||
|
// El stage completo de E2E se ha comentado temporalmente mientras se
|
||||||
|
// estabiliza el flujo de Workspace / Fixed Assets y los tests Playwright.
|
||||||
|
// Para reactivarlo, recuperar la definición anterior de stage('E2E (Playwright)').
|
||||||
|
|
||||||
|
// ── Deploy Dev ────────────────────────────────────────────────────────
|
||||||
|
stage('Deploy — Dev') {
|
||||||
|
when { branch 'development' }
|
||||||
|
steps {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(
|
||||||
|
credentialsId: 'harbor-credentials',
|
||||||
|
usernameVariable: 'HARBOR_USER',
|
||||||
|
passwordVariable: 'HARBOR_PASS'
|
||||||
|
),
|
||||||
|
string(credentialsId: 'dev-server-user', variable: 'DEV_SERVER_USER'),
|
||||||
|
string(credentialsId: 'dev-server-host', variable: 'DEPLOY_HOST'),
|
||||||
|
sshUserPrivateKey(
|
||||||
|
credentialsId: 'dev-server-ssh',
|
||||||
|
keyFileVariable: 'SSH_KEY',
|
||||||
|
usernameVariable: 'SSH_USER',
|
||||||
|
passphraseVariable: 'SSH_PASSPHRASE'
|
||||||
|
)
|
||||||
|
]) {
|
||||||
|
script {
|
||||||
|
def deployHost = env.DEPLOY_HOST?.trim()
|
||||||
|
def knownHostsFile = "${env.WORKSPACE}/.jenkins-a76-known_hosts"
|
||||||
|
sh "ssh-keyscan -T 15 -H '${deployHost}' > '${knownHostsFile}'"
|
||||||
|
def remote = [
|
||||||
|
name : 'dev-server',
|
||||||
|
host : deployHost,
|
||||||
|
user : env.DEV_SERVER_USER ?: env.SSH_USER,
|
||||||
|
identityFile : env.SSH_KEY,
|
||||||
|
passphrase : env.SSH_PASSPHRASE,
|
||||||
|
allowAnyHosts: false,
|
||||||
|
knownHosts : knownHostsFile
|
||||||
|
]
|
||||||
|
sshCommand remote: remote, command: """
|
||||||
|
set -euo pipefail
|
||||||
|
cd ${DEPLOY_PATH}
|
||||||
|
APP_VERSION=${env.APP_VERSION} docker compose -f docker-compose.prod.yml pull
|
||||||
|
APP_VERSION=${env.APP_VERSION} docker compose -f docker-compose.prod.yml up -d || {
|
||||||
|
echo '=== logs anexo76-backend ==='
|
||||||
|
docker logs --tail 100 anexo76-backend 2>&1 || true
|
||||||
|
echo '=== docker compose ps ==='
|
||||||
|
docker compose -f docker-compose.prod.yml ps -a 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
docker image prune -f
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Smoke Test ────────────────────────────────────────────────────────
|
||||||
|
stage('Smoke Test') {
|
||||||
|
when { branch 'development' }
|
||||||
|
steps {
|
||||||
|
withCredentials([
|
||||||
|
string(credentialsId: 'dev-server-host', variable: 'DEPLOY_HOST'),
|
||||||
|
sshUserPrivateKey(
|
||||||
|
credentialsId: 'dev-server-ssh',
|
||||||
|
keyFileVariable: 'SSH_KEY',
|
||||||
|
usernameVariable: 'SSH_USER',
|
||||||
|
passphraseVariable: 'SSH_PASSPHRASE'
|
||||||
|
)
|
||||||
|
]) {
|
||||||
|
script {
|
||||||
|
def deployHost = env.DEPLOY_HOST?.trim()
|
||||||
|
def knownHostsFile = "${env.WORKSPACE}/.jenkins-a76-known_hosts"
|
||||||
|
sh "ssh-keyscan -T 15 -H '${deployHost}' > '${knownHostsFile}'"
|
||||||
|
def remote = [
|
||||||
|
name : 'dev-server',
|
||||||
|
host : deployHost,
|
||||||
|
user : env.SSH_USER,
|
||||||
|
identityFile : env.SSH_KEY,
|
||||||
|
passphrase : env.SSH_PASSPHRASE,
|
||||||
|
allowAnyHosts: false,
|
||||||
|
knownHosts : knownHostsFile
|
||||||
|
]
|
||||||
|
sshCommand remote: remote, command: '''
|
||||||
|
set -euo pipefail
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" http://localhost:3467/api/health 2>/dev/null || true)
|
||||||
|
if [ "$STATUS" = "200" ]; then
|
||||||
|
echo "Smoke test OK — HTTP $STATUS"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Intento $i/12 — HTTP ${STATUS:-000} — reintentando en 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "ERROR: Smoke test fallido tras 120s"
|
||||||
|
exit 1
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
sh "docker logout ${HARBOR_REGISTRY} || true"
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
echo "Pipeline OK — ${env.BRANCH_NAME} #${env.BUILD_NUMBER} (${env.APP_VERSION ?: 'n/a'})"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
echo "PIPELINE FALLIDO — ${env.BRANCH_NAME} #${env.BUILD_NUMBER}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
README.md
Normal file
156
README.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Plantilla Workspace — Aduanasoft
|
||||||
|
|
||||||
|
Plantilla base para nuevos proyectos del ecosistema **Workspace de Aduanasoft**.
|
||||||
|
Incluye autenticación SSO con Keycloak/Hub, arquitectura multi-tenant, y un dashboard
|
||||||
|
funcional listo para extender.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Capa | Tecnología |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | SvelteKit 5 + Tailwind CSS |
|
||||||
|
| Backend | FastAPI + SQLAlchemy + PostgreSQL |
|
||||||
|
| Auth | Keycloak (OpenID Connect) vía Workspace Hub |
|
||||||
|
| Cache / Queue | Valkey (Redis) + Celery |
|
||||||
|
| Storage | MinIO (S3-compatible) |
|
||||||
|
| Contenedores | Docker Compose |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura del proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
├── backend/
|
||||||
|
│ ├── api/v1/modules/
|
||||||
|
│ │ ├── core/ # Auth, usuarios, tenants, permisos, licencias
|
||||||
|
│ │ └── example/ # Módulo de referencia — copia y renombra
|
||||||
|
│ ├── core/ # Config, seguridad, DB, middleware
|
||||||
|
│ └── alembic/ # Migraciones (solo esquema core)
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/routes/
|
||||||
|
│ │ ├── auth/ # Callback OAuth2, SSO, logout
|
||||||
|
│ │ ├── login/ # Login local (dev) o redirect al workspace
|
||||||
|
│ │ └── dashboard/ # Shell + rutas stub
|
||||||
|
│ └── src/lib/
|
||||||
|
│ ├── server/ # workspace-auth, workspace-apps, api SSR
|
||||||
|
│ └── stores/ # company, system, workspace-apps
|
||||||
|
│
|
||||||
|
└── scripts/
|
||||||
|
└── auth-mode.sh # Alterna entre modo local y workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inicio rápido
|
||||||
|
|
||||||
|
### 1. Clonar y configurar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.aduanasoft.com/ADUANASOFT/plantillas-proyectos.git mi-proyecto
|
||||||
|
cd mi-proyecto
|
||||||
|
```
|
||||||
|
|
||||||
|
Renombrar el proyecto en `.env` y `docker-compose.yml`:
|
||||||
|
- `CORE_DB_NAME=app_core` → `mi_proyecto_core`
|
||||||
|
- `name: app` en `docker-compose.yml` → `mi-proyecto`
|
||||||
|
|
||||||
|
### 2. Levantar en modo local (sin workspace)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/auth-mode.sh local
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre `http://localhost:5173` → click **"Entrar como dev"**.
|
||||||
|
|
||||||
|
### 3. Conectar al workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/auth-mode.sh workspace
|
||||||
|
# Pregunta:
|
||||||
|
# URL del workspace → https://workspace.aduanasoft.com
|
||||||
|
# Keycloak Realm → master
|
||||||
|
# Keycloak Client ID → nombre-del-client
|
||||||
|
```
|
||||||
|
|
||||||
|
> El equipo del Hub debe registrar la app y agregar el redirect URI:
|
||||||
|
> `http://localhost:5173/auth/callback`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
El `.env` usa una sola URL base para derivar toda la configuración del workspace:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Una variable, todo se deriva de aquí
|
||||||
|
WORKSPACE_URL=https://workspace.aduanasoft.com
|
||||||
|
KEYCLOAK_REALM=master
|
||||||
|
KEYCLOAK_CLIENT_ID=mi-app-frontend
|
||||||
|
|
||||||
|
# Auth local (desarrollo sin workspace)
|
||||||
|
DEV_LOCAL_AUTH=False # True = botón "Entrar como dev"
|
||||||
|
SECRET_KEY=... # Se genera automáticamente con auth-mode.sh local
|
||||||
|
```
|
||||||
|
|
||||||
|
El `docker-compose.yml` construye automáticamente:
|
||||||
|
- `HUB_URL` = `${WORKSPACE_URL}`
|
||||||
|
- `VITE_KEYCLOAK_URL` = `${WORKSPACE_URL}/kcauth`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agregar un módulo nuevo
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Copia `backend/api/v1/modules/example/` y renombra:
|
||||||
|
|
||||||
|
```
|
||||||
|
my_module/
|
||||||
|
├── __init__.py
|
||||||
|
├── models.py # SQLAlchemy — hereda TenantScopedMixin + TimestampMixin
|
||||||
|
├── dto.py # Pydantic v2 — Request / Response separados
|
||||||
|
├── service.py # Lógica de negocio, recibe db + tenant_id + company_id
|
||||||
|
└── routes.py # FastAPI router, usa Depends(get_current_user)
|
||||||
|
```
|
||||||
|
|
||||||
|
Registrar en `backend/api/v1/router.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .modules.my_module.routes import router as my_router
|
||||||
|
router.include_router(my_router, prefix="/my-module", tags=["my-module"])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Crear `frontend/src/routes/dashboard/my-module/+page.svelte` y agregar al sidebar en
|
||||||
|
`frontend/src/lib/components/sidebar/modules.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ title: 'Mi módulo', url: '/dashboard/my-module', icon: MyIcon }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Script auth-mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/auth-mode.sh status # Ver modo actual
|
||||||
|
./scripts/auth-mode.sh local # Activar login local (dev sin workspace)
|
||||||
|
./scripts/auth-mode.sh workspace # Configurar y conectar al workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Al cambiar de modo el script pregunta si reiniciar los contenedores automáticamente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Convenciones
|
||||||
|
|
||||||
|
- **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `chore:`)
|
||||||
|
- **Branches**: `feature/DESC`, `fix/DESC`
|
||||||
|
- **Nombres en código**: inglés; comentarios de lógica de negocio en español
|
||||||
|
- **Backend**: Pydantic v2, async por default, routers por dominio
|
||||||
|
- **Frontend**: SvelteKit 5 runes (`$state`, `$derived`, `$effect`), Tailwind utility-first
|
||||||
0
archivo.txt
Normal file
0
archivo.txt
Normal file
44
azure.crt
Normal file
44
azure.crt
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIH1jCCBr6gAwIBAgIQC6Mxbk470/aejYi9ZXtTGjANBgkqhkiG9w0BAQsFADBN
|
||||||
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E
|
||||||
|
aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMjUwOTIyMDAwMDAwWhcN
|
||||||
|
MjYwMzIyMjM1OTU5WjB/MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3Rv
|
||||||
|
bjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0
|
||||||
|
aW9uMSkwJwYDVQQDEyBzdGFtcDIubG9naW4ubWljcm9zb2Z0b25saW5lLmNvbTCC
|
||||||
|
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM3K1uJNEzm2EnB1LY34OThA
|
||||||
|
fC0P/F5bue+IV4GnTxjmfDTeWBW4VcInt6Row7UyCNh6EyRXIGyr1zJr34HI2NcF
|
||||||
|
5TgkftNCDua8v3SivzknmVnXNVj51ct70+UBjgN6CMhl9/b61R7nguQIVs+GdyoX
|
||||||
|
deFqgMn+awDEmLcjUS3ijw1OVbf3O5Oha5LZwxmTx3gDkb+kwH7Tba2gIr7IvVQe
|
||||||
|
dbfIO0eYQKyBCPvyCiuNS3YHGEMug+I5y1ycPQV+OmrxYbEjoS3m2cic0YV/P7xY
|
||||||
|
vdx8O2XUAVTzfJs5GAkY+IiNtpG5oaZYXJbbxIM5v11CyYeJthfpdALGA5MkiR0C
|
||||||
|
AwEAAaOCBH4wggR6MB8GA1UdIwQYMBaAFA+AYRyCMWHVLyjnjUY4tCzhxtniMB0G
|
||||||
|
A1UdDgQWBBQiLJufwFGrmBabwkp+oOZQ2USotzCCASYGA1UdEQSCAR0wggEZgiBz
|
||||||
|
dGFtcDIubG9naW4ubWljcm9zb2Z0b25saW5lLmNvbYIdbG9naW4ubWljcm9zb2Z0
|
||||||
|
b25saW5lLWludC5jb22CG2xvZ2luLm1pY3Jvc29mdG9ubGluZS1wLmNvbYIZbG9n
|
||||||
|
aW4ubWljcm9zb2Z0b25saW5lLmNvbYIebG9naW4yLm1pY3Jvc29mdG9ubGluZS1p
|
||||||
|
bnQuY29tghpsb2dpbjIubWljcm9zb2Z0b25saW5lLmNvbYIfbG9naW5leC5taWNy
|
||||||
|
b3NvZnRvbmxpbmUtaW50LmNvbYIbbG9naW5leC5taWNyb3NvZnRvbmxpbmUuY29t
|
||||||
|
giRzdGFtcDIubG9naW4ubWljcm9zb2Z0b25saW5lLWludC5jb20wPgYDVR0gBDcw
|
||||||
|
NTAzBgZngQwBAgIwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5j
|
||||||
|
b20vQ1BTMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
|
||||||
|
BQUHAwIwgY0GA1UdHwSBhTCBgjA/oD2gO4Y5aHR0cDovL2NybDMuZGlnaWNlcnQu
|
||||||
|
Y29tL0RpZ2ljZXJ0U0hBMlNlY3VyZVNlcnZlckNBLTEuY3JsMD+gPaA7hjlodHRw
|
||||||
|
Oi8vY3JsNC5kaWdpY2VydC5jb20vRGlnaWNlcnRTSEEyU2VjdXJlU2VydmVyQ0Et
|
||||||
|
MS5jcmwwfgYIKwYBBQUHAQEEcjBwMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k
|
||||||
|
aWdpY2VydC5jb20wSAYIKwYBBQUHMAKGPGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0
|
||||||
|
LmNvbS9EaWdpQ2VydFNIQTJTZWN1cmVTZXJ2ZXJDQS0yLmNydDAMBgNVHRMBAf8E
|
||||||
|
AjAAMIIBfwYKKwYBBAHWeQIEAgSCAW8EggFrAWkAdgCWl2S/VViXrfdDh2g3CEJ3
|
||||||
|
6fA61fak8zZuRqQ/D8qpxgAAAZlxQ3dQAAAEAwBHMEUCICRSWP825/83352Dv6WQ
|
||||||
|
IcXT2bmT1D6lrJQ1W6U+6PX0AiEA7XUiPn8JJmJWCK0jUS/ZrOUEKf1cML3MWeQg
|
||||||
|
6WX7MfMAdwBkEcRspBLsp4kcogIuALyrTygH1B41J6vq/tUDyX3N8AAAAZlxQ3dT
|
||||||
|
AAAEAwBIMEYCIQCL6HjqOV6VliTqVirdscl+wjEPNfq/fkzm26WJ5m26yQIhANJ9
|
||||||
|
Gc6Kt5AX15DNNJ5ukpAWgtENmpT3M+oTw/7SB0SdAHYASZybad4dfOz8Nt7Nh2Sm
|
||||||
|
uFuvCoeAGdFVUvvp6ynd+MMAAAGZcUN3bwAABAMARzBFAiEAssa9lKaXdaV7UbAj
|
||||||
|
ZwpILJrsHrszTewahn6yIo24XYoCIGl+1U3yR/RN/Ox3mNlPpJO2tDSkxcUBk3mR
|
||||||
|
BaNcrLcuMA0GCSqGSIb3DQEBCwUAA4IBAQBIru75Gq6GMI/GG+3fLKFT+NYwHqHq
|
||||||
|
J97CcTPwFRW0iv3EKKEZCyxB5su+gB6JkYFq4B+n7KZgmkuIXyO50uAgJ3toeYNs
|
||||||
|
S9WWVTqL2ETWSUl+4iNbTXnaj2/eW+27OJcM6z0phQbu/uZh8wsD2pJzv7y6isyF
|
||||||
|
5FbvC4pbb+z9LfAUH7D3kJEYEjZfD4c/WZH13s0rSlXmBPXweOFGEFCeCO1/BI0Q
|
||||||
|
2l5PdbgYAtCYUwZl3Vy7/J8uhrs0papWAMauKZWrvTauCtKcPjSI8oRwsVWrf0vL
|
||||||
|
dcWYPQmaWGxUd1+mEqcB9OiB7lTlwx+ipRJDURninhn1dOef6+qg9oV2
|
||||||
|
-----END CERTIFICATE-----
|
||||||
39
backend/.env.example
Normal file
39
backend/.env.example
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Application
|
||||||
|
APP_NAME=Mi Aplicación
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
DEBUG=True
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Auth local para desarrollo (sin Keycloak/Hub)
|
||||||
|
# Cambia a True para entrar sin workspace. NUNCA en producción.
|
||||||
|
DEV_LOCAL_AUTH=False
|
||||||
|
DEV_LOCAL_AUTH_EMAIL=dev@local.test
|
||||||
|
DEV_LOCAL_AUTH_NAME=Dev User
|
||||||
|
DEV_LOCAL_AUTH_TENANT_ID=1
|
||||||
|
DEV_LOCAL_AUTH_COMPANY_ID=1
|
||||||
|
|
||||||
|
# Database - Core
|
||||||
|
CORE_DB_HOST=localhost
|
||||||
|
CORE_DB_PORT=5432
|
||||||
|
CORE_DB_NAME=anexo76_core
|
||||||
|
CORE_DB_USER=postgres
|
||||||
|
CORE_DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||||
|
HUB_URL=https://hub.aduanasoft.com
|
||||||
|
|
||||||
|
# Factura COVE / VUCEM / DODA / API Ventanilla Única
|
||||||
|
# Llave y IV AES-256-CBC para cifrar la clave FIEL.
|
||||||
|
COVE_FIEL_HASH_KEY=
|
||||||
|
COVE_FIEL_HASH_IV=
|
||||||
|
# URL base del API de Ventanilla Única (COVE, Expediente y DODA comparten esta variable).
|
||||||
|
COVE_API_URL=https://api.vu.aduanasoft.com
|
||||||
|
# Verificación SSL para el API de VU (False en redes internas / dev, True en producción).
|
||||||
|
COVE_API_VERIFY_SSL=False
|
||||||
|
|
||||||
|
# Sincronización (Hub & Spoke)
|
||||||
|
SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||||
|
CENTRAL_SERVER_URL=
|
||||||
62
backend/Dockerfile
Normal file
62
backend/Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Instalar dependencias del sistema
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Instalar dependencias para wkhtmltopdf y reportes PDF
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y \
|
||||||
|
xvfb \
|
||||||
|
fontconfig \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
libfontconfig1 \
|
||||||
|
libxrender1 \
|
||||||
|
libxtst6 \
|
||||||
|
libxi6 \
|
||||||
|
libxrandr2 \
|
||||||
|
ca-certificates \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libpng16-16 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Instalar wkhtmltopdf binario oficial con soporte para footers/headers
|
||||||
|
# TARGETARCH permite amd64 y arm64 (Apple Silicon)
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN curl -k -L -o /tmp/wkhtmltox.deb "https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_${TARGETARCH}.deb" \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y /tmp/wkhtmltox.deb \
|
||||||
|
&& rm /tmp/wkhtmltox.deb \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& wkhtmltopdf --version
|
||||||
|
|
||||||
|
|
||||||
|
# Copiar requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Instalar dependencias Python
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Entrypoint (espera Postgres/Keycloak; no montar desde el host)
|
||||||
|
COPY docker-entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Copiar código
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Jenkins / CI pasan --build-arg APP_VERSION=…; debe quedar en ENV para runtime (API /version, OpenAPI, etc.)
|
||||||
|
ARG APP_VERSION=dev-local
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
|
# Exponer puerto
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
# Comando por defecto
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
148
backend/alembic.ini
Normal file
148
backend/alembic.ini
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
|
||||||
|
sqlalchemy.url = postgresql://${CORE_DB_USER}:${CORE_DB_PASSWORD}@${CORE_DB_HOST}:${CORE_DB_PORT}/${CORE_DB_NAME}
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
363
backend/alembic/env.py
Normal file
363
backend/alembic/env.py
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from alembic.operations import ops
|
||||||
|
from core.config import settings
|
||||||
|
from core.database import Base
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from sqlalchemy.engine.url import make_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_env_url(raw: str) -> str:
|
||||||
|
"""Quita espacios/comillas típicos de secretos CI (.env, Gitea)."""
|
||||||
|
url = raw.strip().strip('"').strip("'")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_alembic_sqlalchemy_url(url: str) -> str:
|
||||||
|
"""Alembic usa el driver síncrono psycopg2; normaliza DSN típicos de app/tests."""
|
||||||
|
url = _strip_env_url(url)
|
||||||
|
if url.startswith("postgresql+asyncpg://"):
|
||||||
|
return url.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1)
|
||||||
|
if url.startswith("postgresql+psycopg2://"):
|
||||||
|
return url
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
return url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
return url.replace("postgres://", "postgresql+psycopg2://", 1)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sqlalchemy_url(url: str, env_key: str) -> None:
|
||||||
|
"""Misma validación que create_engine; evita urlparse (falla con esquemas tipo postgresql+psycopg2)."""
|
||||||
|
try:
|
||||||
|
make_url(url)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{env_key} no es una URL de SQLAlchemy válida. "
|
||||||
|
"Ejemplo: postgresql://usuario:clave@127.0.0.1:5432/nombre_bd"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_documentation_placeholder_host(url: str, source: str) -> None:
|
||||||
|
"""
|
||||||
|
Evita el error críptico de DNS: muchos ejemplos usan @host:5432 como texto literal.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = make_url(url)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
h = (parsed.host or "").strip().lower()
|
||||||
|
if h == "host":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{source}: el hostname \"host\" es un placeholder de documentación, no un servidor real. "
|
||||||
|
"Usa el host alcanzable desde el runner (IP, nombre DNS, servicio en docker-compose, "
|
||||||
|
"o host.docker.internal si act corre en contenedor y Postgres en tu máquina)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_url():
|
||||||
|
"""Obtiene la URL de la base de datos (PostgreSQL) desde variables de entorno o alembic.ini."""
|
||||||
|
# CI / pytest: misma URL que los tests (secret TEST_DATABASE_URL) o DATABASE_URL explícita.
|
||||||
|
# CRÍTICO: debe ser tupla con coma final si un solo elemento: ("X",) — si no, ("X") es str y el for
|
||||||
|
# itera caracteres; env_key "_" + os.environ["_"] (común en shells) rompe con URL inválida.
|
||||||
|
for env_key in ("TEST_DATABASE_URL", "DATABASE_URL"):
|
||||||
|
raw = os.environ.get(env_key)
|
||||||
|
if raw and raw.strip():
|
||||||
|
normalized = _normalize_alembic_sqlalchemy_url(raw)
|
||||||
|
_validate_sqlalchemy_url(normalized, env_key)
|
||||||
|
_reject_documentation_placeholder_host(normalized, env_key)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
# Construcción desde settings (CORE_DB_* en .env / entorno)
|
||||||
|
host = settings.CORE_DB_HOST
|
||||||
|
db = settings.CORE_DB_NAME
|
||||||
|
user = settings.CORE_DB_USER
|
||||||
|
password = settings.CORE_DB_PASSWORD
|
||||||
|
port = settings.CORE_DB_PORT
|
||||||
|
|
||||||
|
if host and db and user and password:
|
||||||
|
try:
|
||||||
|
encoded_user = quote_plus(user)
|
||||||
|
encoded_password = quote_plus(password)
|
||||||
|
encoded_db = quote_plus(db)
|
||||||
|
built = f"postgresql+psycopg2://{encoded_user}:{encoded_password}@{host}:{port}/{encoded_db}"
|
||||||
|
_reject_documentation_placeholder_host(built, "CORE_DB_HOST")
|
||||||
|
return built
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error al construir URL: {e}")
|
||||||
|
|
||||||
|
# Fallback al archivo de configuración
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
if not url or "${" in url or "%(" in url:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No se ha configurado la cadena de conexión a PostgreSQL. "
|
||||||
|
"Define TEST_DATABASE_URL o DATABASE_URL, o variables CORE_DB_*; "
|
||||||
|
"sqlalchemy.url en alembic.ini con placeholders ${...} no está soportado."
|
||||||
|
)
|
||||||
|
|
||||||
|
_reject_documentation_placeholder_host(url, "alembic.ini sqlalchemy.url")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
# Configurar la URL de la base de datos
|
||||||
|
database_url = get_database_url()
|
||||||
|
|
||||||
|
# Debug: mostrar la URL (sin la contraseña)
|
||||||
|
if os.environ.get("ALEMBIC_DEBUG"):
|
||||||
|
# Ocultar la contraseña para el debug en la URL
|
||||||
|
try:
|
||||||
|
before, after = database_url.split("@", 1)
|
||||||
|
if ":" in before:
|
||||||
|
before = before.split(":", 1)[0] + ":***"
|
||||||
|
debug_url = before + "@" + after
|
||||||
|
except Exception:
|
||||||
|
debug_url = "postgresql://***:***@***"
|
||||||
|
logger.error("Error al ocultar la contraseña en la URL para debug.")
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Ajusta la ruta para que puedas importar core y módulos
|
||||||
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
|
# Configuración de Alembic
|
||||||
|
config = context.config
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def include_object(object_, name, type_, reflected, compare_to):
|
||||||
|
"""
|
||||||
|
Keep all objects in autogenerate.
|
||||||
|
FK noise is cleaned in process_revision_directives.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_drop_signature(op_):
|
||||||
|
if not isinstance(op_, ops.DropConstraintOp):
|
||||||
|
return None
|
||||||
|
if getattr(op_, "constraint_type", None) != "foreignkey":
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
getattr(op_, "schema", None),
|
||||||
|
getattr(op_, "table_name", None),
|
||||||
|
getattr(op_, "constraint_name", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_create_signature(op_):
|
||||||
|
if not isinstance(op_, ops.CreateForeignKeyOp):
|
||||||
|
return None
|
||||||
|
local_cols = tuple(getattr(op_, "local_cols", ()) or ())
|
||||||
|
remote_cols = tuple(getattr(op_, "remote_cols", ()) or ())
|
||||||
|
return (
|
||||||
|
getattr(op_, "source_schema", None),
|
||||||
|
getattr(op_, "source_table", None),
|
||||||
|
getattr(op_, "referent_schema", None),
|
||||||
|
getattr(op_, "referent_table", None),
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_to_create_match(drop_op, create_op):
|
||||||
|
if not isinstance(drop_op, ops.DropConstraintOp):
|
||||||
|
return False
|
||||||
|
if not isinstance(create_op, ops.CreateForeignKeyOp):
|
||||||
|
return False
|
||||||
|
if getattr(drop_op, "constraint_type", None) != "foreignkey":
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _normalize_schema(value):
|
||||||
|
# PostgreSQL reports default schema inconsistently as None/public.
|
||||||
|
return "public" if value in (None, "") else value
|
||||||
|
|
||||||
|
# Prefer structural comparison using Alembic's reverse op when available.
|
||||||
|
reverse_create = getattr(drop_op, "_reverse", None)
|
||||||
|
if isinstance(reverse_create, ops.CreateForeignKeyOp):
|
||||||
|
return (
|
||||||
|
_normalize_schema(getattr(reverse_create, "source_schema", None))
|
||||||
|
== _normalize_schema(getattr(create_op, "source_schema", None))
|
||||||
|
and getattr(reverse_create, "source_table", None) == getattr(create_op, "source_table", None)
|
||||||
|
and _normalize_schema(getattr(reverse_create, "referent_schema", None))
|
||||||
|
== _normalize_schema(getattr(create_op, "referent_schema", None))
|
||||||
|
and getattr(reverse_create, "referent_table", None) == getattr(create_op, "referent_table", None)
|
||||||
|
and tuple(getattr(reverse_create, "local_cols", ()) or ())
|
||||||
|
== tuple(getattr(create_op, "local_cols", ()) or ())
|
||||||
|
and tuple(getattr(reverse_create, "remote_cols", ()) or ())
|
||||||
|
== tuple(getattr(create_op, "remote_cols", ()) or ())
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback for older op payloads: compare source table/schema and name.
|
||||||
|
return (
|
||||||
|
_normalize_schema(getattr(drop_op, "schema", None)) == _normalize_schema(getattr(create_op, "source_schema", None))
|
||||||
|
and getattr(drop_op, "table_name", None) == getattr(create_op, "source_table", None)
|
||||||
|
and getattr(drop_op, "constraint_name", None) == getattr(create_op, "constraint_name", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_fk_churn(container):
|
||||||
|
if not hasattr(container, "ops"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# First recurse into nested containers.
|
||||||
|
for op_ in list(container.ops):
|
||||||
|
_prune_fk_churn(op_)
|
||||||
|
|
||||||
|
table_ops = container.ops
|
||||||
|
kept_ops = []
|
||||||
|
consumed_indexes = set()
|
||||||
|
|
||||||
|
for i, op_i in enumerate(table_ops):
|
||||||
|
if i in consumed_indexes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(op_i, ops.DropConstraintOp) and getattr(op_i, "constraint_type", None) == "foreignkey":
|
||||||
|
matched_j = None
|
||||||
|
for j in range(i + 1, len(table_ops)):
|
||||||
|
if j in consumed_indexes:
|
||||||
|
continue
|
||||||
|
op_j = table_ops[j]
|
||||||
|
if _drop_to_create_match(op_i, op_j):
|
||||||
|
matched_j = j
|
||||||
|
break
|
||||||
|
if matched_j is not None:
|
||||||
|
# Drop + recreate same FK detected; remove both.
|
||||||
|
consumed_indexes.add(i)
|
||||||
|
consumed_indexes.add(matched_j)
|
||||||
|
continue
|
||||||
|
|
||||||
|
kept_ops.append(op_i)
|
||||||
|
|
||||||
|
container.ops = kept_ops
|
||||||
|
|
||||||
|
|
||||||
|
def process_revision_directives(context_, revision, directives):
|
||||||
|
"""
|
||||||
|
Remove autogenerate noise where Alembic emits drop/create for equivalent FKs.
|
||||||
|
Real FK changes are preserved.
|
||||||
|
"""
|
||||||
|
if not directives:
|
||||||
|
return
|
||||||
|
script = directives[0]
|
||||||
|
_prune_fk_churn(script.upgrade_ops)
|
||||||
|
_prune_fk_churn(script.downgrade_ops)
|
||||||
|
|
||||||
|
|
||||||
|
def import_models_from_dir(dir_path: str):
|
||||||
|
"""Importa recursivamente cualquier archivo models.py desde dir_path y archivos en directorios models/"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def _load_module(module_path: str):
|
||||||
|
rel_path = os.path.relpath(module_path, BASE_DIR)
|
||||||
|
module_name = rel_path.replace(os.sep, ".").replace(".py", "")
|
||||||
|
# Skip if already loaded to avoid duplicate SQLAlchemy table registrations
|
||||||
|
if module_name in sys.modules:
|
||||||
|
return
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
# Register in sys.modules before exec so transitive imports resolve correctly
|
||||||
|
sys.modules[module_name] = mod
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(dir_path):
|
||||||
|
# Importar archivos models.py directos
|
||||||
|
if "models.py" in files:
|
||||||
|
try:
|
||||||
|
_load_module(os.path.join(root, "models.py"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"No se pudo importar {os.path.join(root, 'models.py')}: {e}")
|
||||||
|
|
||||||
|
# Importar todos los archivos .py en directorios llamados "models"
|
||||||
|
if os.path.basename(root) == "models":
|
||||||
|
for file in files:
|
||||||
|
if file.endswith(".py") and not file.startswith("__"):
|
||||||
|
try:
|
||||||
|
_load_module(os.path.join(root, file))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"No se pudo importar {os.path.join(root, file)}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Importar todos los models dentro de api/v1/modules y api/v1/modules/uploads
|
||||||
|
modules_dir = os.path.join(BASE_DIR, "api", "v1", "modules")
|
||||||
|
import_models_from_dir(modules_dir)
|
||||||
|
|
||||||
|
# Tablas declaradas fuera de models.py / carpeta models/ (autogenerate)
|
||||||
|
# Agrega aquí imports de models que no estén en archivos models.py estándar.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
include_schemas=True,
|
||||||
|
include_object=include_object,
|
||||||
|
process_revision_directives=process_revision_directives,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
include_schemas=True,
|
||||||
|
include_object=include_object,
|
||||||
|
process_revision_directives=process_revision_directives,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
24
backend/alembic/versions/8c9bad3da37f_seed_initial_data.py
Normal file
24
backend/alembic/versions/8c9bad3da37f_seed_initial_data.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""seed_initial_data
|
||||||
|
|
||||||
|
Revision ID: 8c9bad3da37f
|
||||||
|
Revises: 9db46c604463
|
||||||
|
Create Date: 2026-05-01 22:01:50.174319
|
||||||
|
|
||||||
|
Nota: la migración original sembraba catálogos de referencia de Anexo 76.
|
||||||
|
En la plantilla este paso es un no-op — agrega tus seeds aquí si los necesitas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
revision: str = "8c9bad3da37f"
|
||||||
|
down_revision: Union[str, None] = "9db46c604463"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
5091
backend/alembic/versions/9db46c604463_initial_schema.py
Normal file
5091
backend/alembic/versions/9db46c604463_initial_schema.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
"""add first_name and last_name to user_tenants
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 8c9bad3da37f
|
||||||
|
Create Date: 2026-05-06 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "a1b2c3d4e5f6"
|
||||||
|
down_revision: Union[str, None] = "8c9bad3da37f"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column("first_name", sa.String(100), nullable=True,
|
||||||
|
comment="Nombre (caché local de Keycloak)"),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column("last_name", sa.String(100), nullable=True,
|
||||||
|
comment="Apellido (caché local de Keycloak)"),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user_tenants", "last_name", schema="core")
|
||||||
|
op.drop_column("user_tenants", "first_name", schema="core")
|
||||||
78
backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py
Normal file
78
backend/alembic/versions/b2c3d4e5f6a7_add_invite_tokens.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""add invite_tokens table
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-05-06 12:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "b2c3d4e5f6a7"
|
||||||
|
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"invite_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("token_hash", sa.String(64), nullable=False, unique=True),
|
||||||
|
sa.Column("tenant_slug", sa.String(100), nullable=False),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("role", sa.String(50), nullable=False, server_default="user"),
|
||||||
|
sa.Column("created_by", sa.String(255), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("product_ids", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("company_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_invite_tokens_token_hash",
|
||||||
|
"invite_tokens",
|
||||||
|
["token_hash"],
|
||||||
|
unique=True,
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_invite_tokens_tenant_slug",
|
||||||
|
"invite_tokens",
|
||||||
|
["tenant_slug"],
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'invite_tokens',
|
||||||
|
sa.Column('hub_invite_token', sa.String(length=255), nullable=True),
|
||||||
|
schema='core',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_core_invite_tokens_tenant_slug",
|
||||||
|
table_name="invite_tokens",
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_core_invite_tokens_token_hash",
|
||||||
|
table_name="invite_tokens",
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.drop_table("invite_tokens", schema="core")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""add workspace profile fields to user_tenants
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-05-08 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "c3d4e5f6a7b"
|
||||||
|
down_revision: Union[str, None] = "b2c3d4e5f6a7"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column(
|
||||||
|
"workspace_user_id",
|
||||||
|
sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
comment="User ID (sub) proveniente de Workspace",
|
||||||
|
),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column(
|
||||||
|
"workspace_avatar_url",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
comment="Avatar URL sincronizado desde Workspace",
|
||||||
|
),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column(
|
||||||
|
"workspace_profile_synced_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Última sincronización de perfil con Workspace",
|
||||||
|
),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user_tenants", "workspace_profile_synced_at", schema="core")
|
||||||
|
op.drop_column("user_tenants", "workspace_avatar_url", schema="core")
|
||||||
|
op.drop_column("user_tenants", "workspace_user_id", schema="core")
|
||||||
54
backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py
Normal file
54
backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""add invite codes
|
||||||
|
|
||||||
|
Revision ID: g2h3i4j5k6l7
|
||||||
|
Revises: c3d4e5f6a7b
|
||||||
|
Create Date: 2026-06-02 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "g2h3i4j5k6l7"
|
||||||
|
down_revision: str = "c3d4e5f6a7b"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"invite_codes",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("tenant_slug", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("company_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("role", sa.String(length=50), nullable=False, server_default="user"),
|
||||||
|
sa.Column("max_uses", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("uses_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.create_index("ix_core_invite_codes_id", "invite_codes", ["id"], schema="core")
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_invite_codes_code", "invite_codes", ["code"], unique=True, schema="core"
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_invite_codes_tenant_slug", "invite_codes", ["tenant_slug"], schema="core"
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_invite_codes_company_id", "invite_codes", ["company_id"], schema="core"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_core_invite_codes_company_id", table_name="invite_codes", schema="core")
|
||||||
|
op.drop_index("ix_core_invite_codes_tenant_slug", table_name="invite_codes", schema="core")
|
||||||
|
op.drop_index("ix_core_invite_codes_code", table_name="invite_codes", schema="core")
|
||||||
|
op.drop_index("ix_core_invite_codes_id", table_name="invite_codes", schema="core")
|
||||||
|
op.drop_table("invite_codes", schema="core")
|
||||||
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/v1/__init__.py
Normal file
0
backend/api/v1/__init__.py
Normal file
33
backend/api/v1/common/base_models.py
Normal file
33
backend/api/v1/common/base_models.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTimestampMixin:
|
||||||
|
"""Mixin for basic timestamp fields (no soft delete)"""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin(BaseTimestampMixin):
|
||||||
|
"""Mixin for common timestamp fields including soft delete"""
|
||||||
|
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantScopedMixin:
|
||||||
|
"""Mixin para entidades multi-tenant.
|
||||||
|
|
||||||
|
company_id no tiene FK declarada aquí — agrégala en cada modelo
|
||||||
|
apuntando a la tabla de compañías de tu proyecto.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
|
||||||
|
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||||
12
backend/api/v1/common/catalog_validation_errors.py
Normal file
12
backend/api/v1/common/catalog_validation_errors.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Errores de validación alineados a reglas CSV / catálogos (HTTP 422)."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogValidationError(Exception):
|
||||||
|
"""Lista de errores tipo {line, col, msg} como en import CSV."""
|
||||||
|
|
||||||
|
def __init__(self, errors: List[Dict[str, Any]]):
|
||||||
|
self.errors = errors or []
|
||||||
|
first = self.errors[0].get("msg", "Validación de catálogo") if self.errors else "Validación de catálogo"
|
||||||
|
super().__init__(first)
|
||||||
121
backend/api/v1/common/crud_routes.py
Normal file
121
backend/api/v1/common/crud_routes.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from typing import Any, Callable, Generic, Type, TypeVar
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
ModelType = TypeVar("ModelType")
|
||||||
|
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||||
|
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||||
|
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDRouterFactory(
|
||||||
|
Generic[ModelType, CreateSchemaType, UpdateSchemaType, ResponseSchemaType]
|
||||||
|
):
|
||||||
|
"""Factory to create standard CRUD routes"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: Type[ModelType],
|
||||||
|
create_schema: Type[CreateSchemaType],
|
||||||
|
update_schema: Type[UpdateSchemaType],
|
||||||
|
response_schema: Type[ResponseSchemaType],
|
||||||
|
db_dependency: Callable,
|
||||||
|
auth_dependency: Callable,
|
||||||
|
prefix: str,
|
||||||
|
tags: list[str],
|
||||||
|
id_field: str = "key",
|
||||||
|
):
|
||||||
|
self.model = model
|
||||||
|
self.create_schema = create_schema
|
||||||
|
self.update_schema = update_schema
|
||||||
|
self.response_schema = response_schema
|
||||||
|
self.db_dependency = db_dependency
|
||||||
|
self.auth_dependency = auth_dependency
|
||||||
|
self.id_field = id_field
|
||||||
|
self.router = APIRouter(prefix=prefix, tags=tags)
|
||||||
|
self._register_routes()
|
||||||
|
|
||||||
|
def _register_routes(self):
|
||||||
|
"""Register all CRUD routes"""
|
||||||
|
|
||||||
|
@self.router.get("/", response_model=list[self.response_schema])
|
||||||
|
def list_items(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: dict = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
items = db.query(self.model).offset(skip).limit(limit).all()
|
||||||
|
return items
|
||||||
|
|
||||||
|
@self.router.get(f"/{{{self.id_field}}}", response_model=self.response_schema)
|
||||||
|
def get_item(
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: dict = Depends(self.auth_dependency),
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
item_id = kwargs.get(self.id_field)
|
||||||
|
obj = (
|
||||||
|
db.query(self.model)
|
||||||
|
.filter(getattr(self.model, self.id_field) == item_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@self.router.post("/", response_model=self.response_schema)
|
||||||
|
def create_item(
|
||||||
|
data: Any,
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: dict = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
obj = self.model(**data.dict())
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@self.router.put(f"/{{{self.id_field}}}", response_model=self.response_schema)
|
||||||
|
def update_item(
|
||||||
|
data: Any,
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: dict = Depends(self.auth_dependency),
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
item_id = kwargs.get(self.id_field)
|
||||||
|
obj = (
|
||||||
|
db.query(self.model)
|
||||||
|
.filter(getattr(self.model, self.id_field) == item_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
for field, value in data.dict(exclude_unset=True).items():
|
||||||
|
setattr(obj, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@self.router.delete(f"/{{{self.id_field}}}", status_code=204)
|
||||||
|
def delete_item(
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: dict = Depends(self.auth_dependency),
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
item_id = kwargs.get(self.id_field)
|
||||||
|
obj = (
|
||||||
|
db.query(self.model)
|
||||||
|
.filter(getattr(self.model, self.id_field) == item_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
db.delete(obj)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
32
backend/api/v1/common/dto_mixins.py
Normal file
32
backend/api/v1/common/dto_mixins.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyMixin:
|
||||||
|
"""Mixin for currency-related fields"""
|
||||||
|
|
||||||
|
currency: Optional[str] = Field(None, max_length=3, description="Currency")
|
||||||
|
currency_factor: Optional[Decimal] = Field(None, description="Currency factor")
|
||||||
|
|
||||||
|
|
||||||
|
class AffectValueMixin:
|
||||||
|
"""Mixin for value affect flags"""
|
||||||
|
|
||||||
|
not_affect_usd_value: Optional[bool] = Field(
|
||||||
|
None, description="Not affect USD value"
|
||||||
|
)
|
||||||
|
not_affect_customs_value: Optional[bool] = Field(
|
||||||
|
None, description="Not affect customs value"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFlagsMixin:
|
||||||
|
"""Mixin for update flags"""
|
||||||
|
|
||||||
|
update_vat: Optional[bool] = Field(None, description="Update VAT")
|
||||||
|
update_advalorem: Optional[bool] = Field(None, description="Update advalorem")
|
||||||
|
update_dta: Optional[bool] = Field(None, description="Update DTA")
|
||||||
|
update_cc: Optional[bool] = Field(None, description="Update CC")
|
||||||
|
update_ieps: Optional[bool] = Field(None, description="Update IEPS")
|
||||||
651
backend/api/v1/common/tenant_crud_routes.py
Normal file
651
backend/api/v1/common/tenant_crud_routes.py
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
from typing import Any, Callable, Dict, Generic, Optional, Type, TypeVar, Union
|
||||||
|
import logging
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, is_hub_admin, resolve_tenant_id_required, validate_access_to_resource, get_active_system
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request
|
||||||
|
|
||||||
|
from api.v1.common.catalog_validation_errors import CatalogValidationError
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Type variables for generic types
|
||||||
|
ModelType = TypeVar("ModelType")
|
||||||
|
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||||
|
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||||
|
ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
|
||||||
|
ServiceType = TypeVar("ServiceType")
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCRUDRoutes(
|
||||||
|
Generic[CreateSchemaType, UpdateSchemaType, ResponseSchemaType, ServiceType]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Generic CRUD routes factory for tenant-scoped resources
|
||||||
|
|
||||||
|
Supports both parent resources (with list/pagination) and child resources (nested under parent).
|
||||||
|
|
||||||
|
Usage examples:
|
||||||
|
|
||||||
|
1. Parent resource with list (e.g., /pedimentos):
|
||||||
|
router = TenantCRUDRoutes(
|
||||||
|
service=PedimentosService,
|
||||||
|
create_schema=PedimentosCreate,
|
||||||
|
update_schema=PedimentosUpdate,
|
||||||
|
response_schema=PedimentosResponse,
|
||||||
|
prefix="/pedimentos",
|
||||||
|
tags=["Pedimentos"],
|
||||||
|
resource_name="Pedimento",
|
||||||
|
id_name="pedimento_id",
|
||||||
|
enable_list=True,
|
||||||
|
).router
|
||||||
|
|
||||||
|
2. Child resource (e.g., /pedimentos/{pedimento_id}/config-additional):
|
||||||
|
router = TenantCRUDRoutes(
|
||||||
|
service=PedimentoConfigAdditionalService,
|
||||||
|
create_schema=PedimentoConfigAdditionalCreate,
|
||||||
|
update_schema=PedimentoConfigAdditionalUpdate,
|
||||||
|
response_schema=PedimentoConfigAdditionalResponse,
|
||||||
|
prefix="/{pedimento_id}/config-additional",
|
||||||
|
tags=["Pedimento Config Additional"],
|
||||||
|
resource_name="Config additional",
|
||||||
|
parent_id_name="pedimento_id",
|
||||||
|
enable_list=False,
|
||||||
|
).router
|
||||||
|
|
||||||
|
3. Parent resource with string ID (e.g., /vehicles with vehicle_key):
|
||||||
|
router = TenantCRUDRoutes(
|
||||||
|
service=VehicleService,
|
||||||
|
create_schema=VehicleCreate,
|
||||||
|
update_schema=VehicleUpdate,
|
||||||
|
response_schema=VehicleResponse,
|
||||||
|
prefix="/vehicles",
|
||||||
|
tags=["Vehicles"],
|
||||||
|
resource_name="Vehicle",
|
||||||
|
id_name="vehicle_key",
|
||||||
|
id_type=str, # Specify string type for vehicle_key
|
||||||
|
enable_list=True,
|
||||||
|
).router
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service: Type[ServiceType],
|
||||||
|
create_schema: Type[CreateSchemaType],
|
||||||
|
update_schema: Type[UpdateSchemaType],
|
||||||
|
response_schema: Type[ResponseSchemaType],
|
||||||
|
prefix: str,
|
||||||
|
tags: list[str],
|
||||||
|
resource_name: str = "Resource",
|
||||||
|
# For parent resources (e.g., "pedimento_id")
|
||||||
|
id_name: Optional[str] = None,
|
||||||
|
id_type: Type = int, # Type of the ID (int, str, etc.)
|
||||||
|
parent_id_name: Optional[
|
||||||
|
str
|
||||||
|
] = None, # For child resources (e.g., "pedimento_id")
|
||||||
|
db_dependency: Callable = get_core_db,
|
||||||
|
auth_dependency: Callable = get_current_user,
|
||||||
|
validate_parent_match: bool = True, # Validate parent_id matches in create
|
||||||
|
enable_list: bool = False, # Enable GET list endpoint with pagination
|
||||||
|
enable_filters: bool = False, # Enable custom filters in list endpoint
|
||||||
|
default_page_size: int = 50,
|
||||||
|
max_page_size: int = 2000,
|
||||||
|
# Permissions for each operation
|
||||||
|
list_permissions: Optional[list[str]] = None,
|
||||||
|
get_permissions: Optional[list[str]] = None,
|
||||||
|
create_permissions: Optional[list[str]] = None,
|
||||||
|
update_permissions: Optional[list[str]] = None,
|
||||||
|
delete_permissions: Optional[list[str]] = None,
|
||||||
|
require_all: bool = True, # If True, requires ALL permissions; if False, requires ANY
|
||||||
|
):
|
||||||
|
self.service = service
|
||||||
|
self.create_schema = create_schema
|
||||||
|
self.update_schema = update_schema
|
||||||
|
self.response_schema = response_schema
|
||||||
|
self.resource_name = resource_name
|
||||||
|
self.id_name = id_name or parent_id_name or "id"
|
||||||
|
self.id_type = id_type
|
||||||
|
self.parent_id_name = parent_id_name
|
||||||
|
self.db_dependency = db_dependency
|
||||||
|
self.auth_dependency = auth_dependency
|
||||||
|
self.validate_parent_match = validate_parent_match
|
||||||
|
self.enable_list = enable_list
|
||||||
|
self.enable_filters = enable_filters
|
||||||
|
self.default_page_size = default_page_size
|
||||||
|
self.max_page_size = max_page_size
|
||||||
|
self.list_permissions = list_permissions
|
||||||
|
self.get_permissions = get_permissions
|
||||||
|
self.create_permissions = create_permissions
|
||||||
|
self.update_permissions = update_permissions
|
||||||
|
self.delete_permissions = delete_permissions
|
||||||
|
self.require_all = require_all
|
||||||
|
|
||||||
|
self.router = APIRouter(prefix=prefix, tags=tags)
|
||||||
|
self._register_routes()
|
||||||
|
|
||||||
|
def _register_routes(self):
|
||||||
|
"""Register all CRUD routes"""
|
||||||
|
|
||||||
|
# LIST route (optional, for parent resources)
|
||||||
|
if self.enable_list:
|
||||||
|
if self.enable_filters:
|
||||||
|
|
||||||
|
@self.router.get(
|
||||||
|
"/",
|
||||||
|
response_model=Dict[str, Any],
|
||||||
|
summary=f"List {self.resource_name}s",
|
||||||
|
description=f"Get paginated list of {self.resource_name}s with optional filters",
|
||||||
|
)
|
||||||
|
async def list_resources(
|
||||||
|
request: Request,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"),
|
||||||
|
page: int = Query(1, ge=1, description="Page number"),
|
||||||
|
page_size: int = Query(
|
||||||
|
self.default_page_size,
|
||||||
|
ge=1,
|
||||||
|
le=self.max_page_size,
|
||||||
|
description="Page size",
|
||||||
|
),
|
||||||
|
sort_by: Optional[str] = Query(None, description="Column to sort by"),
|
||||||
|
sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"),
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
if all_companies:
|
||||||
|
# Hub admin: tenant_id=None → el servicio devuelve todas las empresas
|
||||||
|
tenant_id = resolve_tenant_id_required(current_user, db=db)
|
||||||
|
target_company_id = None
|
||||||
|
else:
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.list_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
target_company_id = company_id
|
||||||
|
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
# Extraer todos los parámetros de búsqueda dinámicamente
|
||||||
|
# Excluimos los parámetros estándar de paginación y control
|
||||||
|
standard_params = {"company_id", "all_companies", "page", "page_size", "sort_by", "sort_order"}
|
||||||
|
filters = {
|
||||||
|
k: v
|
||||||
|
for k, v in request.query_params.items()
|
||||||
|
if k not in standard_params and v is not None and v != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Inyectar active_system (header/cookie) si no viene por query param
|
||||||
|
active_system = get_active_system(request)
|
||||||
|
if active_system and "system" not in filters:
|
||||||
|
filters["system"] = active_system
|
||||||
|
|
||||||
|
# Determine what parameters the service method accepts
|
||||||
|
sig = inspect.signature(self.service.get_all)
|
||||||
|
kwargs = {}
|
||||||
|
if "sort_by" in sig.parameters:
|
||||||
|
kwargs["sort_by"] = sort_by
|
||||||
|
if "sort_order" in sig.parameters:
|
||||||
|
kwargs["sort_order"] = sort_order
|
||||||
|
|
||||||
|
try:
|
||||||
|
items, total = self.service.get_all(
|
||||||
|
db, tenant_id, target_company_id, skip, page_size, filters, **kwargs
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in {self.resource_name} list service: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error listing {self.resource_name}s: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
self.response_schema.model_validate(item) for item in items
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating {self.resource_name} response schema: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Data validation error in {self.resource_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
@self.router.get(
|
||||||
|
"/",
|
||||||
|
response_model=Dict[str, Any],
|
||||||
|
summary=f"List {self.resource_name}s",
|
||||||
|
description=f"Get paginated list of {self.resource_name}s",
|
||||||
|
)
|
||||||
|
async def list_resources(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"),
|
||||||
|
page: int = Query(1, ge=1, description="Page number"),
|
||||||
|
page_size: int = Query(
|
||||||
|
self.default_page_size,
|
||||||
|
ge=1,
|
||||||
|
le=self.max_page_size,
|
||||||
|
description="Page size",
|
||||||
|
),
|
||||||
|
sort_by: Optional[str] = Query(None, description="Column to sort by"),
|
||||||
|
sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"),
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
if all_companies:
|
||||||
|
# Hub admin: tenant_id=None → el servicio devuelve todas las empresas
|
||||||
|
tenant_id = resolve_tenant_id_required(current_user, db=db)
|
||||||
|
target_company_id = None
|
||||||
|
else:
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.list_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
target_company_id = company_id
|
||||||
|
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
|
||||||
|
# Determine what parameters the service method accepts
|
||||||
|
sig = inspect.signature(self.service.get_all)
|
||||||
|
kwargs = {}
|
||||||
|
if "sort_by" in sig.parameters:
|
||||||
|
kwargs["sort_by"] = sort_by
|
||||||
|
if "sort_order" in sig.parameters:
|
||||||
|
kwargs["sort_order"] = sort_order
|
||||||
|
|
||||||
|
try:
|
||||||
|
items, total = self.service.get_all(
|
||||||
|
db, tenant_id, target_company_id, skip, page_size, None, **kwargs
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in {self.resource_name} list service: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error listing {self.resource_name}s: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
self.response_schema.model_validate(item) for item in items
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating {self.resource_name} response schema: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Data validation error in {self.resource_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# GET single resource route
|
||||||
|
# For parent resources: GET /{id}
|
||||||
|
# For child resources: GET / (parent_id comes from path)
|
||||||
|
if self.parent_id_name:
|
||||||
|
# Child resource - single GET without ID in path
|
||||||
|
@self.router.get(
|
||||||
|
"/",
|
||||||
|
response_model=self.response_schema,
|
||||||
|
summary=f"Get {self.resource_name}",
|
||||||
|
description=f"Get {self.resource_name} by {self.parent_id_name}",
|
||||||
|
)
|
||||||
|
async def get_resource(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
**path_params,
|
||||||
|
):
|
||||||
|
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db, company_id, current_user, self.get_permissions, self.require_all
|
||||||
|
)
|
||||||
|
parent_id = path_params.get(self.parent_id_name)
|
||||||
|
|
||||||
|
# Try method with 4 params (pedimento_id, tenant_id, company_id)
|
||||||
|
if hasattr(self.service, "get_by_pedimento_id"):
|
||||||
|
resource = self.service.get_by_pedimento_id(
|
||||||
|
db, parent_id, tenant_id, company_id
|
||||||
|
)
|
||||||
|
# Fallback to method with 3 params
|
||||||
|
elif hasattr(self.service, "get_by_id"):
|
||||||
|
resource = self.service.get_by_id(
|
||||||
|
db, parent_id, tenant_id, company_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resource = self.service.get(db, parent_id, tenant_id, company_id)
|
||||||
|
|
||||||
|
if not resource:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"{self.resource_name} not found"
|
||||||
|
)
|
||||||
|
return resource
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Parent resource - GET by ID in path
|
||||||
|
@self.router.get(
|
||||||
|
f"/{{{self.id_name}}}",
|
||||||
|
response_model=self.response_schema,
|
||||||
|
summary=f"Get {self.resource_name} by ID",
|
||||||
|
description=f"Get a specific {self.resource_name} by {self.id_name}",
|
||||||
|
)
|
||||||
|
async def get_resource_by_id(
|
||||||
|
resource_id: Union[int, str] = Path(
|
||||||
|
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||||
|
),
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db, company_id, current_user, self.get_permissions, self.require_all
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = self.service.get_by_id(
|
||||||
|
db, resource_id, tenant_id, company_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in {self.resource_name} get service: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error retrieving {self.resource_name}: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resource:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"{self.resource_name} not found"
|
||||||
|
)
|
||||||
|
return resource
|
||||||
|
|
||||||
|
# POST route
|
||||||
|
if self.parent_id_name:
|
||||||
|
# Child resource - needs parent_id from path
|
||||||
|
|
||||||
|
# Create a closure to capture the schema type
|
||||||
|
create_schema = self.create_schema
|
||||||
|
|
||||||
|
@self.router.post(
|
||||||
|
"/",
|
||||||
|
response_model=self.response_schema,
|
||||||
|
status_code=201,
|
||||||
|
summary=f"Create {self.resource_name}",
|
||||||
|
description=f"Create a new {self.resource_name}",
|
||||||
|
)
|
||||||
|
async def create_child_resource(
|
||||||
|
request: Request,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
data: create_schema = Body(...), # type: ignore
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.create_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||||
|
if self.enable_filters:
|
||||||
|
active_system = get_active_system(request)
|
||||||
|
if active_system and hasattr(data, "system"):
|
||||||
|
data = data.model_copy(update={"system": active_system})
|
||||||
|
|
||||||
|
# For child resources, parent_id validation would go here
|
||||||
|
try:
|
||||||
|
resource = self.service.create(db, data, tenant_id, company_id)
|
||||||
|
return resource
|
||||||
|
except CatalogValidationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": str(e),
|
||||||
|
"errors": e.errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Capturar errores de validación (como duplicados)
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
# Re-lanzar otros errores
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Parent resource - no parent_id needed
|
||||||
|
|
||||||
|
# Create a closure to capture the schema type
|
||||||
|
create_schema = self.create_schema
|
||||||
|
|
||||||
|
@self.router.post(
|
||||||
|
"/",
|
||||||
|
response_model=self.response_schema,
|
||||||
|
status_code=201,
|
||||||
|
summary=f"Create {self.resource_name}",
|
||||||
|
description=f"Create a new {self.resource_name}",
|
||||||
|
)
|
||||||
|
async def create_parent_resource(
|
||||||
|
request: Request,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
data: create_schema = Body(...), # type: ignore
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.create_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inyectar sistema activo en el campo system si el recurso lo soporta
|
||||||
|
if self.enable_filters:
|
||||||
|
active_system = get_active_system(request)
|
||||||
|
if active_system and hasattr(data, "system"):
|
||||||
|
data = data.model_copy(update={"system": active_system})
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = self.service.create(db, data, tenant_id, company_id)
|
||||||
|
return resource
|
||||||
|
except CatalogValidationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": str(e),
|
||||||
|
"errors": e.errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Capturar errores de validación (como duplicados)
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
# Re-lanzar otros errores
|
||||||
|
raise
|
||||||
|
|
||||||
|
# PUT route
|
||||||
|
# For parent resources: PUT /{id}
|
||||||
|
# For child resources: PUT / (parent_id comes from path)
|
||||||
|
if self.parent_id_name:
|
||||||
|
# Child resource
|
||||||
|
|
||||||
|
# Create a closure to capture the schema type
|
||||||
|
update_schema = self.update_schema
|
||||||
|
|
||||||
|
@self.router.put(
|
||||||
|
"/",
|
||||||
|
response_model=self.response_schema,
|
||||||
|
summary=f"Update {self.resource_name}",
|
||||||
|
description=f"Update an existing {self.resource_name}",
|
||||||
|
)
|
||||||
|
async def update_resource(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
data: update_schema = Body(...), # type: ignore
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
**path_params,
|
||||||
|
):
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.update_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
parent_id = path_params.get(self.parent_id_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = self.service.update(
|
||||||
|
db, parent_id, tenant_id, data, company_id
|
||||||
|
)
|
||||||
|
except CatalogValidationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": str(e),
|
||||||
|
"errors": e.errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Capturar errores de validación (como duplicados)
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
if not resource:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"{self.resource_name} not found"
|
||||||
|
)
|
||||||
|
return resource
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Parent resource
|
||||||
|
|
||||||
|
# Create a closure to capture the schema type
|
||||||
|
update_schema = self.update_schema
|
||||||
|
|
||||||
|
@self.router.put(
|
||||||
|
f"/{{{self.id_name}}}/",
|
||||||
|
response_model=self.response_schema,
|
||||||
|
summary=f"Update {self.resource_name}",
|
||||||
|
description=f"Update an existing {self.resource_name} by {self.id_name}",
|
||||||
|
)
|
||||||
|
async def update_resource_by_id(
|
||||||
|
resource_id: Union[int, str] = Path(
|
||||||
|
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||||
|
),
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
data: update_schema = Body(...), # type: ignore
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
f"""Update {self.resource_name}"""
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.update_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = self.service.update(
|
||||||
|
db, resource_id, tenant_id, data, company_id
|
||||||
|
)
|
||||||
|
except CatalogValidationError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": str(e),
|
||||||
|
"errors": e.errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
# Capturar errores de validación (como duplicados)
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
if not resource:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"{self.resource_name} not found"
|
||||||
|
)
|
||||||
|
return resource
|
||||||
|
|
||||||
|
# DELETE route
|
||||||
|
# For parent resources: DELETE /{id}
|
||||||
|
# For child resources: DELETE / (parent_id comes from path)
|
||||||
|
if self.parent_id_name:
|
||||||
|
# Child resource
|
||||||
|
@self.router.delete(
|
||||||
|
"/",
|
||||||
|
status_code=204,
|
||||||
|
summary=f"Delete {self.resource_name}",
|
||||||
|
description=f"Delete an existing {self.resource_name}",
|
||||||
|
)
|
||||||
|
async def delete_resource(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
**path_params,
|
||||||
|
):
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.delete_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
parent_id = path_params.get(self.parent_id_name)
|
||||||
|
|
||||||
|
success = self.service.delete(db, parent_id, tenant_id, company_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"{self.resource_name} not found"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Parent resource
|
||||||
|
@self.router.delete(
|
||||||
|
f"/{{{self.id_name}}}",
|
||||||
|
status_code=204,
|
||||||
|
summary=f"Delete {self.resource_name}",
|
||||||
|
description=f"Delete an existing {self.resource_name} by {self.id_name}",
|
||||||
|
)
|
||||||
|
async def delete_resource_by_id(
|
||||||
|
resource_id: Union[int, str] = Path(
|
||||||
|
..., alias=self.id_name, description=f"{self.resource_name} ID"
|
||||||
|
),
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(self.db_dependency),
|
||||||
|
current_user: Dict[str, Any] = Depends(self.auth_dependency),
|
||||||
|
):
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
self.delete_permissions,
|
||||||
|
self.require_all,
|
||||||
|
)
|
||||||
|
|
||||||
|
success = self.service.delete(db, resource_id, tenant_id, company_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"{self.resource_name} not found"
|
||||||
|
)
|
||||||
|
return None
|
||||||
0
backend/api/v1/modules/__init__.py
Normal file
0
backend/api/v1/modules/__init__.py
Normal file
7
backend/api/v1/modules/core/auth/__init__.py
Normal file
7
backend/api/v1/modules/core/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Módulo de Authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
210
backend/api/v1/modules/core/auth/dto.py
Normal file
210
backend/api/v1/modules/core/auth/dto.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""
|
||||||
|
DTOs para módulo de autenticación
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequestDTO(BaseModel):
|
||||||
|
"""DTO para solicitud de login"""
|
||||||
|
|
||||||
|
username: str = Field(..., description="Usuario o email")
|
||||||
|
password: str = Field(..., min_length=6, description="Contraseña")
|
||||||
|
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
|
||||||
|
# y devuelve la lista de tenants disponibles en lugar de tokens.
|
||||||
|
tenant_slug: Optional[str] = Field(None, description="Slug del tenant")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"username": "usuario@ejemplo.com",
|
||||||
|
"password": "password123",
|
||||||
|
"tenant_slug": "empresa-abc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponseDTO(BaseModel):
|
||||||
|
"""DTO para respuesta de token"""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
tenant: Optional["TenantInfoDTO"] = None
|
||||||
|
tenant_id: Optional[int] = None
|
||||||
|
tenant_slug: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenRequestDTO(BaseModel):
|
||||||
|
"""DTO para solicitud de refresh token"""
|
||||||
|
|
||||||
|
refresh_token: str = Field(..., description="Refresh token")
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfoResponseDTO(BaseModel):
|
||||||
|
"""DTO para información de usuario"""
|
||||||
|
|
||||||
|
sub: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
preferred_username: Optional[str] = None
|
||||||
|
tenant_id: Optional[int] = None
|
||||||
|
tenant_slug: Optional[str] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
roles: list[str] = []
|
||||||
|
permissions: list[str] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"email": "usuario@ejemplo.com",
|
||||||
|
"name": "Juan Pérez",
|
||||||
|
"preferred_username": "jperez",
|
||||||
|
"tenant_id": 1,
|
||||||
|
"roles": ["user", "admin"],
|
||||||
|
"permissions": ["cat_ports.view", "cat_ports.create"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequestDTO(BaseModel):
|
||||||
|
"""DTO para solicitud de logout"""
|
||||||
|
|
||||||
|
refresh_token: str = Field(..., description="Refresh token para invalidar")
|
||||||
|
username: Optional[str] = Field(None, description="Nombre de usuario para auditoría")
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequestDTO(BaseModel):
|
||||||
|
"""DTO para solicitud de registro"""
|
||||||
|
|
||||||
|
username: str = Field(
|
||||||
|
..., min_length=3, max_length=50, description="Nombre de usuario"
|
||||||
|
)
|
||||||
|
email: EmailStr = Field(..., description="Email del usuario")
|
||||||
|
password: str = Field(..., min_length=8, description="Contraseña")
|
||||||
|
first_name: str = Field(..., min_length=2, max_length=50, description="Nombre")
|
||||||
|
last_name: str = Field(..., min_length=2, max_length=50, description="Apellido")
|
||||||
|
tenant_slug: str = Field(..., description="Slug del tenant")
|
||||||
|
invite_token: Optional[str] = Field(None, description="Token de invitación local (opcional)")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"username": "jperez",
|
||||||
|
"email": "jperez@ejemplo.com",
|
||||||
|
"password": "MiPassword123!",
|
||||||
|
"first_name": "Juan",
|
||||||
|
"last_name": "Pérez",
|
||||||
|
"tenant_slug": "empresa-abc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterResponseDTO(BaseModel):
|
||||||
|
"""DTO para respuesta de registro"""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"username": "jperez",
|
||||||
|
"email": "jperez@ejemplo.com",
|
||||||
|
"message": "User registered successfully",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeCodeRequestDTO(BaseModel):
|
||||||
|
"""DTO para intercambiar authorization code por tokens (OAuth2 flow)"""
|
||||||
|
|
||||||
|
code: str = Field(..., description="Authorization code de OAuth2")
|
||||||
|
redirect_uri: str = Field(..., description="Redirect URI usado en la autorización")
|
||||||
|
tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...",
|
||||||
|
"redirect_uri": "http://localhost:5173/auth/callback",
|
||||||
|
"tenant_slug": "empresa-abc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SetCookieRequestDTO(BaseModel):
|
||||||
|
"""DTO para establecer cookies de autenticación"""
|
||||||
|
|
||||||
|
access_token: str = Field(..., description="Access token JWT")
|
||||||
|
refresh_token: str = Field(..., description="Refresh token JWT")
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchTenantRequestDTO(BaseModel):
|
||||||
|
"""DTO para cambiar de tenant estando autenticado"""
|
||||||
|
|
||||||
|
tenant_slug: str = Field(..., description="Slug del tenant destino")
|
||||||
|
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverTenantsRequestDTO(BaseModel):
|
||||||
|
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
|
||||||
|
|
||||||
|
username: str = Field(..., description="Nombre de usuario o email")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"username": "jperez",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TenantInfoDTO(BaseModel):
|
||||||
|
"""Información básica de un tenant para mostrar en el selector de login"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverTenantsResponseDTO(BaseModel):
|
||||||
|
"""Respuesta con los tenants disponibles para un usuario"""
|
||||||
|
|
||||||
|
tenants: list[TenantInfoDTO]
|
||||||
|
|
||||||
|
|
||||||
|
class LoginChoiceResponseDTO(BaseModel):
|
||||||
|
"""
|
||||||
|
Respuesta del login cuando el usuario pertenece a varios tenants.
|
||||||
|
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: str = "choose_tenant"
|
||||||
|
tenants: list[TenantInfoDTO]
|
||||||
|
|
||||||
|
|
||||||
|
class SSOExchangeRequestDTO(BaseModel):
|
||||||
|
"""DTO para canjear el relay token por KC tokens."""
|
||||||
|
|
||||||
|
relay_token: str = Field(..., description="Relay token recibido en la URL")
|
||||||
422
backend/api/v1/modules/core/auth/routes.py
Normal file
422
backend/api/v1/modules/core/auth/routes.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""
|
||||||
|
Endpoints API para autenticación
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
ExchangeCodeRequestDTO,
|
||||||
|
LoginChoiceResponseDTO,
|
||||||
|
LoginRequestDTO,
|
||||||
|
LogoutRequestDTO,
|
||||||
|
RefreshTokenRequestDTO,
|
||||||
|
RegisterRequestDTO,
|
||||||
|
RegisterResponseDTO,
|
||||||
|
SetCookieRequestDTO,
|
||||||
|
SSOExchangeRequestDTO,
|
||||||
|
SwitchTenantRequestDTO,
|
||||||
|
TokenResponseDTO,
|
||||||
|
UserInfoResponseDTO,
|
||||||
|
)
|
||||||
|
from .service import AuthService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/register/check")
|
||||||
|
async def check_register(
|
||||||
|
invite_token: str = Query(..., description="Token de invitación"),
|
||||||
|
tenant_slug: str = Query(..., description="Slug del tenant"),
|
||||||
|
email: str = Query(..., description="Email del usuario invitado"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Valida un token de invitación y verifica si el email ya existe en Keycloak.
|
||||||
|
No consume el token. Responde con user_exists y datos básicos del usuario si ya existe.
|
||||||
|
"""
|
||||||
|
from api.v1.modules.core.invites.service import InviteService
|
||||||
|
import httpx
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
invite_service = InviteService(db)
|
||||||
|
# Valida token (lanza 403 si es inválido)
|
||||||
|
invite_result = invite_service.validate(invite_token, tenant_slug, email)
|
||||||
|
|
||||||
|
# Intentar verificar si el email ya existe en el Hub usando service account
|
||||||
|
user_exists = False
|
||||||
|
user_info: dict = {}
|
||||||
|
if settings.HUB_ADMIN_EMAIL and settings.HUB_ADMIN_PASSWORD:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
# Login con service account
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/login",
|
||||||
|
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||||
|
)
|
||||||
|
if login_resp.status_code == 200:
|
||||||
|
svc_token = login_resp.json().get("access_token", "")
|
||||||
|
if svc_token:
|
||||||
|
# Buscar admin por email
|
||||||
|
admins_resp = await client.get(
|
||||||
|
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||||
|
params={"email": email},
|
||||||
|
headers={"Authorization": f"Bearer {svc_token}"},
|
||||||
|
)
|
||||||
|
if admins_resp.status_code == 200:
|
||||||
|
admins = admins_resp.json()
|
||||||
|
if isinstance(admins, list):
|
||||||
|
matches = [a for a in admins if a.get("email", "").lower() == email.lower()]
|
||||||
|
elif isinstance(admins, dict) and "items" in admins:
|
||||||
|
matches = [a for a in admins["items"] if a.get("email", "").lower() == email.lower()]
|
||||||
|
else:
|
||||||
|
matches = []
|
||||||
|
if matches:
|
||||||
|
user_exists = True
|
||||||
|
a = matches[0]
|
||||||
|
user_info = {
|
||||||
|
"username": a.get("username", ""),
|
||||||
|
"first_name": a.get("first_name", ""),
|
||||||
|
"last_name": a.get("last_name", ""),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning("register/check Hub lookup failed: %s", exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": invite_result.email,
|
||||||
|
"role": invite_result.role,
|
||||||
|
"user_exists": user_exists,
|
||||||
|
**user_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=RegisterResponseDTO, status_code=201)
|
||||||
|
async def register(
|
||||||
|
register_data: RegisterRequestDTO, db: Session = Depends(get_core_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Registra un nuevo usuario en Keycloak
|
||||||
|
|
||||||
|
El usuario debe proporcionar:
|
||||||
|
- username: Nombre de usuario único
|
||||||
|
- email: Email único
|
||||||
|
- password: Contraseña (mínimo 8 caracteres)
|
||||||
|
- first_name: Nombre
|
||||||
|
- last_name: Apellido
|
||||||
|
- tenant_slug: Slug del tenant al que pertenece
|
||||||
|
|
||||||
|
El usuario se crea automáticamente en Keycloak con:
|
||||||
|
- Cuenta habilitada
|
||||||
|
- Rol 'user' asignado por defecto
|
||||||
|
- Atributos de tenant
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.register(register_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=None)
|
||||||
|
async def login(
|
||||||
|
login_data: LoginRequestDTO,
|
||||||
|
request: Request, # Inject Request
|
||||||
|
db: Session = Depends(get_core_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Autentica usuario con Keycloak y retorna tokens JWT
|
||||||
|
|
||||||
|
El usuario debe proporcionar:
|
||||||
|
- username: Usuario o email
|
||||||
|
- password: Contraseña
|
||||||
|
- tenant_slug: Slug del tenant al que pertenece
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
return await service.login(
|
||||||
|
login_data=login_data,
|
||||||
|
ip_address=request.client.host,
|
||||||
|
user_agent=request.headers.get("user-agent")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/switch-tenant", response_model=TokenResponseDTO)
|
||||||
|
async def switch_tenant(
|
||||||
|
data: SwitchTenantRequestDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
|
||||||
|
|
||||||
|
Requiere:
|
||||||
|
- Authorization: Bearer <access_token> (para identificar al usuario)
|
||||||
|
- Body: { tenant_slug, refresh_token }
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
# Obtener info del usuario desde el access token actual
|
||||||
|
user_info = await service.get_user_info(credentials.credentials)
|
||||||
|
|
||||||
|
keycloak_user_id = user_info.sub
|
||||||
|
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
|
||||||
|
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
|
||||||
|
# Todos los tenants comparten el mismo realm en esta arquitectura.
|
||||||
|
from api.v1.modules.core.tenants.models import Tenant
|
||||||
|
from core.database import get_core_db as _gcdb
|
||||||
|
# Obtener el realm del tenant destino (o default)
|
||||||
|
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
return await service.switch_tenant(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
keycloak_realm=tenant.keycloak_realm,
|
||||||
|
tenant_slug=data.tenant_slug,
|
||||||
|
refresh_token=data.refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponseDTO)
|
||||||
|
async def refresh_token(
|
||||||
|
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Refresca el access token usando el refresh token
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.refresh_token(refresh_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserInfoResponseDTO)
|
||||||
|
async def get_current_user_info(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene información del usuario actual desde el token
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.get_user_info(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lazy-link", status_code=200)
|
||||||
|
async def lazy_link(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Vincula un invite pendiente al usuario autenticado (lazy-link).
|
||||||
|
Se llama después de un SSO login desde el workspace para crear el UserTenant
|
||||||
|
si hay un invite_token pendiente para el email del usuario.
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
await service._link_pending_invite(
|
||||||
|
credentials.credentials, # username_or_email = token (fallback)
|
||||||
|
access_token=credentials.credentials,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
claims = service._decode_kc_user_from_token(credentials.credentials)
|
||||||
|
service._backfill_company_roles(claims.get("sub", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(
|
||||||
|
logout_data: LogoutRequestDTO,
|
||||||
|
request: Request, # Inject request for IP/User-Agent
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
# Make current_user optional to avoid 401 on expired tokens
|
||||||
|
# We will try to use it if available, otherwise use DTO
|
||||||
|
# Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency.
|
||||||
|
# Instead, we will rely on DTO username since user explicitly asked for this simplified flow.
|
||||||
|
# But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens.
|
||||||
|
# So we remove the strict dependency for now as per "simplified" request.
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cierra sesión invalidando el refresh token
|
||||||
|
"""
|
||||||
|
# Extract info for logging (optional, but harmless to keep providing context if needed,
|
||||||
|
# but strictly speaking we can revert to just calling service)
|
||||||
|
# The original file likely didn't have IP extraction here unless I added it.
|
||||||
|
# I'll keep it simple.
|
||||||
|
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.logout(logout_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/exchange-code", response_model=TokenResponseDTO)
|
||||||
|
async def exchange_code(
|
||||||
|
exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Intercambia un authorization code de OAuth2 por tokens
|
||||||
|
|
||||||
|
Este endpoint es útil cuando el frontend usa el flujo de autorización
|
||||||
|
con proveedores externos (Microsoft, Google, etc.) a través de Keycloak.
|
||||||
|
|
||||||
|
El código se obtiene después de que el usuario se autentica con el proveedor
|
||||||
|
externo y Keycloak lo redirige al frontend con el código en los query params.
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
return await service.exchange_code(exchange_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-cookie")
|
||||||
|
async def set_cookie(
|
||||||
|
cookie_data: SetCookieRequestDTO,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Establece cookies HttpOnly con los tokens de autenticación
|
||||||
|
|
||||||
|
Este endpoint se llama desde el frontend después de una autenticación
|
||||||
|
SSO exitosa para establecer las cookies de sesión necesarias para
|
||||||
|
la validación server-side en los layouts protegidos.
|
||||||
|
|
||||||
|
Las cookies se configuran como:
|
||||||
|
- HttpOnly: No accesibles desde JavaScript (mayor seguridad)
|
||||||
|
- Secure: Solo se envían por HTTPS (en producción)
|
||||||
|
- SameSite=Lax: Protección contra CSRF
|
||||||
|
- Max-Age: Tiempo de vida del token
|
||||||
|
"""
|
||||||
|
# Validar que los tokens sean válidos decodificándolos
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
# Validar el access token
|
||||||
|
user_info = await service.get_user_info(cookie_data.access_token)
|
||||||
|
|
||||||
|
# Establecer las cookies
|
||||||
|
# Access token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=cookie_data.access_token,
|
||||||
|
httponly=True, # No accesible desde JavaScript
|
||||||
|
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||||
|
samesite="lax", # Protección CSRF
|
||||||
|
max_age=3600, # 1 hora (ajustar según configuración del token)
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=cookie_data.refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400, # 24 horas (ajustar según configuración del token)
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Cookies establecidas correctamente",
|
||||||
|
"user": user_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sso-exchange", response_model=TokenResponseDTO)
|
||||||
|
async def sso_exchange(
|
||||||
|
body: SSOExchangeRequestDTO,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
|
||||||
|
Llamado server-side desde la página /auth/sso del frontend.
|
||||||
|
Establece cookies HttpOnly con los tokens y devuelve el resultado.
|
||||||
|
"""
|
||||||
|
service = AuthService(db)
|
||||||
|
tokens = await service.sso_exchange(body.relay_token)
|
||||||
|
|
||||||
|
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=tokens.access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=_is_prod,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=3600,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=tokens.refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=_is_prod,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dev-only local auth — solo disponible cuando DEV_LOCAL_AUTH=True
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/dev-login")
|
||||||
|
async def dev_login():
|
||||||
|
"""
|
||||||
|
Genera un token local firmado con SECRET_KEY para desarrollo sin Keycloak/Hub.
|
||||||
|
Disponible únicamente cuando DEV_LOCAL_AUTH=True en el entorno.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from jose import jwt as jose_jwt
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
if not settings.DEV_LOCAL_AUTH:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": "dev-local-user",
|
||||||
|
"email": settings.DEV_LOCAL_AUTH_EMAIL,
|
||||||
|
"preferred_username": settings.DEV_LOCAL_AUTH_EMAIL.split("@")[0],
|
||||||
|
"name": settings.DEV_LOCAL_AUTH_NAME,
|
||||||
|
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
|
||||||
|
"tenant_slug": "dev",
|
||||||
|
"company_id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
|
||||||
|
"roles": ["super_admin"],
|
||||||
|
"permissions": [],
|
||||||
|
"allowed_systems": ["fixed_asset", "inventory"],
|
||||||
|
"dev_local": True,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(hours=8),
|
||||||
|
}
|
||||||
|
token = jose_jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-companies")
|
||||||
|
async def get_my_companies(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retorna las compañías accesibles para el usuario actual.
|
||||||
|
STUB: implementa con tu modelo de compañías.
|
||||||
|
En dev-local retorna una compañía ficticia para que el dashboard funcione.
|
||||||
|
"""
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
if settings.DEV_LOCAL_AUTH and current_user.get("dev_local"):
|
||||||
|
return [{
|
||||||
|
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
|
||||||
|
"name": "Empresa Dev Local",
|
||||||
|
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
|
||||||
|
"is_active": True,
|
||||||
|
}]
|
||||||
|
|
||||||
|
# Implementa aquí la consulta real a tu tabla de compañías.
|
||||||
|
return []
|
||||||
847
backend/api/v1/modules/core/auth/service.py
Normal file
847
backend/api/v1/modules/core/auth/service.py
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
LoginRequestDTO,
|
||||||
|
LogoutRequestDTO,
|
||||||
|
RefreshTokenRequestDTO,
|
||||||
|
TokenResponseDTO,
|
||||||
|
UserInfoResponseDTO,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Servicio de autenticación centralizado vía Hub"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_text(value: Any) -> Optional[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
cleaned = value.strip()
|
||||||
|
if cleaned:
|
||||||
|
return cleaned
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _pick_text(self, *candidates: Any) -> Optional[str]:
|
||||||
|
for candidate in candidates:
|
||||||
|
value = self._clean_text(candidate)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
claims = jwt.get_unverified_claims(access_token)
|
||||||
|
return claims if isinstance(claims, dict) else {}
|
||||||
|
except JWTError:
|
||||||
|
return {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fallback de datos de usuario consultando el Hub admin API.
|
||||||
|
Es opcional y no debe romper /me si falla.
|
||||||
|
"""
|
||||||
|
if not keycloak_user_id:
|
||||||
|
return None
|
||||||
|
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"username": settings.HUB_ADMIN_EMAIL,
|
||||||
|
"password": settings.HUB_ADMIN_PASSWORD,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
|
admin_token = login_resp.json().get("access_token")
|
||||||
|
if not admin_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_resp = await client.get(
|
||||||
|
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
|
||||||
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
|
)
|
||||||
|
if user_resp.status_code == 200:
|
||||||
|
payload = user_resp.json()
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("kc_admin_user_lookup_failed: %s", exc)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
|
||||||
|
for source in sources:
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
direct = self._pick_text(
|
||||||
|
source.get("avatar_url"),
|
||||||
|
source.get("avatarUrl"),
|
||||||
|
source.get("picture"),
|
||||||
|
source.get("photo"),
|
||||||
|
)
|
||||||
|
if direct:
|
||||||
|
return direct
|
||||||
|
|
||||||
|
attrs = source.get("attributes")
|
||||||
|
if isinstance(attrs, dict):
|
||||||
|
attr_candidate = attrs.get("avatar_url")
|
||||||
|
if isinstance(attr_candidate, list) and attr_candidate:
|
||||||
|
value = self._clean_text(attr_candidate[0])
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
if isinstance(attr_candidate, str):
|
||||||
|
value = self._clean_text(attr_candidate)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
self,
|
||||||
|
login_data: LoginRequestDTO,
|
||||||
|
ip_address: str = None,
|
||||||
|
user_agent: str = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Autentica usuario a través del Hub y obtiene tokens.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/login",
|
||||||
|
json=login_data.model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
|
||||||
|
if "tenants" in data:
|
||||||
|
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
|
||||||
|
return LoginChoiceResponseDTO(
|
||||||
|
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Si devolvió tokens — lazy-link: verificar si hay invite pendiente
|
||||||
|
try:
|
||||||
|
await self._link_pending_invite(login_data.username)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
|
||||||
|
|
||||||
|
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
|
||||||
|
try:
|
||||||
|
login_sub = data.get("sub") or data.get("user_id")
|
||||||
|
if not login_sub:
|
||||||
|
claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||||
|
login_sub = claims.get("sub")
|
||||||
|
self._backfill_company_roles(login_sub)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)
|
||||||
|
|
||||||
|
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
|
||||||
|
# No bloquea login si Workspace no responde.
|
||||||
|
access_token = data.get("access_token")
|
||||||
|
if access_token:
|
||||||
|
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||||
|
from core.workspace_profile_client import WorkspaceProfileClient
|
||||||
|
|
||||||
|
workspace_profile = None
|
||||||
|
try:
|
||||||
|
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"workspace_profile_sync_failed",
|
||||||
|
extra={
|
||||||
|
"event": "workspace_profile_sync_failed",
|
||||||
|
"phase": "login",
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
workspace_profile = None
|
||||||
|
|
||||||
|
await sync_workspace_profile_for_user(
|
||||||
|
self.db,
|
||||||
|
access_token=access_token,
|
||||||
|
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||||
|
or data.get("sub")
|
||||||
|
or data.get("user_id"),
|
||||||
|
tenant_id=data.get("tenant_id"),
|
||||||
|
workspace_profile=workspace_profile,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# AUDIT LOG: implementa tu servicio de auditoría aquí si lo necesitas.
|
||||||
|
|
||||||
|
return TokenResponseDTO(**data)
|
||||||
|
|
||||||
|
# Pasar el mensaje de error real del Hub al cliente
|
||||||
|
try:
|
||||||
|
hub_detail = response.json().get("detail", None)
|
||||||
|
except Exception:
|
||||||
|
hub_detail = None
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
|
||||||
|
|
||||||
|
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Hub unreachable during login: {str(e)}")
|
||||||
|
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected login error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Authentication error")
|
||||||
|
|
||||||
|
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||||
|
"""
|
||||||
|
Refresca el access token usando el Hub
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||||
|
json=refresh_data.model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||||
|
from core.workspace_profile_client import WorkspaceProfileClient
|
||||||
|
|
||||||
|
workspace_profile = None
|
||||||
|
try:
|
||||||
|
workspace_profile = await WorkspaceProfileClient().get_me(
|
||||||
|
data.get("access_token", "")
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"workspace_profile_sync_failed",
|
||||||
|
extra={
|
||||||
|
"event": "workspace_profile_sync_failed",
|
||||||
|
"phase": "refresh",
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
workspace_profile = None
|
||||||
|
|
||||||
|
await sync_workspace_profile_for_user(
|
||||||
|
self.db,
|
||||||
|
access_token=data.get("access_token"),
|
||||||
|
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||||
|
or data.get("sub")
|
||||||
|
or data.get("user_id"),
|
||||||
|
tenant_id=data.get("tenant_id"),
|
||||||
|
workspace_profile=workspace_profile,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
return TokenResponseDTO(**data)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token refresh error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Token refresh error")
|
||||||
|
|
||||||
|
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
|
||||||
|
"""
|
||||||
|
Obtiene información del usuario desde el Hub
|
||||||
|
"""
|
||||||
|
from core.security import verify_token
|
||||||
|
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||||
|
# Aprovechamos la verificación (y cache) de security.py
|
||||||
|
user_info = await verify_token(access_token)
|
||||||
|
|
||||||
|
kc_user = self._decode_kc_user_from_token(access_token)
|
||||||
|
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
|
||||||
|
|
||||||
|
needs_admin_fallback = any(
|
||||||
|
not self._clean_text(user_info.get(field))
|
||||||
|
for field in ("email", "preferred_username")
|
||||||
|
) or self._extract_avatar_url(user_info) is None
|
||||||
|
|
||||||
|
kc_admin_user = None
|
||||||
|
if needs_admin_fallback:
|
||||||
|
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
|
||||||
|
|
||||||
|
first_name = self._pick_text(
|
||||||
|
user_info.get("first_name"),
|
||||||
|
user_info.get("given_name"),
|
||||||
|
kc_user.get("given_name"),
|
||||||
|
kc_user.get("first_name"),
|
||||||
|
(kc_admin_user or {}).get("firstName"),
|
||||||
|
(kc_admin_user or {}).get("first_name"),
|
||||||
|
)
|
||||||
|
last_name = self._pick_text(
|
||||||
|
user_info.get("last_name"),
|
||||||
|
user_info.get("family_name"),
|
||||||
|
kc_user.get("family_name"),
|
||||||
|
kc_user.get("last_name"),
|
||||||
|
(kc_admin_user or {}).get("lastName"),
|
||||||
|
(kc_admin_user or {}).get("last_name"),
|
||||||
|
)
|
||||||
|
full_name = self._pick_text(
|
||||||
|
f"{first_name} {last_name}" if first_name and last_name else None,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched_user_info = dict(user_info)
|
||||||
|
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
|
||||||
|
enriched_user_info["email"] = self._pick_text(
|
||||||
|
user_info.get("email"),
|
||||||
|
(kc_admin_user or {}).get("email"),
|
||||||
|
kc_user.get("email"),
|
||||||
|
)
|
||||||
|
enriched_user_info["preferred_username"] = self._pick_text(
|
||||||
|
user_info.get("preferred_username"),
|
||||||
|
user_info.get("username"),
|
||||||
|
kc_user.get("preferred_username"),
|
||||||
|
kc_user.get("username"),
|
||||||
|
(kc_admin_user or {}).get("username"),
|
||||||
|
)
|
||||||
|
enriched_user_info["avatar_url"] = self._extract_avatar_url(
|
||||||
|
user_info,
|
||||||
|
kc_user,
|
||||||
|
kc_admin_user or {},
|
||||||
|
)
|
||||||
|
enriched_user_info["name"] = self._pick_text(
|
||||||
|
user_info.get("name"),
|
||||||
|
full_name,
|
||||||
|
kc_user.get("name"),
|
||||||
|
enriched_user_info.get("preferred_username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await sync_workspace_profile_for_user(
|
||||||
|
self.db,
|
||||||
|
access_token=access_token,
|
||||||
|
keycloak_user_id=enriched_user_info.get("sub"),
|
||||||
|
tenant_id=enriched_user_info.get("tenant_id"),
|
||||||
|
workspace_profile=enriched_user_info,
|
||||||
|
)
|
||||||
|
return UserInfoResponseDTO(**enriched_user_info)
|
||||||
|
|
||||||
|
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
|
||||||
|
"""
|
||||||
|
Cierra sesión a través del Hub
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/logout",
|
||||||
|
json=logout_data.model_dump()
|
||||||
|
)
|
||||||
|
return {"message": "Logged out successfully"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Logout error: {str(e)}")
|
||||||
|
return {"message": "Logged out"}
|
||||||
|
|
||||||
|
async def register(self, register_data: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Registra un usuario.
|
||||||
|
- Si trae invite_token: valida el token local, crea usuario en Hub y
|
||||||
|
genera la fila UserTenant local, luego consume el token.
|
||||||
|
- Si no trae invite_token: reenvía directamente al Hub (flujo original).
|
||||||
|
"""
|
||||||
|
if getattr(register_data, "invite_token", None):
|
||||||
|
return await self._register_with_invite(register_data)
|
||||||
|
|
||||||
|
# Flujo original — reenviar al Hub sin invite_token
|
||||||
|
try:
|
||||||
|
payload = register_data.model_dump(exclude={"invite_token"})
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/register",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
return response.json()
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=response.text)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Registration error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Registration error")
|
||||||
|
|
||||||
|
async def _register_with_invite(self, register_data: Any) -> Any:
|
||||||
|
"""Flujo de registro con token de invitación local."""
|
||||||
|
from api.v1.modules.core.invites.service import InviteService
|
||||||
|
from api.v1.modules.core.tenants.models import Tenant
|
||||||
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||||
|
|
||||||
|
invite_service = InviteService(self.db)
|
||||||
|
|
||||||
|
# 1. Validar invite token (sin consumir)
|
||||||
|
invite_result = invite_service.validate(
|
||||||
|
register_data.invite_token,
|
||||||
|
register_data.tenant_slug,
|
||||||
|
str(register_data.email),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Buscar tenant local
|
||||||
|
tenant = (
|
||||||
|
self.db.query(Tenant)
|
||||||
|
.filter(Tenant.slug == register_data.tenant_slug)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant no encontrado")
|
||||||
|
|
||||||
|
# 3. Obtener token de service account y gestionar usuario en Hub
|
||||||
|
hub_user_id = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
# Login con service account
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/login",
|
||||||
|
json={
|
||||||
|
"username": settings.HUB_ADMIN_EMAIL,
|
||||||
|
"password": settings.HUB_ADMIN_PASSWORD,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación")
|
||||||
|
svc_token = login_resp.json().get("access_token", "")
|
||||||
|
|
||||||
|
# Verificar si el usuario ya existe en el Hub
|
||||||
|
search_resp = await client.get(
|
||||||
|
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||||
|
params={"email": str(register_data.email)},
|
||||||
|
headers={"Authorization": f"Bearer {svc_token}"},
|
||||||
|
)
|
||||||
|
existing_user = None
|
||||||
|
if search_resp.status_code == 200:
|
||||||
|
admins = search_resp.json()
|
||||||
|
items = admins if isinstance(admins, list) else admins.get("items", [])
|
||||||
|
matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()]
|
||||||
|
if matches:
|
||||||
|
existing_user = matches[0]
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
# Usuario ya existe — solo vinculamos (no creamos nuevo)
|
||||||
|
hub_user_id = existing_user.get("id")
|
||||||
|
else:
|
||||||
|
# Crear usuario via admin endpoint (no requiere invite_token)
|
||||||
|
hub_payload = {
|
||||||
|
"username": register_data.username,
|
||||||
|
"email": str(register_data.email),
|
||||||
|
"password": register_data.password,
|
||||||
|
"first_name": register_data.first_name,
|
||||||
|
"last_name": register_data.last_name,
|
||||||
|
"tenant_slug": register_data.tenant_slug,
|
||||||
|
}
|
||||||
|
create_resp = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||||
|
json=hub_payload,
|
||||||
|
headers={"Authorization": f"Bearer {svc_token}"},
|
||||||
|
)
|
||||||
|
if create_resp.status_code in (200, 201):
|
||||||
|
hub_user_id = create_resp.json().get("id")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
detail = create_resp.json().get("detail", create_resp.text)
|
||||||
|
except Exception:
|
||||||
|
detail = create_resp.text
|
||||||
|
raise HTTPException(status_code=create_resp.status_code, detail=detail)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Hub admin create error during invite flow: %s", exc)
|
||||||
|
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
|
||||||
|
|
||||||
|
# 4. Crear fila UserTenant y UserCompanyRole local
|
||||||
|
if hub_user_id and invite_result.company_id:
|
||||||
|
try:
|
||||||
|
ut = UserTenant(
|
||||||
|
keycloak_user_id=hub_user_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
company_id=invite_result.company_id,
|
||||||
|
role=invite_result.role,
|
||||||
|
is_active=True,
|
||||||
|
first_name=register_data.first_name,
|
||||||
|
last_name=register_data.last_name,
|
||||||
|
)
|
||||||
|
self.db.add(ut)
|
||||||
|
self.db.flush()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not create UserTenant (may already exist): %s", exc)
|
||||||
|
self.db.rollback()
|
||||||
|
|
||||||
|
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
|
||||||
|
try:
|
||||||
|
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
|
||||||
|
company_role_obj = (
|
||||||
|
self.db.query(CompanyRole)
|
||||||
|
.filter(
|
||||||
|
CompanyRole.code == invite_result.role,
|
||||||
|
CompanyRole.company_id == invite_result.company_id,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if company_role_obj:
|
||||||
|
existing_ucr = (
|
||||||
|
self.db.query(UserCompanyRole)
|
||||||
|
.filter(
|
||||||
|
UserCompanyRole.user_id == hub_user_id,
|
||||||
|
UserCompanyRole.company_role_id == company_role_obj.id,
|
||||||
|
UserCompanyRole.company_id == invite_result.company_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing_ucr:
|
||||||
|
ucr = UserCompanyRole(
|
||||||
|
user_id=hub_user_id,
|
||||||
|
company_role_id=company_role_obj.id,
|
||||||
|
company_id=invite_result.company_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(ucr)
|
||||||
|
self.db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
|
||||||
|
self.db.rollback()
|
||||||
|
|
||||||
|
# 5. Consumir invite token
|
||||||
|
invite_service.consume_by_id(invite_result.invite_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": hub_user_id or "",
|
||||||
|
"username": register_data.username,
|
||||||
|
"email": str(register_data.email),
|
||||||
|
"message": "Usuario registrado exitosamente",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
|
||||||
|
"""
|
||||||
|
Intercambia código por tokens a través del Hub
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/exchange-code",
|
||||||
|
json=exchange_data.model_dump()
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
# Lazy-link: crear UserTenant si hay invite pendiente
|
||||||
|
try:
|
||||||
|
await self._link_pending_invite("", access_token=data.get("access_token", ""))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
|
||||||
|
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
|
||||||
|
try:
|
||||||
|
ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||||
|
self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
|
||||||
|
return TokenResponseDTO(**data)
|
||||||
|
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Exchange code error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Exchange code error")
|
||||||
|
|
||||||
|
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
|
||||||
|
"""
|
||||||
|
Cambia de tenant a través del Hub
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
|
||||||
|
json=kwargs
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return TokenResponseDTO(**response.json())
|
||||||
|
raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Switch tenant error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Switch tenant error")
|
||||||
|
|
||||||
|
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
|
||||||
|
"""
|
||||||
|
Canjea un relay token de un solo uso por KC tokens.
|
||||||
|
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
|
||||||
|
json={"relay_token": relay_token},
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
# Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
|
||||||
|
try:
|
||||||
|
await self._link_pending_invite("", access_token=data.get("access_token", ""))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
|
||||||
|
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
|
||||||
|
try:
|
||||||
|
sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||||
|
self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
|
||||||
|
return TokenResponseDTO(
|
||||||
|
access_token=data["access_token"],
|
||||||
|
refresh_token=data["refresh_token"],
|
||||||
|
token_type=data.get("token_type", "bearer"),
|
||||||
|
expires_in=data.get("expires_in", 3600),
|
||||||
|
tenant_id=data.get("tenant_id"),
|
||||||
|
tenant_slug=data.get("tenant_slug"),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=response.json().get("detail", "SSO exchange failed"),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSO exchange error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="SSO exchange error")
|
||||||
|
|
||||||
|
async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
|
||||||
|
"""
|
||||||
|
Lazy-link: después de un login exitoso comprueba si existe un invite_token
|
||||||
|
pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
|
||||||
|
y consume el token.
|
||||||
|
|
||||||
|
Si se provee access_token, extrae hub_user_id y email directamente del JWT
|
||||||
|
sin necesidad de un lookup extra al Hub.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from api.v1.modules.core.invites.models import InviteToken
|
||||||
|
from api.v1.modules.core.tenants.models import Tenant
|
||||||
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||||
|
|
||||||
|
hub_user_id = None
|
||||||
|
user_email = username_or_email
|
||||||
|
|
||||||
|
# Si tenemos el access_token, extraer info del JWT directamente (sin red)
|
||||||
|
if access_token:
|
||||||
|
try:
|
||||||
|
claims = self._decode_kc_user_from_token(access_token)
|
||||||
|
hub_user_id = claims.get("sub")
|
||||||
|
user_email = claims.get("email") or username_or_email
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("_link_pending_invite: JWT decode failed: %s", exc)
|
||||||
|
|
||||||
|
# Sin access_token: buscar usuario en el Hub vía service account
|
||||||
|
if not hub_user_id:
|
||||||
|
if not user_email:
|
||||||
|
return # Sin email ni hub_user_id no podemos buscar el invite
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/login",
|
||||||
|
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||||
|
)
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
return
|
||||||
|
svc_token = login_resp.json().get("access_token", "")
|
||||||
|
search_resp = await client.get(
|
||||||
|
f"{settings.HUB_URL}api/v1/hub/admins",
|
||||||
|
params={"email": username_or_email},
|
||||||
|
headers={"Authorization": f"Bearer {svc_token}"},
|
||||||
|
)
|
||||||
|
if search_resp.status_code == 200:
|
||||||
|
items = search_resp.json()
|
||||||
|
items = items if isinstance(items, list) else items.get("items", [])
|
||||||
|
matches = [
|
||||||
|
u for u in items
|
||||||
|
if u.get("email", "").lower() == username_or_email.lower()
|
||||||
|
or u.get("username", "").lower() == username_or_email.lower()
|
||||||
|
]
|
||||||
|
if matches:
|
||||||
|
hub_user_id = matches[0].get("id")
|
||||||
|
user_email = matches[0].get("email", username_or_email)
|
||||||
|
if not hub_user_id:
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
pending = (
|
||||||
|
self.db.query(InviteToken)
|
||||||
|
.filter(
|
||||||
|
InviteToken.email == user_email,
|
||||||
|
InviteToken.used_at.is_(None),
|
||||||
|
InviteToken.expires_at > now,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
|
||||||
|
tenant = (
|
||||||
|
self.db.query(Tenant)
|
||||||
|
.filter(Tenant.slug == pending.tenant_slug)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not tenant:
|
||||||
|
logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Evitar duplicados
|
||||||
|
existing = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == hub_user_id,
|
||||||
|
UserTenant.tenant_id == tenant.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
# Vincular existe, solo consumir el token
|
||||||
|
pending.used_at = now
|
||||||
|
self.db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
ut = UserTenant(
|
||||||
|
keycloak_user_id=hub_user_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
company_id=pending.company_id,
|
||||||
|
role=pending.role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(ut)
|
||||||
|
self.db.flush()
|
||||||
|
logger.info(
|
||||||
|
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
|
||||||
|
hub_user_id,
|
||||||
|
tenant.slug,
|
||||||
|
pending.role,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
|
||||||
|
self.db.rollback()
|
||||||
|
|
||||||
|
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
|
||||||
|
if pending.company_id:
|
||||||
|
try:
|
||||||
|
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
|
||||||
|
company_role_obj = (
|
||||||
|
self.db.query(CompanyRole)
|
||||||
|
.filter(
|
||||||
|
CompanyRole.code == pending.role,
|
||||||
|
CompanyRole.company_id == pending.company_id,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if company_role_obj:
|
||||||
|
existing_ucr = (
|
||||||
|
self.db.query(UserCompanyRole)
|
||||||
|
.filter(
|
||||||
|
UserCompanyRole.user_id == hub_user_id,
|
||||||
|
UserCompanyRole.company_role_id == company_role_obj.id,
|
||||||
|
UserCompanyRole.company_id == pending.company_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing_ucr:
|
||||||
|
ucr = UserCompanyRole(
|
||||||
|
user_id=hub_user_id,
|
||||||
|
company_role_id=company_role_obj.id,
|
||||||
|
company_id=pending.company_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(ucr)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)
|
||||||
|
|
||||||
|
pending.used_at = now
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def _backfill_company_roles(self, hub_user_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
|
||||||
|
pero no UserCompanyRole (creados antes del fix del flujo de invitación).
|
||||||
|
Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
|
||||||
|
el UserCompanyRole si no existe. Non-blocking.
|
||||||
|
"""
|
||||||
|
if not hub_user_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||||
|
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
|
||||||
|
|
||||||
|
user_tenants = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == hub_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
UserTenant.company_id.isnot(None),
|
||||||
|
UserTenant.role.isnot(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
for ut in user_tenants:
|
||||||
|
company_role_obj = (
|
||||||
|
self.db.query(CompanyRole)
|
||||||
|
.filter(
|
||||||
|
CompanyRole.code == ut.role,
|
||||||
|
CompanyRole.company_id == ut.company_id,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not company_role_obj:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
self.db.query(UserCompanyRole)
|
||||||
|
.filter(
|
||||||
|
UserCompanyRole.user_id == hub_user_id,
|
||||||
|
UserCompanyRole.company_role_id == company_role_obj.id,
|
||||||
|
UserCompanyRole.company_id == ut.company_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
self.db.add(UserCompanyRole(
|
||||||
|
user_id=hub_user_id,
|
||||||
|
company_role_id=company_role_obj.id,
|
||||||
|
company_id=ut.company_id,
|
||||||
|
tenant_id=ut.tenant_id,
|
||||||
|
is_active=True,
|
||||||
|
))
|
||||||
|
changed = True
|
||||||
|
logger.info(
|
||||||
|
"backfill: UserCompanyRole created for user=%s company=%s role=%s",
|
||||||
|
hub_user_id, ut.company_id, ut.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
self.db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
|
||||||
|
self.db.rollback()
|
||||||
|
|
||||||
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Módulo de dashboard para estadísticas y métricas empresariales
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
109
backend/api/v1/modules/core/dashboard/dto.py
Normal file
109
backend/api/v1/modules/core/dashboard/dto.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
DTOs para el dashboard de estadísticas y métricas empresariales
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class KPIMetric(BaseModel):
|
||||||
|
"""Métrica individual de KPI"""
|
||||||
|
|
||||||
|
label: str = Field(..., description="Nombre del indicador")
|
||||||
|
value: int | float = Field(..., description="Valor actual")
|
||||||
|
previous_value: Optional[int | float] = Field(
|
||||||
|
None, description="Valor anterior para comparación"
|
||||||
|
)
|
||||||
|
percentage_change: Optional[float] = Field(None, description="Porcentaje de cambio")
|
||||||
|
trend: Optional[str] = Field(None, description="up, down, stable")
|
||||||
|
unit: Optional[str] = Field(None, description="Unidad de medida (%, USD, etc)")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityItem(BaseModel):
|
||||||
|
"""Item de actividad reciente"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
type: str = Field(
|
||||||
|
..., description="Tipo de actividad: invoice, pedimento, client, etc"
|
||||||
|
)
|
||||||
|
title: str = Field(..., description="Título descriptivo")
|
||||||
|
description: Optional[str] = Field(None, description="Descripción adicional")
|
||||||
|
timestamp: datetime
|
||||||
|
status: Optional[str] = Field(None, description="Estado del item")
|
||||||
|
icon: Optional[str] = Field(None, description="Icono a mostrar")
|
||||||
|
|
||||||
|
|
||||||
|
class ChartDataPoint(BaseModel):
|
||||||
|
"""Punto de datos para gráficas"""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
value: float
|
||||||
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
"""Estadísticas generales del dashboard"""
|
||||||
|
|
||||||
|
# KPIs principales
|
||||||
|
total_invoices: KPIMetric
|
||||||
|
total_pedimentos: KPIMetric
|
||||||
|
total_clients: KPIMetric
|
||||||
|
total_providers: KPIMetric
|
||||||
|
active_items: KPIMetric
|
||||||
|
pending_approvals: KPIMetric
|
||||||
|
|
||||||
|
# Estadísticas financieras
|
||||||
|
total_value_imports: Optional[float] = Field(
|
||||||
|
None, description="Valor total de importaciones"
|
||||||
|
)
|
||||||
|
total_value_exports: Optional[float] = Field(
|
||||||
|
None, description="Valor total de exportaciones"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Datos para gráficas
|
||||||
|
invoices_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||||
|
pedimentos_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||||
|
operations_by_type: List[ChartDataPoint] = Field(default_factory=list)
|
||||||
|
top_clients: List[ChartDataPoint] = Field(default_factory=list)
|
||||||
|
top_providers: List[ChartDataPoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Actividad reciente
|
||||||
|
recent_activity: List[ActivityItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
company_id: int
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsOverview(BaseModel):
|
||||||
|
"""Vista general de operaciones"""
|
||||||
|
|
||||||
|
total_operations: int
|
||||||
|
by_type: Dict[str, int] = Field(default_factory=dict)
|
||||||
|
by_status: Dict[str, int] = Field(default_factory=dict)
|
||||||
|
avg_processing_time: Optional[float] = Field(
|
||||||
|
None, description="Tiempo promedio en días"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryMetrics(BaseModel):
|
||||||
|
"""Métricas de inventario"""
|
||||||
|
|
||||||
|
total_items: int
|
||||||
|
items_in_stock: int
|
||||||
|
items_low_stock: int
|
||||||
|
total_value: Optional[float] = None
|
||||||
|
by_category: Dict[str, int] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceMetrics(BaseModel):
|
||||||
|
"""Métricas de cumplimiento normativo"""
|
||||||
|
|
||||||
|
pending_documents: int
|
||||||
|
expired_permits: int
|
||||||
|
upcoming_deadlines: int
|
||||||
|
compliance_score: Optional[float] = Field(
|
||||||
|
None, description="Score de cumplimiento 0-100"
|
||||||
|
)
|
||||||
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Endpoints del dashboard para estadísticas y métricas empresariales
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, validate_access_to_resource
|
||||||
|
|
||||||
|
from .dto import DashboardStats, OperationsOverview, InventoryMetrics
|
||||||
|
from .service import DashboardService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=DashboardStats)
|
||||||
|
async def get_dashboard_stats(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene estadísticas completas del dashboard para la compañía especificada.
|
||||||
|
|
||||||
|
Incluye:
|
||||||
|
- KPIs principales (facturas, pedimentos, clientes, proveedores, items)
|
||||||
|
- Gráficas de tendencias (facturas por mes, operaciones por tipo)
|
||||||
|
- Top clientes y proveedores
|
||||||
|
- Actividad reciente
|
||||||
|
"""
|
||||||
|
# Validar acceso
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||||
|
|
||||||
|
# Generar estadísticas
|
||||||
|
service = DashboardService(db, tenant_id, company_id)
|
||||||
|
stats = service.get_complete_dashboard_stats()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operations-overview", response_model=OperationsOverview)
|
||||||
|
async def get_operations_overview(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene una vista general de las operaciones
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||||
|
|
||||||
|
service = DashboardService(db, tenant_id, company_id)
|
||||||
|
|
||||||
|
# Implementación básica
|
||||||
|
ops_by_type = service.get_operations_by_type()
|
||||||
|
|
||||||
|
return OperationsOverview(
|
||||||
|
total_operations=sum(int(op.value) for op in ops_by_type),
|
||||||
|
by_type={op.label: int(op.value) for op in ops_by_type},
|
||||||
|
by_status={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory-metrics", response_model=InventoryMetrics)
|
||||||
|
async def get_inventory_metrics(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene métricas de inventario
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||||
|
|
||||||
|
service = DashboardService(db, tenant_id, company_id)
|
||||||
|
items_kpi = service.get_items_metrics()
|
||||||
|
|
||||||
|
return InventoryMetrics(
|
||||||
|
total_items=int(items_kpi.value),
|
||||||
|
items_in_stock=int(items_kpi.value), # Simplificado
|
||||||
|
items_low_stock=0,
|
||||||
|
by_category={},
|
||||||
|
)
|
||||||
43
backend/api/v1/modules/core/dashboard/service.py
Normal file
43
backend/api/v1/modules/core/dashboard/service.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Servicio del dashboard — STUB.
|
||||||
|
Implementa las métricas de tu proyecto aquí.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import DashboardStats, KPIMetric, OperationsOverview, InventoryMetrics
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardService:
|
||||||
|
"""Stub — reemplaza con las consultas de tu proyecto."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session, tenant_id: int, company_id: int):
|
||||||
|
self.db = db
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.company_id = company_id
|
||||||
|
|
||||||
|
def get_stats(self) -> DashboardStats:
|
||||||
|
empty_kpi = KPIMetric(label="", value=0, trend="stable")
|
||||||
|
return DashboardStats(
|
||||||
|
company_id=self.company_id,
|
||||||
|
generated_at="",
|
||||||
|
total_invoices=empty_kpi,
|
||||||
|
total_pedimentos=empty_kpi,
|
||||||
|
total_clients=empty_kpi,
|
||||||
|
total_providers=empty_kpi,
|
||||||
|
active_items=empty_kpi,
|
||||||
|
pending_approvals=empty_kpi,
|
||||||
|
invoices_by_month=[],
|
||||||
|
operations_by_type=[],
|
||||||
|
top_clients=[],
|
||||||
|
top_providers=[],
|
||||||
|
recent_activity=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_operations_overview(self) -> OperationsOverview:
|
||||||
|
return OperationsOverview(total_operations=0, by_type={}, by_status={})
|
||||||
|
|
||||||
|
def get_inventory_metrics(self) -> InventoryMetrics:
|
||||||
|
return InventoryMetrics(
|
||||||
|
total_items=0, items_in_stock=0, items_low_stock=0, by_category={}
|
||||||
|
)
|
||||||
32
backend/api/v1/modules/core/help_center/models.py
Normal file
32
backend/api/v1/modules/core/help_center/models.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, String, Text, DateTime, Integer
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
class HelpArticle(Base):
|
||||||
|
"""
|
||||||
|
Modelo para los artículos de ayuda (Base de Conocimientos).
|
||||||
|
Sincronizado entre Servidor Central y Clientes.
|
||||||
|
"""
|
||||||
|
__tablename__ = "help_articles"
|
||||||
|
|
||||||
|
uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||||
|
slug = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
last_editor = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Library Mode Fields
|
||||||
|
category = Column(String(255), nullable=True, default="General")
|
||||||
|
order = Column(Integer, nullable=True, default=0)
|
||||||
|
|
||||||
|
# Removed missing fields to avoid 500 errors (No migration approach)
|
||||||
|
# content_type = Column(String(50), nullable=False, default="article")
|
||||||
|
# file_url = Column(String(512), nullable=True)
|
||||||
|
# file_size = Column(Integer, nullable=True)
|
||||||
|
# mime_type = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"
|
||||||
273
backend/api/v1/modules/core/help_center/routes.py
Normal file
273
backend/api/v1/modules/core/help_center/routes.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.s3_keys import (
|
||||||
|
help_asset_key,
|
||||||
|
help_public_api_path,
|
||||||
|
help_s3_key_to_public_relative_path,
|
||||||
|
system_help_object_key,
|
||||||
|
)
|
||||||
|
from core.storage_s3 import get_object_bytes, put_object_bytes
|
||||||
|
from core.security import get_current_user, has_role
|
||||||
|
from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||||
|
from .services import HelpCenterService
|
||||||
|
from .tasks import sync_single_article_task
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/help-center", tags=["Help Center"])
|
||||||
|
|
||||||
|
def verify_sync_token(x_sync_token: str = Header(...)):
|
||||||
|
if x_sync_token != settings.SYNC_SECRET_TOKEN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid Sync Token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def trigger_sync_or_broadcast(article_uuid: UUID):
|
||||||
|
"""
|
||||||
|
Helper function to handle synchronization logic.
|
||||||
|
- If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync.
|
||||||
|
- If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}")
|
||||||
|
logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||||
|
|
||||||
|
# 1. Upstream Sync (Client -> Hub)
|
||||||
|
if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""':
|
||||||
|
logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}")
|
||||||
|
sync_single_article_task.delay(str(article_uuid))
|
||||||
|
|
||||||
|
# 2. Downstream Broadcast (Hub -> Spokes)
|
||||||
|
# Only if we are the Hub (no upstream) and have spokes configured.
|
||||||
|
elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS:
|
||||||
|
from .tasks import broadcast_help_update
|
||||||
|
logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}")
|
||||||
|
# origin_client_uuid is None because this change originated on the Hub itself
|
||||||
|
broadcast_help_update.delay(str(article_uuid), None)
|
||||||
|
else:
|
||||||
|
logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True)
|
||||||
|
# We don't re-raise here to avoid returning 500 to the user if the save was successful
|
||||||
|
|
||||||
|
@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)])
|
||||||
|
def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)):
|
||||||
|
"""
|
||||||
|
Endpoint de sincronización inteligente para artículos de ayuda.
|
||||||
|
Requiere X-Sync-Token en los headers.
|
||||||
|
"""
|
||||||
|
result = HelpCenterService.sync_article(db, sync_data)
|
||||||
|
|
||||||
|
# Broadcast to other spokes (Hub logic)
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
|
||||||
|
|
||||||
|
if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS:
|
||||||
|
# We are the Hub (no central server to push to) and have Spokes configured
|
||||||
|
from .tasks import broadcast_help_update
|
||||||
|
logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}")
|
||||||
|
broadcast_help_update.delay(
|
||||||
|
str(sync_data.article_uuid),
|
||||||
|
str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("DEBUG: Broadcast skipped (Condition failed)")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.get("/files/{file_path:path}")
|
||||||
|
def serve_help_file(file_path: str):
|
||||||
|
"""Sirve un objeto bajo system/help/ (público vía middleware)."""
|
||||||
|
if ".." in file_path or file_path.startswith("/"):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
try:
|
||||||
|
key = system_help_object_key(file_path)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if not settings.use_s3_object_storage:
|
||||||
|
legacy = os.path.join("uploads", "help", file_path)
|
||||||
|
if not os.path.isfile(legacy):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
with open(legacy, "rb") as f:
|
||||||
|
data = f.read()
|
||||||
|
media = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||||
|
return Response(content=data, media_type=media)
|
||||||
|
try:
|
||||||
|
data = get_object_bytes(key)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
media = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||||
|
return Response(content=data, media_type=media)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-image/")
|
||||||
|
async def upload_help_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Sube una imagen para usar en los artículos."""
|
||||||
|
try:
|
||||||
|
file_ext = os.path.splitext(file.filename or "")[1] or ".png"
|
||||||
|
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||||
|
body = await file.read()
|
||||||
|
if settings.use_s3_object_storage:
|
||||||
|
key = help_asset_key("", new_filename)
|
||||||
|
ct = mimetypes.guess_type(new_filename)[0] or "image/png"
|
||||||
|
put_object_bytes(key, body, content_type=ct)
|
||||||
|
rel = help_s3_key_to_public_relative_path(key)
|
||||||
|
return {"url": help_public_api_path(rel)}
|
||||||
|
os.makedirs("uploads/help", exist_ok=True)
|
||||||
|
file_location = f"uploads/help/{new_filename}"
|
||||||
|
with open(file_location, "wb") as f:
|
||||||
|
f.write(body)
|
||||||
|
return {"url": f"/api/uploads/help/{new_filename}"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/upload-asset/")
|
||||||
|
async def upload_help_asset(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
|
||||||
|
try:
|
||||||
|
file_ext = os.path.splitext(file.filename or "")[1].lower()
|
||||||
|
new_filename = f"{uuid.uuid4()}{file_ext}"
|
||||||
|
subfolder = "assets"
|
||||||
|
if file_ext == ".pdf":
|
||||||
|
subfolder = "pdfs"
|
||||||
|
elif file_ext in [".mp4", ".mov", ".avi"]:
|
||||||
|
subfolder = "videos"
|
||||||
|
|
||||||
|
if settings.use_s3_object_storage:
|
||||||
|
body = await file.read()
|
||||||
|
key = help_asset_key(subfolder, new_filename)
|
||||||
|
ct = file.content_type or mimetypes.guess_type(new_filename)[0] or "application/octet-stream"
|
||||||
|
put_object_bytes(key, body, content_type=ct)
|
||||||
|
rel = help_s3_key_to_public_relative_path(key)
|
||||||
|
return {
|
||||||
|
"url": help_public_api_path(rel),
|
||||||
|
"filename": file.filename,
|
||||||
|
"size": len(body),
|
||||||
|
"mime_type": file.content_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
folder = f"uploads/help/{subfolder}"
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
file_location = f"{folder}/{new_filename}"
|
||||||
|
body = await file.read()
|
||||||
|
with open(file_location, "wb") as f:
|
||||||
|
f.write(body)
|
||||||
|
file_size = os.path.getsize(file_location)
|
||||||
|
return {
|
||||||
|
"url": f"/api/{file_location}",
|
||||||
|
"filename": file.filename,
|
||||||
|
"size": file_size,
|
||||||
|
"mime_type": file.content_type,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/articles/", response_model=List[HelpArticleInDB])
|
||||||
|
def list_articles(
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Lista todos los artículos de ayuda."""
|
||||||
|
return HelpCenterService.get_all(db)
|
||||||
|
|
||||||
|
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
|
||||||
|
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
|
||||||
|
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
|
||||||
|
return HelpCenterService.get_modifications(db, since)
|
||||||
|
|
||||||
|
@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||||
|
def get_article(
|
||||||
|
article_uuid: UUID,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Obtiene un artículo por UUID."""
|
||||||
|
article = HelpCenterService.get_by_uuid(db, article_uuid)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
return article
|
||||||
|
|
||||||
|
@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_article(
|
||||||
|
article: HelpArticleCreate,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Crea un nuevo artículo."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
|
||||||
|
|
||||||
|
# Fill last_editor with admin username
|
||||||
|
if current_user.get('preferred_username'):
|
||||||
|
article.last_editor = current_user.get('preferred_username')
|
||||||
|
|
||||||
|
new_article = HelpCenterService.create(db, article)
|
||||||
|
logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}")
|
||||||
|
|
||||||
|
trigger_sync_or_broadcast(new_article.uuid)
|
||||||
|
|
||||||
|
return new_article
|
||||||
|
|
||||||
|
@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB)
|
||||||
|
def update_article(
|
||||||
|
article_uuid: UUID,
|
||||||
|
article_data: HelpArticleUpdate,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Actualiza un artículo."""
|
||||||
|
if current_user.get('preferred_username'):
|
||||||
|
article_data.last_editor = current_user.get('preferred_username')
|
||||||
|
|
||||||
|
article = HelpCenterService.update(db, article_uuid, article_data)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
|
||||||
|
trigger_sync_or_broadcast(article.uuid)
|
||||||
|
|
||||||
|
return article
|
||||||
|
|
||||||
|
@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_article(
|
||||||
|
article_uuid: UUID,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: Dict[str, Any] = Depends(has_role("admin"))
|
||||||
|
):
|
||||||
|
"""Elimina un artículo."""
|
||||||
|
if not HelpCenterService.delete(db, article_uuid):
|
||||||
|
raise HTTPException(status_code=404, detail="Article not found")
|
||||||
|
|
||||||
|
# Broadcast or Sync the deletion?
|
||||||
|
# Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone.
|
||||||
|
# For now, let's at least trigger the logic.
|
||||||
|
# WARNING: sync_single_article_task expects the article to exist to send it.
|
||||||
|
# If we deleted it locally, sync_single_article_task will fail or send nothing.
|
||||||
|
# We need a dedicated 'sync_deletion' task or similar.
|
||||||
|
# Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync
|
||||||
|
# logic right now to avoid breaking things, but I'll add the hook for completeness.
|
||||||
|
# Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs.
|
||||||
|
|
||||||
|
return None
|
||||||
74
backend/api/v1/modules/core/help_center/schemas.py
Normal file
74
backend/api/v1/modules/core/help_center/schemas.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class HelpArticleBase(BaseModel):
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
last_editor: str
|
||||||
|
category: Optional[str] = "General"
|
||||||
|
order: Optional[int] = 0
|
||||||
|
content_type: str = "article"
|
||||||
|
file_url: Optional[str] = None
|
||||||
|
file_size: Optional[int] = None
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
context_path: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
class HelpArticleCreate(HelpArticleBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class HelpArticleUpdate(BaseModel):
|
||||||
|
slug: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
last_editor: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
order: Optional[int] = None
|
||||||
|
content_type: Optional[str] = None
|
||||||
|
file_url: Optional[str] = None
|
||||||
|
file_size: Optional[int] = None
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
context_path: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
class HelpArticleInDB(HelpArticleBase):
|
||||||
|
uuid: UUID
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class HelpSyncRequest(BaseModel):
|
||||||
|
article_uuid: UUID
|
||||||
|
client_updated_at: datetime
|
||||||
|
client_content: str
|
||||||
|
client_title: str
|
||||||
|
client_slug: str
|
||||||
|
last_editor: str
|
||||||
|
client_category: Optional[str] = "General"
|
||||||
|
client_order: Optional[int] = 0
|
||||||
|
client_content_type: str = "article"
|
||||||
|
client_file_url: Optional[str] = None
|
||||||
|
client_file_size: Optional[int] = None
|
||||||
|
client_mime_type: Optional[str] = None
|
||||||
|
client_context_path: Optional[str] = None
|
||||||
|
client_tags: Optional[str] = None
|
||||||
|
|
||||||
|
class HelpSyncResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
server_updated_at: Optional[datetime] = None
|
||||||
|
server_content: Optional[str] = None
|
||||||
|
server_title: Optional[str] = None
|
||||||
|
server_slug: Optional[str] = None
|
||||||
|
server_category: Optional[str] = None
|
||||||
|
server_order: Optional[int] = None
|
||||||
|
server_content_type: Optional[str] = None
|
||||||
|
server_file_url: Optional[str] = None
|
||||||
|
server_file_size: Optional[int] = None
|
||||||
|
server_mime_type: Optional[str] = None
|
||||||
|
server_context_path: Optional[str] = None
|
||||||
|
server_tags: Optional[str] = None
|
||||||
|
message: str
|
||||||
257
backend/api/v1/modules/core/help_center/services.py
Normal file
257
backend/api/v1/modules/core/help_center/services.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .models import HelpArticle
|
||||||
|
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
|
||||||
|
|
||||||
|
class HelpCenterService:
|
||||||
|
@staticmethod
|
||||||
|
def _inject_metadata(article: HelpArticle) -> HelpArticle:
|
||||||
|
if not article or not article.content:
|
||||||
|
return article
|
||||||
|
|
||||||
|
# Look for <!-- a76_metadata: { ... } -->
|
||||||
|
match = re.search(r'<!-- a76_metadata: (.*?) -->', article.content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(match.group(1))
|
||||||
|
article.content_type = metadata.get("content_type", "article")
|
||||||
|
article.file_url = metadata.get("file_url")
|
||||||
|
article.file_size = metadata.get("file_size")
|
||||||
|
article.mime_type = metadata.get("mime_type")
|
||||||
|
article.context_path = metadata.get("context_path")
|
||||||
|
article.tags = metadata.get("tags")
|
||||||
|
# Remove metadata from content for clean display if needed,
|
||||||
|
# but usually better to leave it and let parser handle it or hide it here.
|
||||||
|
# For now, we just set the attributes.
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
article.content_type = "article"
|
||||||
|
article.file_url = None
|
||||||
|
article.file_size = None
|
||||||
|
article.mime_type = None
|
||||||
|
article.context_path = None
|
||||||
|
article.tags = None
|
||||||
|
|
||||||
|
return article
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_metadata(content: str, data: dict) -> str:
|
||||||
|
# Remove existing metadata block if any
|
||||||
|
content = re.sub(r'\n\n<!-- a76_metadata: .*? -->', '', content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"content_type": data.get("content_type", "article"),
|
||||||
|
"file_url": data.get("file_url"),
|
||||||
|
"file_size": data.get("file_size"),
|
||||||
|
"mime_type": data.get("mime_type"),
|
||||||
|
"context_path": data.get("context_path"),
|
||||||
|
"tags": data.get("tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only append if there's something meaningful beyond "article"
|
||||||
|
if (metadata["content_type"] != "article" or
|
||||||
|
metadata["file_url"] or
|
||||||
|
metadata["context_path"] or
|
||||||
|
metadata["tags"]):
|
||||||
|
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all(db: Session) -> List[HelpArticle]:
|
||||||
|
articles = db.query(HelpArticle).all()
|
||||||
|
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
return HelpCenterService._inject_metadata(article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
|
||||||
|
return HelpCenterService._inject_metadata(article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
|
||||||
|
# Ensure timezone awareness
|
||||||
|
if since.tzinfo is None:
|
||||||
|
since = since.replace(tzinfo=timezone.utc)
|
||||||
|
articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
|
||||||
|
return [HelpCenterService._inject_metadata(a) for a in articles]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
|
||||||
|
data = article.model_dump()
|
||||||
|
# Move metadata into content
|
||||||
|
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
|
||||||
|
# Remove virtual fields from data to avoid SQLAlchemy errors
|
||||||
|
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
|
||||||
|
for f in virtual_fields:
|
||||||
|
if f in data:
|
||||||
|
del data[f]
|
||||||
|
|
||||||
|
db_article = HelpArticle(**data)
|
||||||
|
db.add(db_article)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_article)
|
||||||
|
return HelpCenterService._inject_metadata(db_article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
|
||||||
|
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
if not db_article:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Inject metadata to existing article to get current virtual fields
|
||||||
|
db_article = HelpCenterService._inject_metadata(db_article)
|
||||||
|
|
||||||
|
update_data = article_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle metadata update
|
||||||
|
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]):
|
||||||
|
# Merge existing metadata with new updates
|
||||||
|
current_meta = {
|
||||||
|
"content_type": getattr(db_article, "content_type", "article"),
|
||||||
|
"file_url": getattr(db_article, "file_url", None),
|
||||||
|
"file_size": getattr(db_article, "file_size", None),
|
||||||
|
"mime_type": getattr(db_article, "mime_type", None),
|
||||||
|
"context_path": getattr(db_article, "context_path", None),
|
||||||
|
"tags": getattr(db_article, "tags", None)
|
||||||
|
}
|
||||||
|
# Update with new data if present
|
||||||
|
for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]:
|
||||||
|
if f in update_data:
|
||||||
|
current_meta[f] = update_data[f]
|
||||||
|
|
||||||
|
# Use current content or new content
|
||||||
|
content = update_data.get("content", db_article.content)
|
||||||
|
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
|
||||||
|
|
||||||
|
# Remove virtual fields from data
|
||||||
|
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
|
||||||
|
for f in virtual_fields:
|
||||||
|
if f in update_data:
|
||||||
|
del update_data[f]
|
||||||
|
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(db_article, key, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_article)
|
||||||
|
return HelpCenterService._inject_metadata(db_article)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete(db: Session, article_uuid: UUID) -> bool:
|
||||||
|
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
if not db_article:
|
||||||
|
return False
|
||||||
|
db.delete(db_article)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse:
|
||||||
|
"""
|
||||||
|
Lógica de sincronización "Smart Sync" (Last Write Wins).
|
||||||
|
"""
|
||||||
|
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first()
|
||||||
|
|
||||||
|
client_updated_at = sync_data.client_updated_at
|
||||||
|
if client_updated_at.tzinfo is None:
|
||||||
|
client_updated_at = client_updated_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if not db_article:
|
||||||
|
# Caso A: Artículo nuevo desde el cliente
|
||||||
|
# Store metadata in content
|
||||||
|
client_meta = {
|
||||||
|
"content_type": sync_data.client_content_type,
|
||||||
|
"file_url": sync_data.client_file_url,
|
||||||
|
"file_size": sync_data.client_file_size,
|
||||||
|
"mime_type": sync_data.client_mime_type,
|
||||||
|
"context_path": sync_data.client_context_path,
|
||||||
|
"tags": sync_data.client_tags
|
||||||
|
}
|
||||||
|
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||||
|
|
||||||
|
new_article = HelpArticle(
|
||||||
|
uuid=sync_data.article_uuid,
|
||||||
|
slug=sync_data.client_slug,
|
||||||
|
title=sync_data.client_title,
|
||||||
|
content=content_with_meta,
|
||||||
|
updated_at=client_updated_at,
|
||||||
|
last_editor=sync_data.last_editor,
|
||||||
|
category=sync_data.client_category,
|
||||||
|
order=sync_data.client_order
|
||||||
|
)
|
||||||
|
db.add(new_article)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Download assets if needed (Images in content and main file)
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
if sync_data.client_file_url:
|
||||||
|
download_file_from_hub(sync_data.client_file_url)
|
||||||
|
sync_assets_from_content(sync_data.client_content)
|
||||||
|
|
||||||
|
return HelpSyncResponse(status="OK", message="Article created on server.")
|
||||||
|
|
||||||
|
server_updated_at = db_article.updated_at
|
||||||
|
if server_updated_at.tzinfo is None:
|
||||||
|
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Caso A: Cliente es más nuevo
|
||||||
|
if client_updated_at > server_updated_at:
|
||||||
|
client_meta = {
|
||||||
|
"content_type": sync_data.client_content_type,
|
||||||
|
"file_url": sync_data.client_file_url,
|
||||||
|
"file_size": sync_data.client_file_size,
|
||||||
|
"mime_type": sync_data.client_mime_type,
|
||||||
|
"context_path": sync_data.client_context_path,
|
||||||
|
"tags": sync_data.client_tags
|
||||||
|
}
|
||||||
|
db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||||
|
db_article.title = sync_data.client_title
|
||||||
|
db_article.slug = sync_data.client_slug
|
||||||
|
db_article.updated_at = client_updated_at
|
||||||
|
db_article.last_editor = sync_data.last_editor
|
||||||
|
db_article.category = sync_data.client_category
|
||||||
|
db_article.order = sync_data.client_order
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Download assets if needed (Images in content)
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
if sync_data.client_file_url:
|
||||||
|
download_file_from_hub(sync_data.client_file_url)
|
||||||
|
sync_assets_from_content(sync_data.client_content)
|
||||||
|
|
||||||
|
return HelpSyncResponse(status="OK", message="Server updated with client data.")
|
||||||
|
|
||||||
|
# Caso B: Servidor es más nuevo
|
||||||
|
elif server_updated_at > client_updated_at:
|
||||||
|
# Inject metadata for response
|
||||||
|
db_article = HelpCenterService._inject_metadata(db_article)
|
||||||
|
return HelpSyncResponse(
|
||||||
|
status="UPDATE_REQUIRED",
|
||||||
|
server_updated_at=server_updated_at,
|
||||||
|
server_content=db_article.content,
|
||||||
|
server_title=db_article.title,
|
||||||
|
server_slug=db_article.slug,
|
||||||
|
server_category=db_article.category,
|
||||||
|
server_order=db_article.order,
|
||||||
|
server_content_type=getattr(db_article, "content_type", "article"),
|
||||||
|
server_file_url=getattr(db_article, "file_url", None),
|
||||||
|
server_file_size=getattr(db_article, "file_size", None),
|
||||||
|
server_mime_type=getattr(db_article, "mime_type", None),
|
||||||
|
server_context_path=getattr(db_article, "context_path", None),
|
||||||
|
server_tags=getattr(db_article, "tags", None),
|
||||||
|
message="Client is outdated. Update required."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Caso C: Iguales
|
||||||
|
else:
|
||||||
|
return HelpSyncResponse(status="OK", message="Already in sync.")
|
||||||
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
269
backend/api/v1/modules/core/help_center/tasks.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from uuid import UUID
|
||||||
|
from celery import shared_task
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from core.database import CoreSessionLocal
|
||||||
|
from core.config import settings
|
||||||
|
from .models import HelpArticle
|
||||||
|
from .schemas import HelpSyncRequest, HelpSyncResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@shared_task(name="sync_all_articles_task")
|
||||||
|
def sync_all_articles_task():
|
||||||
|
"""
|
||||||
|
Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central.
|
||||||
|
Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke).
|
||||||
|
"""
|
||||||
|
if not settings.CENTRAL_SERVER_URL:
|
||||||
|
logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).")
|
||||||
|
return
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
articles = db.query(HelpArticle).all()
|
||||||
|
for article in articles:
|
||||||
|
sync_single_article(article.uuid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in sync_all_articles_task: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@shared_task(name="sync_single_article_task")
|
||||||
|
def sync_single_article_task(article_uuid_str: str):
|
||||||
|
"""
|
||||||
|
Sincroniza un único artículo inmediatamente después de una edición local.
|
||||||
|
"""
|
||||||
|
sync_single_article(article_uuid_str)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="broadcast_help_update")
|
||||||
|
def broadcast_help_update(article_uuid_str: str):
|
||||||
|
"""
|
||||||
|
Difunde una actualización de artículo a todos los spokes configurados.
|
||||||
|
"""
|
||||||
|
if not settings.SPOKE_URLS:
|
||||||
|
logger.info("No SPOKE_URLS configured. Skipping broadcast.")
|
||||||
|
return
|
||||||
|
|
||||||
|
spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()]
|
||||||
|
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first()
|
||||||
|
if not article:
|
||||||
|
logger.error(f"Article {article_uuid_str} not found for broadcast.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sync_payload = HelpSyncRequest(
|
||||||
|
article_uuid=article.uuid,
|
||||||
|
client_updated_at=article.updated_at,
|
||||||
|
client_content=article.content,
|
||||||
|
client_title=article.title,
|
||||||
|
client_slug=article.slug,
|
||||||
|
last_editor=article.last_editor,
|
||||||
|
client_category=article.category,
|
||||||
|
client_order=article.order
|
||||||
|
).model_dump(mode='json')
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
for spoke_url in spokes:
|
||||||
|
# Loop Prevention: Skip if the spoke is the origin
|
||||||
|
try:
|
||||||
|
logger.info(f"Broadcasting update to {spoke_url}")
|
||||||
|
response = client.post(
|
||||||
|
spoke_url,
|
||||||
|
json=sync_payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error broadcasting to {spoke_url}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Broadcast error: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_single_article(article_uuid):
|
||||||
|
"""
|
||||||
|
Lógica compartida para sincronizar un artículo con el servidor central.
|
||||||
|
"""
|
||||||
|
logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})")
|
||||||
|
|
||||||
|
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||||
|
# Enhanced check to catch literal empty quotes if they slip through
|
||||||
|
return
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
|
||||||
|
if not article:
|
||||||
|
return
|
||||||
|
|
||||||
|
sync_data = HelpSyncRequest(
|
||||||
|
article_uuid=article.uuid,
|
||||||
|
client_updated_at=article.updated_at,
|
||||||
|
client_content=article.content,
|
||||||
|
client_title=article.title,
|
||||||
|
client_slug=article.slug,
|
||||||
|
last_editor=article.last_editor,
|
||||||
|
client_category=article.category,
|
||||||
|
client_order=article.order
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.post(
|
||||||
|
settings.CENTRAL_SERVER_URL,
|
||||||
|
json=sync_data.model_dump(mode='json'),
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = HelpSyncResponse(**response.json())
|
||||||
|
if result.status == "UPDATE_REQUIRED":
|
||||||
|
# El servidor tiene una versión más nueva, actualizamos localmente
|
||||||
|
article.content = result.server_content
|
||||||
|
article.title = result.server_title
|
||||||
|
article.slug = result.server_slug
|
||||||
|
article.updated_at = result.server_updated_at
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Article {article.uuid} updated from server.")
|
||||||
|
|
||||||
|
# Download assets if needed
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
if result.server_file_url:
|
||||||
|
download_file_from_hub(result.server_file_url)
|
||||||
|
sync_assets_from_content(result.server_content)
|
||||||
|
else:
|
||||||
|
logger.info(f"Article {article.uuid} sync OK: {result.message}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing article {article.uuid}: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
@shared_task(name="sync_from_hub_task")
|
||||||
|
def sync_from_hub_task():
|
||||||
|
"""
|
||||||
|
Tarea de POLLING que el Cliente ejecuta periódicamente.
|
||||||
|
Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde
|
||||||
|
la última actualización local.
|
||||||
|
"""
|
||||||
|
if not settings.CENTRAL_SERVER_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
# 1. Obtener la fecha de la última actualización local
|
||||||
|
last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar()
|
||||||
|
if not last_local_update:
|
||||||
|
# Si no hay datos, traer todo desde el principio de los tiempos
|
||||||
|
last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Asegurar timezone awareness
|
||||||
|
if last_local_update.tzinfo is None:
|
||||||
|
last_local_update = last_local_update.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
logger.info(f"Polling Hub for updates since {last_local_update}")
|
||||||
|
|
||||||
|
# 2. Consultar al Hub
|
||||||
|
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
|
||||||
|
# CENTRAL_SERVER_URL es ".../help-center/sync/"
|
||||||
|
# Queremos ".../help-center/modifications/"
|
||||||
|
hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/")
|
||||||
|
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get(
|
||||||
|
hub_url,
|
||||||
|
params={"since": last_local_update.isoformat()},
|
||||||
|
headers=headers,
|
||||||
|
timeout=10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
articles_data = response.json()
|
||||||
|
if not articles_data:
|
||||||
|
logger.info("No updates found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Found {len(articles_data)} updates from Hub. Applying...")
|
||||||
|
|
||||||
|
# 3. Aplicar actualizaciones
|
||||||
|
for art_data in articles_data:
|
||||||
|
try:
|
||||||
|
# Logic similar to sync_article but simpler (Force Update from Hub)
|
||||||
|
# We assume Hub is Truth in this Polling flow
|
||||||
|
|
||||||
|
# Try to find by UUID
|
||||||
|
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
|
||||||
|
|
||||||
|
# Fallback: find by Slug if UUID doesn't match
|
||||||
|
if not local_article:
|
||||||
|
local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first()
|
||||||
|
|
||||||
|
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
|
||||||
|
if server_updated_at.tzinfo is None:
|
||||||
|
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if not local_article:
|
||||||
|
new_article = HelpArticle(
|
||||||
|
uuid=art_data['uuid'],
|
||||||
|
slug=art_data['slug'],
|
||||||
|
title=art_data['title'],
|
||||||
|
content=art_data['content'],
|
||||||
|
updated_at=server_updated_at,
|
||||||
|
last_editor=art_data['last_editor'],
|
||||||
|
category=art_data.get('category', "General"),
|
||||||
|
order=art_data.get('order', 0)
|
||||||
|
)
|
||||||
|
db.add(new_article)
|
||||||
|
logger.info(f"Created new article: {art_data['slug']}")
|
||||||
|
else:
|
||||||
|
# Update existing article
|
||||||
|
# If UUID changed in Hub but slug is the same, we update UUID too
|
||||||
|
local_article.uuid = art_data['uuid']
|
||||||
|
local_article.slug = art_data['slug']
|
||||||
|
local_article.title = art_data['title']
|
||||||
|
local_article.content = art_data['content']
|
||||||
|
local_article.updated_at = server_updated_at
|
||||||
|
local_article.last_editor = art_data['last_editor']
|
||||||
|
local_article.category = art_data.get('category', "General")
|
||||||
|
local_article.order = art_data.get('order', 0)
|
||||||
|
logger.info(f"Updated article: {art_data['slug']}")
|
||||||
|
|
||||||
|
db.commit() # Commit each article to avoid bulk failure
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Download assets after bulk update (Polling)
|
||||||
|
from .utils import download_file_from_hub, sync_assets_from_content
|
||||||
|
for art_data in articles_data:
|
||||||
|
# art_data contains the virtual fields because it was dumped via HelpArticleInDB
|
||||||
|
if "file_url" in art_data and art_data['file_url']:
|
||||||
|
download_file_from_hub(art_data['file_url'])
|
||||||
|
|
||||||
|
sync_assets_from_content(art_data.get('content', ''))
|
||||||
|
|
||||||
|
logger.info("Polling sync completed successfully.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f"Polling failed: {response.status_code} - {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in sync_from_hub_task: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
111
backend/api/v1/modules/core/help_center/utils.py
Normal file
111
backend/api/v1/modules/core/help_center/utils.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from core.s3_keys import SYSTEM_HELP_PREFIX
|
||||||
|
from core.storage_s3 import object_exists, put_object_bytes
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_url_to_s3_key(asset_url: str) -> Optional[str]:
|
||||||
|
"""Deriva la clave S3 bajo system/help/ a partir de una URL de artículo."""
|
||||||
|
if "/help-center/files/" in asset_url:
|
||||||
|
rel = asset_url.split("/help-center/files/", 1)[1].lstrip("/")
|
||||||
|
if ".." in rel:
|
||||||
|
return None
|
||||||
|
return f"{SYSTEM_HELP_PREFIX}{rel}"
|
||||||
|
u = asset_url.replace("/api/uploads/", "uploads/")
|
||||||
|
if u.startswith("/"):
|
||||||
|
u = u[1:]
|
||||||
|
if u.startswith("uploads/help/"):
|
||||||
|
return f"{SYSTEM_HELP_PREFIX}{u[len('uploads/help/') :]}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def download_file_from_hub(relative_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Descarga un asset del Hub y lo guarda en MinIO (system/help/...) o en disco si no hay almacenamiento S3 activo.
|
||||||
|
relative_path: URL parcial, p. ej. '/api/uploads/help/x.png' o '/api/v1/core/help-center/files/pdfs/x.pdf'
|
||||||
|
"""
|
||||||
|
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
|
||||||
|
return False
|
||||||
|
|
||||||
|
key = _asset_url_to_s3_key(relative_path)
|
||||||
|
if not key:
|
||||||
|
logger.warning("download_file_from_hub: could not map URL to S3 key: %s", relative_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if settings.use_s3_object_storage and object_exists(key):
|
||||||
|
logger.info("S3 object %s already exists, skipping download.", key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0]
|
||||||
|
if "/help-center/files/" in relative_path:
|
||||||
|
rel = relative_path.split("/help-center/files/", 1)[1].lstrip("/")
|
||||||
|
hub_file_url = f"{base_url.rstrip('/')}/api/v1/core/help-center/files/{rel}"
|
||||||
|
else:
|
||||||
|
clean_path = relative_path.replace("/api/uploads/", "uploads/")
|
||||||
|
if clean_path.startswith("/"):
|
||||||
|
clean_path = clean_path[1:]
|
||||||
|
hub_file_url = f"{base_url.rstrip('/')}/{clean_path}"
|
||||||
|
|
||||||
|
logger.info("Downloading asset from Hub: %s", hub_file_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client() as client:
|
||||||
|
response = client.get(hub_file_url, timeout=30.0)
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to download %s: Status %s URL: %s",
|
||||||
|
relative_path,
|
||||||
|
response.status_code,
|
||||||
|
hub_file_url,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
body = response.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error downloading %s: %s", relative_path, str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if settings.use_s3_object_storage:
|
||||||
|
rel = key[len(SYSTEM_HELP_PREFIX) :]
|
||||||
|
ct = mimetypes.guess_type(rel)[0] or "application/octet-stream"
|
||||||
|
try:
|
||||||
|
put_object_bytes(key, body, content_type=ct)
|
||||||
|
logger.info("Stored hub asset in S3: %s", key)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("S3 put failed for %s: %s", key, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
rel = key[len(SYSTEM_HELP_PREFIX) :]
|
||||||
|
local_path = Path("uploads/help") / rel
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path.write_bytes(body)
|
||||||
|
logger.info("Stored hub asset locally: %s", local_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sync_assets_from_content(content: str):
|
||||||
|
"""Parsea markdown y descarga imágenes referenciadas (rutas legacy y nuevas)."""
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
|
||||||
|
patterns = [
|
||||||
|
r'!\[.*?\]\((/api/uploads/.*?)\)',
|
||||||
|
r'!\[.*?\]\((/api/v1/core/help-center/files/.*?)\)',
|
||||||
|
]
|
||||||
|
seen = set()
|
||||||
|
for pattern in patterns:
|
||||||
|
for asset_url in re.findall(pattern, content):
|
||||||
|
if asset_url in seen:
|
||||||
|
continue
|
||||||
|
seen.add(asset_url)
|
||||||
|
download_file_from_hub(asset_url)
|
||||||
51
backend/api/v1/modules/core/invite_codes/dto.py
Normal file
51
backend/api/v1/modules/core/invite_codes/dto.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""DTOs para el módulo de códigos de invitación."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInviteCodeDTO(BaseModel):
|
||||||
|
company_id: Optional[int] = Field(
|
||||||
|
None, description="Empresa destino (None = cualquier empresa del tenant)"
|
||||||
|
)
|
||||||
|
role: str = Field("user", description="Rol asignado al canjear el código")
|
||||||
|
max_uses: Optional[int] = Field(None, description="Usos máximos (None = ilimitado)")
|
||||||
|
expires_at: Optional[datetime] = Field(None, description="Expiración (None = sin expiración)")
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCodeResponseDTO(BaseModel):
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
tenant_slug: str
|
||||||
|
company_id: Optional[int]
|
||||||
|
role: str
|
||||||
|
max_uses: Optional[int]
|
||||||
|
uses_count: int
|
||||||
|
expires_at: Optional[datetime]
|
||||||
|
is_active: bool
|
||||||
|
created_by: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateInviteCodeResponseDTO(BaseModel):
|
||||||
|
code: str
|
||||||
|
tenant_slug: str
|
||||||
|
company_id: Optional[int]
|
||||||
|
role: str
|
||||||
|
remaining_uses: Optional[int] = Field(
|
||||||
|
None, description="Usos restantes; None = ilimitado"
|
||||||
|
)
|
||||||
|
expires_at: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumeInviteCodeResponseDTO(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
tenant_slug: str
|
||||||
|
company_id: Optional[int]
|
||||||
|
role: str
|
||||||
49
backend/api/v1/modules/core/invite_codes/models.py
Normal file
49
backend/api/v1/modules/core/invite_codes/models.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Modelo de código de invitación reutilizable para registro."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from api.v1.common.base_models import BaseTimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCode(Base, BaseTimestampMixin):
|
||||||
|
"""
|
||||||
|
Código corto multiuso para invitar usuarios a un tenant/empresa.
|
||||||
|
A diferencia de InviteToken (único por email), un InviteCode es
|
||||||
|
compartible: se distribuye como cadena de 8 chars y puede
|
||||||
|
ser canjeado por múltiples usuarios hasta agotar max_uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "invite_codes"
|
||||||
|
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# Código legible generado automáticamente (8 chars, sin ambigüedad 0/O/I/l)
|
||||||
|
code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Tenant destino — el usuario debe unirse a este workspace en el Hub
|
||||||
|
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Empresa destino específica (None = cualquier empresa del tenant)
|
||||||
|
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
|
||||||
|
# Rol con el que se provisiona el usuario al canjear
|
||||||
|
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
|
||||||
|
|
||||||
|
# Control de uso
|
||||||
|
max_uses: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
uses_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
|
||||||
|
|
||||||
|
# Expiración (None = sin expiración)
|
||||||
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# keycloak_user_id del admin que generó el código
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, server_default="true", default=True
|
||||||
|
)
|
||||||
149
backend/api/v1/modules/core/invite_codes/routes.py
Normal file
149
backend/api/v1/modules/core/invite_codes/routes.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""Rutas para gestión de códigos de invitación."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, validate_access_to_resource
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
ConsumeInviteCodeResponseDTO,
|
||||||
|
CreateInviteCodeDTO,
|
||||||
|
InviteCodeResponseDTO,
|
||||||
|
ValidateInviteCodeResponseDTO,
|
||||||
|
)
|
||||||
|
from .service import InviteCodeService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/invite-codes", tags=["Invite Codes"])
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=InviteCodeResponseDTO, status_code=201)
|
||||||
|
async def create_invite_code(
|
||||||
|
data: CreateInviteCodeDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
credentials=Depends(_bearer),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Genera un código de invitación reutilizable.
|
||||||
|
Requiere permiso user.create sobre la empresa (o ser admin del tenant).
|
||||||
|
"""
|
||||||
|
company_id = data.company_id
|
||||||
|
if company_id is not None:
|
||||||
|
validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
required_permissions=["user.create"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Invitación a nivel tenant: solo roles admin del tenant
|
||||||
|
roles = set(current_user.get("roles") or [])
|
||||||
|
if "admin" not in roles and "hub_admin" not in roles:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Se requiere rol admin para crear invitaciones de nivel tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||||
|
created_by: str = current_user.get("sub") or ""
|
||||||
|
|
||||||
|
service = InviteCodeService(db)
|
||||||
|
return await service.create_code(
|
||||||
|
data=data,
|
||||||
|
created_by=created_by,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
user_access_token=credentials.credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[InviteCodeResponseDTO])
|
||||||
|
def list_invite_codes(
|
||||||
|
company_id: Optional[int] = Query(None, description="Filtrar por empresa"),
|
||||||
|
include_inactive: bool = Query(False, description="Incluir códigos inactivos/agotados"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Lista los códigos de invitación del tenant.
|
||||||
|
Filtra opcionalmente por empresa.
|
||||||
|
"""
|
||||||
|
if company_id is not None:
|
||||||
|
validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
company_id,
|
||||||
|
current_user,
|
||||||
|
required_permissions=["user.create"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
roles = set(current_user.get("roles") or [])
|
||||||
|
if "admin" not in roles and "hub_admin" not in roles:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=403, detail="Se requiere rol admin")
|
||||||
|
|
||||||
|
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||||
|
service = InviteCodeService(db)
|
||||||
|
return service.list_codes(
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
company_id=company_id,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{code}", status_code=204)
|
||||||
|
def revoke_invite_code(
|
||||||
|
code: str,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Revoca (desactiva) un código de invitación."""
|
||||||
|
roles = set(current_user.get("roles") or [])
|
||||||
|
if "admin" not in roles and "hub_admin" not in roles:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=403, detail="Se requiere rol admin")
|
||||||
|
|
||||||
|
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||||
|
service = InviteCodeService(db)
|
||||||
|
service.revoke_code(code=code, tenant_slug=tenant_slug)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/validate/{code}", response_model=ValidateInviteCodeResponseDTO)
|
||||||
|
def validate_invite_code(
|
||||||
|
code: str,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Valida un código de invitación sin consumirlo.
|
||||||
|
Endpoint público — no requiere autenticación.
|
||||||
|
"""
|
||||||
|
service = InviteCodeService(db)
|
||||||
|
return service.validate(code=code)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/consume/{code}", response_model=ConsumeInviteCodeResponseDTO)
|
||||||
|
def consume_invite_code(
|
||||||
|
code: str,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Canjea el código: incrementa el contador de usos y crea la relación
|
||||||
|
UserTenant (usuario ↔ empresa) si el código tiene company_id definido.
|
||||||
|
Requiere autenticación.
|
||||||
|
"""
|
||||||
|
keycloak_user_id: str = current_user.get("sub") or ""
|
||||||
|
tenant_id: int = current_user.get("tenant_id") or 0
|
||||||
|
|
||||||
|
service = InviteCodeService(db)
|
||||||
|
return service.consume(
|
||||||
|
code=code,
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
313
backend/api/v1/modules/core/invite_codes/service.py
Normal file
313
backend/api/v1/modules/core/invite_codes/service.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""Servicio de códigos de invitación reutilizables."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from .dto import (
|
||||||
|
ConsumeInviteCodeResponseDTO,
|
||||||
|
CreateInviteCodeDTO,
|
||||||
|
InviteCodeResponseDTO,
|
||||||
|
ValidateInviteCodeResponseDTO,
|
||||||
|
)
|
||||||
|
from .models import InviteCode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Charset sin caracteres ambiguos (0/O/I/l/1)
|
||||||
|
_CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
_CODE_LENGTH = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code() -> str:
|
||||||
|
return "".join(random.choices(_CODE_CHARSET, k=_CODE_LENGTH))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid(invite: InviteCode) -> bool:
|
||||||
|
"""True si el código es canjeable en este momento."""
|
||||||
|
if not invite.is_active:
|
||||||
|
return False
|
||||||
|
if invite.max_uses is not None and invite.uses_count >= invite.max_uses:
|
||||||
|
return False
|
||||||
|
if invite.expires_at and invite.expires_at < datetime.now(timezone.utc):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCodeService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_code(
|
||||||
|
self,
|
||||||
|
data: CreateInviteCodeDTO,
|
||||||
|
created_by: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
user_access_token: str = "",
|
||||||
|
) -> InviteCodeResponseDTO:
|
||||||
|
from api.v1.modules.core.tenants.models import Tenant
|
||||||
|
|
||||||
|
tenant = (
|
||||||
|
self.db.query(Tenant)
|
||||||
|
.filter(Tenant.slug == tenant_slug, Tenant.is_active == True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant no encontrado")
|
||||||
|
|
||||||
|
if data.company_id is not None:
|
||||||
|
# Implementa la validación de company con tu modelo de compañía.
|
||||||
|
company = None
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Empresa no encontrada o no pertenece al tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Genera código único; reintenta si hay colisión (improbable)
|
||||||
|
for _ in range(5):
|
||||||
|
code = _generate_code()
|
||||||
|
if not self.db.query(InviteCode).filter(InviteCode.code == code).first():
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="No se pudo generar un código único, intenta de nuevo",
|
||||||
|
)
|
||||||
|
|
||||||
|
invite = InviteCode(
|
||||||
|
code=code,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
company_id=data.company_id,
|
||||||
|
role=data.role,
|
||||||
|
max_uses=data.max_uses,
|
||||||
|
uses_count=0,
|
||||||
|
expires_at=data.expires_at,
|
||||||
|
created_by=created_by,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(invite)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(invite)
|
||||||
|
|
||||||
|
# Registrar el mismo código en el Hub para que funcione en workspace /join
|
||||||
|
await self._sync_code_to_hub(
|
||||||
|
code=code,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
data=data,
|
||||||
|
user_access_token=user_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
return InviteCodeResponseDTO.model_validate(invite)
|
||||||
|
|
||||||
|
async def _sync_code_to_hub(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
data: CreateInviteCodeDTO,
|
||||||
|
user_access_token: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Crea el mismo código en Hub's workspace_invite_codes con allowed_systems=['a76'].
|
||||||
|
Best-effort: si falla, el código sigue válido en A76 pero no en workspace.
|
||||||
|
"""
|
||||||
|
if not user_access_token:
|
||||||
|
logger.warning(
|
||||||
|
"[invite_code] Sin token para sincronizar '%s' al Hub — "
|
||||||
|
"el código NO funcionará en workspace /join",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
hub_url = getattr(settings, "HUB_URL", "").rstrip("/")
|
||||||
|
if not hub_url:
|
||||||
|
logger.warning("[invite_code] HUB_URL no configurado — código '%s' no sincronizado", code)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"code": code,
|
||||||
|
"allowed_systems": [],
|
||||||
|
"role": data.role,
|
||||||
|
}
|
||||||
|
if data.max_uses is not None:
|
||||||
|
payload["max_uses"] = data.max_uses
|
||||||
|
if data.expires_at is not None:
|
||||||
|
payload["expires_at"] = data.expires_at.isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{hub_url}/api/v1/hub/invite-codes/{tenant_slug}",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {user_access_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
logger.info(
|
||||||
|
"[invite_code] Código '%s' sincronizado al Hub (tenant=%s)", code, tenant_slug
|
||||||
|
)
|
||||||
|
elif resp.status_code == 409:
|
||||||
|
logger.info(
|
||||||
|
"[invite_code] Código '%s' ya existe en Hub (tenant=%s) — OK", code, tenant_slug
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"[invite_code] Hub sync falló code='%s' status=%s body=%s",
|
||||||
|
code, resp.status_code, resp.text[:300],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[invite_code] Hub sync excepción code='%s': %s", code, exc)
|
||||||
|
|
||||||
|
def list_codes(
|
||||||
|
self,
|
||||||
|
tenant_slug: str,
|
||||||
|
company_id: Optional[int] = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
) -> List[InviteCodeResponseDTO]:
|
||||||
|
q = self.db.query(InviteCode).filter(InviteCode.tenant_slug == tenant_slug)
|
||||||
|
|
||||||
|
if company_id is not None:
|
||||||
|
q = q.filter(InviteCode.company_id == company_id)
|
||||||
|
|
||||||
|
if not include_inactive:
|
||||||
|
q = q.filter(InviteCode.is_active == True)
|
||||||
|
|
||||||
|
invites = q.order_by(InviteCode.created_at.desc()).all()
|
||||||
|
return [InviteCodeResponseDTO.model_validate(i) for i in invites]
|
||||||
|
|
||||||
|
def revoke_code(self, code: str, tenant_slug: str) -> None:
|
||||||
|
invite = (
|
||||||
|
self.db.query(InviteCode)
|
||||||
|
.filter(InviteCode.code == code, InviteCode.tenant_slug == tenant_slug)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=404, detail="Código de invitación no encontrado")
|
||||||
|
|
||||||
|
invite.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def validate(self, code: str) -> ValidateInviteCodeResponseDTO:
|
||||||
|
"""Valida el código sin consumirlo. Devuelve 403 genérico si no es válido."""
|
||||||
|
invite = self.db.query(InviteCode).filter(InviteCode.code == code).first()
|
||||||
|
|
||||||
|
if not invite or not _is_valid(invite):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Código de invitación inválido o expirado",
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining: Optional[int] = None
|
||||||
|
if invite.max_uses is not None:
|
||||||
|
remaining = invite.max_uses - invite.uses_count
|
||||||
|
|
||||||
|
return ValidateInviteCodeResponseDTO(
|
||||||
|
code=invite.code,
|
||||||
|
tenant_slug=invite.tenant_slug,
|
||||||
|
company_id=invite.company_id,
|
||||||
|
role=invite.role,
|
||||||
|
remaining_uses=remaining,
|
||||||
|
expires_at=invite.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def consume(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
keycloak_user_id: str,
|
||||||
|
tenant_id: int,
|
||||||
|
) -> ConsumeInviteCodeResponseDTO:
|
||||||
|
"""
|
||||||
|
Canjea el código:
|
||||||
|
- Incrementa uses_count.
|
||||||
|
- Si company_id está definido, crea UserTenant (usuario ↔ empresa).
|
||||||
|
- Desactiva el código si se agotaron los usos.
|
||||||
|
"""
|
||||||
|
invite = self.db.query(InviteCode).filter(InviteCode.code == code).first()
|
||||||
|
|
||||||
|
if not invite or not _is_valid(invite):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Código de invitación inválido o expirado",
|
||||||
|
)
|
||||||
|
|
||||||
|
if invite.company_id is not None:
|
||||||
|
self._ensure_user_tenant(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=invite.company_id,
|
||||||
|
role=invite.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
invite.uses_count += 1
|
||||||
|
if invite.max_uses is not None and invite.uses_count >= invite.max_uses:
|
||||||
|
invite.is_active = False
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[invite_code] canjeado code=%s user=%s company_id=%s uses=%d/%s",
|
||||||
|
invite.code,
|
||||||
|
keycloak_user_id,
|
||||||
|
invite.company_id,
|
||||||
|
invite.uses_count,
|
||||||
|
invite.max_uses or "∞",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConsumeInviteCodeResponseDTO(
|
||||||
|
success=True,
|
||||||
|
message="Código canjeado correctamente",
|
||||||
|
tenant_slug=invite.tenant_slug,
|
||||||
|
company_id=invite.company_id,
|
||||||
|
role=invite.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_user_tenant(
|
||||||
|
self,
|
||||||
|
keycloak_user_id: str,
|
||||||
|
tenant_id: int,
|
||||||
|
company_id: int,
|
||||||
|
role: str,
|
||||||
|
) -> None:
|
||||||
|
"""Crea la fila UserTenant si el usuario aún no tiene acceso a la empresa."""
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
UserTenant.company_id == company_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
if not existing.is_active:
|
||||||
|
existing.is_active = True
|
||||||
|
existing.role = role
|
||||||
|
self.db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
user_tenant = UserTenant(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=company_id,
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(user_tenant)
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.warning(
|
||||||
|
"[invite_code] race: UserTenant ya existe user=%s company=%d",
|
||||||
|
keycloak_user_id,
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
0
backend/api/v1/modules/core/invites/__init__.py
Normal file
0
backend/api/v1/modules/core/invites/__init__.py
Normal file
32
backend/api/v1/modules/core/invites/dto.py
Normal file
32
backend/api/v1/modules/core/invites/dto.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""DTOs para el módulo de invitaciones."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInviteDTO(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
company_id: int
|
||||||
|
role_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class InviteResponseDTO(BaseModel):
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
expires_at: datetime
|
||||||
|
invite_url: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class InviteValidationResult(BaseModel):
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
invite_id: int
|
||||||
|
tenant_slug: str
|
||||||
|
company_id: Optional[int] = None
|
||||||
42
backend/api/v1/modules/core/invites/models.py
Normal file
42
backend/api/v1/modules/core/invites/models.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Modelo de token de invitación local para registro de usuarios."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from api.v1.common.base_models import BaseTimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
from sqlalchemy import DateTime, Integer, JSON, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class InviteToken(Base, BaseTimestampMixin):
|
||||||
|
"""
|
||||||
|
Token de invitación de un solo uso para registro de usuarios.
|
||||||
|
El token en claro NUNCA se almacena; solo su hash SHA-256.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "invite_tokens"
|
||||||
|
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# sha256(token_plain) — índice único
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
|
||||||
|
|
||||||
|
# keycloak_user_id del admin que generó la invitación
|
||||||
|
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
product_ids: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Específico de Anexo76: empresa destino para crear UserTenant
|
||||||
|
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Token generado en el Hub (para la URL de registro del workspace)
|
||||||
|
hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
49
backend/api/v1/modules/core/invites/routes.py
Normal file
49
backend/api/v1/modules/core/invites/routes.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Rutas para gestión de invitaciones de usuarios."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, validate_access_to_resource
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import CreateInviteDTO, InviteResponseDTO
|
||||||
|
from .service import InviteService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/invites", tags=["Invites"])
|
||||||
|
_bearer = HTTPBearer()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=InviteResponseDTO, status_code=201)
|
||||||
|
async def create_invite(
|
||||||
|
data: CreateInviteDTO,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
credentials=Depends(_bearer),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Genera un token de invitación para que un usuario externo se registre.
|
||||||
|
Requiere permiso user.create. Usa el token del usuario actual para crear
|
||||||
|
el invite en el Hub — no requiere credenciales de hub_admin.
|
||||||
|
"""
|
||||||
|
validate_access_to_resource(
|
||||||
|
db,
|
||||||
|
data.company_id,
|
||||||
|
current_user,
|
||||||
|
required_permissions=["user.create"],
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||||
|
created_by: str = current_user.get("sub") or ""
|
||||||
|
|
||||||
|
service = InviteService(db)
|
||||||
|
return await service.create_invite(
|
||||||
|
data=data,
|
||||||
|
created_by=created_by,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
user_access_token=credentials.credentials,
|
||||||
|
)
|
||||||
282
backend/api/v1/modules/core/invites/service.py
Normal file
282
backend/api/v1/modules/core/invites/service.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Servicio de invitaciones de usuarios."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from .dto import CreateInviteDTO, InviteResponseDTO, InviteValidationResult
|
||||||
|
from .models import InviteToken
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INVITE_TTL_HOURS = 48
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token_plain: str) -> str:
|
||||||
|
return hashlib.sha256(token_plain.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token_from_url(url: str) -> Optional[str]:
|
||||||
|
"""Extract invite_token query param from a URL string."""
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
parsed = urlparse(url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
tokens = params.get("invite_token", [])
|
||||||
|
return tokens[0] if tokens else None
|
||||||
|
|
||||||
|
|
||||||
|
class InviteService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_invite(
|
||||||
|
self,
|
||||||
|
data: CreateInviteDTO,
|
||||||
|
created_by: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
user_access_token: str = "",
|
||||||
|
) -> InviteResponseDTO:
|
||||||
|
import httpx
|
||||||
|
from api.v1.modules.core.tenants.models import Tenant
|
||||||
|
|
||||||
|
tenant = (
|
||||||
|
self.db.query(Tenant)
|
||||||
|
.filter(Tenant.slug == tenant_slug, Tenant.is_active == True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant no encontrado")
|
||||||
|
|
||||||
|
token_plain = secrets.token_urlsafe(32)
|
||||||
|
token_hash = _hash_token(token_plain)
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=INVITE_TTL_HOURS)
|
||||||
|
|
||||||
|
from api.v1.modules.core.permissions.models import CompanyRole
|
||||||
|
|
||||||
|
company_role = (
|
||||||
|
self.db.query(CompanyRole)
|
||||||
|
.filter(
|
||||||
|
CompanyRole.id == data.role_id,
|
||||||
|
CompanyRole.company_id == data.company_id,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not company_role:
|
||||||
|
raise HTTPException(status_code=404, detail="Rol no encontrado")
|
||||||
|
|
||||||
|
# Crear invite en el Hub usando el token del usuario actual.
|
||||||
|
# El usuario debe tener role='admin' en su tenant dentro del Hub.
|
||||||
|
# No se requieren credenciales de hub_admin — sin secretos en el .env del cliente.
|
||||||
|
hub_invite_token: Optional[str] = None
|
||||||
|
invite_url: str = ""
|
||||||
|
if not user_access_token:
|
||||||
|
logger.error(
|
||||||
|
"[invite] user_access_token vacío — no se puede crear el invite en el Hub. "
|
||||||
|
"tenant=%s email=%s",
|
||||||
|
tenant_slug,
|
||||||
|
data.email,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
hub_resp = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/hub/invites",
|
||||||
|
json={"email": str(data.email), "tenant_slug": tenant_slug},
|
||||||
|
headers={"Authorization": f"Bearer {user_access_token}"},
|
||||||
|
)
|
||||||
|
if hub_resp.status_code in (200, 201):
|
||||||
|
hub_data = hub_resp.json()
|
||||||
|
hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", ""))
|
||||||
|
invite_url = hub_data.get("invite_url", "")
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s",
|
||||||
|
hub_resp.status_code,
|
||||||
|
hub_resp.text[:300],
|
||||||
|
tenant_slug,
|
||||||
|
data.email,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc)
|
||||||
|
|
||||||
|
# Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló
|
||||||
|
if not invite_url:
|
||||||
|
hub_base = settings.HUB_URL.rstrip("/")
|
||||||
|
invite_url = (
|
||||||
|
f"{hub_base}/register"
|
||||||
|
f"?invite_token={token_plain}"
|
||||||
|
f"&tenant={tenant_slug}"
|
||||||
|
f"&email={data.email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
invite = InviteToken(
|
||||||
|
token_hash=token_hash,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
email=str(data.email),
|
||||||
|
role=company_role.code,
|
||||||
|
created_by=created_by,
|
||||||
|
expires_at=expires_at,
|
||||||
|
company_id=data.company_id,
|
||||||
|
hub_invite_token=hub_invite_token,
|
||||||
|
)
|
||||||
|
self.db.add(invite)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(invite)
|
||||||
|
|
||||||
|
# Enviar email (best-effort)
|
||||||
|
try:
|
||||||
|
await self._send_invite_email(
|
||||||
|
to_email=str(data.email),
|
||||||
|
tenant_name=tenant.name,
|
||||||
|
invite_url=invite_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Invite email send failed (non-blocking): %s — invite_url=%s",
|
||||||
|
exc,
|
||||||
|
invite_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return InviteResponseDTO(
|
||||||
|
id=invite.id,
|
||||||
|
email=invite.email,
|
||||||
|
role=invite.role,
|
||||||
|
expires_at=invite.expires_at,
|
||||||
|
invite_url=invite_url,
|
||||||
|
created_at=invite.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
token_plain: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
) -> InviteValidationResult:
|
||||||
|
"""Valida el token sin consumirlo. Lanza 403 genérico por seguridad."""
|
||||||
|
token_hash = _hash_token(token_plain)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
invite = (
|
||||||
|
self.db.query(InviteToken)
|
||||||
|
.filter(
|
||||||
|
InviteToken.token_hash == token_hash,
|
||||||
|
InviteToken.tenant_slug == tenant_slug,
|
||||||
|
InviteToken.used_at.is_(None),
|
||||||
|
InviteToken.expires_at > now,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Token de invitación inválido o expirado",
|
||||||
|
)
|
||||||
|
|
||||||
|
if email and invite.email.lower() != email.lower():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Token de invitación inválido o expirado",
|
||||||
|
)
|
||||||
|
|
||||||
|
return InviteValidationResult(
|
||||||
|
email=invite.email,
|
||||||
|
role=invite.role,
|
||||||
|
invite_id=invite.id,
|
||||||
|
tenant_slug=invite.tenant_slug,
|
||||||
|
company_id=invite.company_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def consume_by_id(self, invite_id: int) -> None:
|
||||||
|
invite = self.db.query(InviteToken).filter(InviteToken.id == invite_id).first()
|
||||||
|
if invite:
|
||||||
|
invite.used_at = datetime.now(timezone.utc)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
async def _send_invite_email(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
tenant_name: str,
|
||||||
|
invite_url: str,
|
||||||
|
) -> None:
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = f"Invitación para unirse a {tenant_name} en Mi Aplicación"
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; background: #f3f4f6; padding: 40px 0;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; background: #ffffff; border-radius: 8px;
|
||||||
|
overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
|
||||||
|
<div style="background: #2563eb; padding: 32px 40px;">
|
||||||
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Mi Aplicación</h1>
|
||||||
|
<p style="color: #bfdbfe; margin: 8px 0 0;">Sistema de gestión aduanal</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 40px;">
|
||||||
|
<h2 style="color: #111827; font-size: 20px; margin-top: 0;">
|
||||||
|
Te han invitado a {tenant_name}
|
||||||
|
</h2>
|
||||||
|
<p style="color: #4b5563; line-height: 1.6;">
|
||||||
|
Has recibido una invitación para unirte a <strong>{tenant_name}</strong>
|
||||||
|
en Mi Aplicación. Haz clic en el botón para crear tu cuenta.
|
||||||
|
</p>
|
||||||
|
<div style="text-align: center; margin: 32px 0;">
|
||||||
|
<a href="{invite_url}"
|
||||||
|
style="display: inline-block; background: #2563eb; color: #ffffff;
|
||||||
|
text-decoration: none; padding: 14px 32px; border-radius: 6px;
|
||||||
|
font-weight: 600; font-size: 16px;">
|
||||||
|
Aceptar invitación
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6b7280; font-size: 13px; line-height: 1.5;">
|
||||||
|
Este enlace es válido por <strong>48 horas</strong> y es de
|
||||||
|
<strong>un solo uso</strong>.<br>
|
||||||
|
Si no esperabas esta invitación, puedes ignorar este correo.
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
|
||||||
|
<p style="color: #9ca3af; font-size: 12px;">
|
||||||
|
O copia este enlace en tu navegador:<br>
|
||||||
|
<span style="color: #2563eb; word-break: break-all;">{invite_url}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
|
ssl_ctx = ssl.create_default_context()
|
||||||
|
ssl_ctx.check_hostname = False
|
||||||
|
ssl_ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
if settings.SMTP_PORT == 465:
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.SMTP_HOST,
|
||||||
|
port=settings.SMTP_PORT,
|
||||||
|
use_tls=True,
|
||||||
|
tls_context=ssl_ctx,
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
await smtp.send_message(msg)
|
||||||
|
else:
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.SMTP_HOST,
|
||||||
|
port=settings.SMTP_PORT,
|
||||||
|
tls_context=ssl_ctx,
|
||||||
|
) as smtp:
|
||||||
|
await smtp.starttls(tls_context=ssl_ctx)
|
||||||
|
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
await smtp.send_message(msg)
|
||||||
7
backend/api/v1/modules/core/licenses/__init__.py
Normal file
7
backend/api/v1/modules/core/licenses/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Módulo de Licenses
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
156
backend/api/v1/modules/core/licenses/dto.py
Normal file
156
backend/api/v1/modules/core/licenses/dto.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
DTOs para módulo de licencias
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LicensePlanDTO(str, Enum):
|
||||||
|
"""Planes de licencia"""
|
||||||
|
|
||||||
|
FREE = "free"
|
||||||
|
BASIC = "basic"
|
||||||
|
PROFESSIONAL = "professional"
|
||||||
|
ENTERPRISE = "enterprise"
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseStatusDTO(str, Enum):
|
||||||
|
"""Estados de licencia"""
|
||||||
|
|
||||||
|
ACTIVE = "active"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
SUSPENDED = "suspended"
|
||||||
|
PENDING = "pending"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseCreateDTO(BaseModel):
|
||||||
|
"""DTO para crear una nueva licencia"""
|
||||||
|
|
||||||
|
tenant_id: int = Field(..., description="ID del tenant")
|
||||||
|
plan: LicensePlanDTO = Field(..., description="Plan de licencia")
|
||||||
|
max_users: int = Field(default=5, ge=1, description="Número máximo de usuarios")
|
||||||
|
max_storage_gb: int = Field(
|
||||||
|
default=10, ge=1, description="Almacenamiento máximo en GB"
|
||||||
|
)
|
||||||
|
max_monthly_operations: int = Field(
|
||||||
|
default=1000, ge=1, description="Operaciones mensuales máximas"
|
||||||
|
)
|
||||||
|
|
||||||
|
feature_api_access: bool = Field(default=True)
|
||||||
|
feature_advanced_reports: bool = Field(default=False)
|
||||||
|
feature_integrations: bool = Field(default=False)
|
||||||
|
feature_dedicated_support: bool = Field(default=False)
|
||||||
|
|
||||||
|
starts_at: datetime = Field(..., description="Fecha de inicio de vigencia")
|
||||||
|
expires_at: datetime = Field(..., description="Fecha de expiración")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"tenant_id": 1,
|
||||||
|
"plan": "professional",
|
||||||
|
"max_users": 20,
|
||||||
|
"max_storage_gb": 100,
|
||||||
|
"max_monthly_operations": 10000,
|
||||||
|
"feature_api_access": True,
|
||||||
|
"feature_advanced_reports": True,
|
||||||
|
"feature_integrations": True,
|
||||||
|
"feature_dedicated_support": False,
|
||||||
|
"starts_at": "2025-01-01T00:00:00Z",
|
||||||
|
"expires_at": "2025-12-31T23:59:59Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseUpdateDTO(BaseModel):
|
||||||
|
"""DTO para actualizar una licencia"""
|
||||||
|
|
||||||
|
plan: Optional[LicensePlanDTO] = None
|
||||||
|
status: Optional[LicenseStatusDTO] = None
|
||||||
|
max_users: Optional[int] = Field(None, ge=1)
|
||||||
|
max_storage_gb: Optional[int] = Field(None, ge=1)
|
||||||
|
max_monthly_operations: Optional[int] = Field(None, ge=1)
|
||||||
|
|
||||||
|
feature_api_access: Optional[bool] = None
|
||||||
|
feature_advanced_reports: Optional[bool] = None
|
||||||
|
feature_integrations: Optional[bool] = None
|
||||||
|
feature_dedicated_support: Optional[bool] = None
|
||||||
|
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseResponseDTO(BaseModel):
|
||||||
|
"""DTO para respuesta de licencia"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
plan: LicensePlanDTO
|
||||||
|
status: LicenseStatusDTO
|
||||||
|
|
||||||
|
max_users: int
|
||||||
|
max_storage_gb: int
|
||||||
|
max_monthly_operations: int
|
||||||
|
|
||||||
|
feature_api_access: bool
|
||||||
|
feature_advanced_reports: bool
|
||||||
|
feature_integrations: bool
|
||||||
|
feature_dedicated_support: bool
|
||||||
|
|
||||||
|
starts_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseValidationResponseDTO(BaseModel):
|
||||||
|
"""DTO para respuesta de validación de licencia"""
|
||||||
|
|
||||||
|
is_valid: bool
|
||||||
|
status: LicenseStatusDTO
|
||||||
|
plan: LicensePlanDTO
|
||||||
|
expires_at: datetime
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"is_valid": True,
|
||||||
|
"status": "active",
|
||||||
|
"plan": "professional",
|
||||||
|
"expires_at": "2025-12-31T23:59:59Z",
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseUsageResponseDTO(BaseModel):
|
||||||
|
"""DTO para respuesta de uso de licencia"""
|
||||||
|
|
||||||
|
tenant_id: int
|
||||||
|
period_start: datetime
|
||||||
|
period_end: datetime
|
||||||
|
active_users: int
|
||||||
|
storage_used_gb: int
|
||||||
|
operations_count: int
|
||||||
|
api_calls_count: int
|
||||||
|
|
||||||
|
# Límites actuales
|
||||||
|
max_users: int
|
||||||
|
max_storage_gb: int
|
||||||
|
max_monthly_operations: int
|
||||||
|
|
||||||
|
# Porcentajes de uso
|
||||||
|
users_usage_percent: float
|
||||||
|
storage_usage_percent: float
|
||||||
|
operations_usage_percent: float
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
102
backend/api/v1/modules/core/licenses/models.py
Normal file
102
backend/api/v1/modules/core/licenses/models.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Modelos ORM para gestión de licencias
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from api.v1.common.base_models import TimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime
|
||||||
|
from sqlalchemy import Enum as SQLEnum
|
||||||
|
from sqlalchemy import ForeignKey, Integer
|
||||||
|
|
||||||
|
|
||||||
|
class LicensePlan(enum.Enum):
|
||||||
|
"""Planes de licencia disponibles"""
|
||||||
|
|
||||||
|
FREE = "free"
|
||||||
|
BASIC = "basic"
|
||||||
|
PROFESSIONAL = "professional"
|
||||||
|
ENTERPRISE = "enterprise"
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseStatus(enum.Enum):
|
||||||
|
"""Estados de licencia"""
|
||||||
|
|
||||||
|
ACTIVE = "active"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
SUSPENDED = "suspended"
|
||||||
|
PENDING = "pending"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class License(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Modelo de Licencia - Control de planes y límites por tenant
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "licenses"
|
||||||
|
__table_args__ = {"schema": "core"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(
|
||||||
|
Integer, ForeignKey("core.tenants.id"), nullable=False, unique=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plan y características
|
||||||
|
plan = Column(
|
||||||
|
SQLEnum(LicensePlan),
|
||||||
|
default=LicensePlan.FREE,
|
||||||
|
server_default="FREE",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
status = Column(
|
||||||
|
SQLEnum(LicenseStatus),
|
||||||
|
default=LicenseStatus.PENDING,
|
||||||
|
server_default="PENDING",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Límites del plan
|
||||||
|
max_users = Column(Integer, server_default="5", nullable=False)
|
||||||
|
max_storage_gb = Column(Integer, server_default="10", nullable=False)
|
||||||
|
max_monthly_operations = Column(Integer, server_default="1000", nullable=False)
|
||||||
|
|
||||||
|
# Features habilitadas (booleans)
|
||||||
|
feature_api_access = Column(Boolean, default=True, server_default="true")
|
||||||
|
feature_advanced_reports = Column(Boolean, default=False, server_default="false")
|
||||||
|
feature_integrations = Column(Boolean, default=False, server_default="false")
|
||||||
|
feature_dedicated_support = Column(Boolean, default=False, server_default="false")
|
||||||
|
|
||||||
|
# Vigencia
|
||||||
|
starts_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<License(tenant_id={self.tenant_id}, plan={self.plan.value}, status={self.status.value})>"
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseUsage(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Modelo para tracking de uso de licencia
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "license_usage"
|
||||||
|
__table_args__ = {"schema": "core"}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tenant_id = Column(
|
||||||
|
Integer, ForeignKey("core.tenants.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Métricas de uso
|
||||||
|
period_start = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
period_end = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
active_users = Column(Integer, default=0, server_default="0")
|
||||||
|
storage_used_gb = Column(Integer, default=0, server_default="0")
|
||||||
|
operations_count = Column(Integer, default=0, server_default="0")
|
||||||
|
api_calls_count = Column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<LicenseUsage(tenant_id={self.tenant_id}, operations={self.operations_count})>"
|
||||||
119
backend/api/v1/modules/core/licenses/routes.py
Normal file
119
backend/api/v1/modules/core/licenses/routes.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Endpoints API para gestión de licencias
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, has_role
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
LicenseCreateDTO,
|
||||||
|
LicenseResponseDTO,
|
||||||
|
LicenseUpdateDTO,
|
||||||
|
LicenseUsageResponseDTO,
|
||||||
|
LicenseValidationResponseDTO,
|
||||||
|
)
|
||||||
|
from .service import LicenseService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/licenses")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=LicenseResponseDTO, status_code=201)
|
||||||
|
async def create_license(
|
||||||
|
license_data: LicenseCreateDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(has_role("admin")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Crea una nueva licencia para un tenant
|
||||||
|
|
||||||
|
Requiere rol: admin
|
||||||
|
"""
|
||||||
|
service = LicenseService(db)
|
||||||
|
return service.create_license(license_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenant/{tenant_id}", response_model=LicenseResponseDTO)
|
||||||
|
async def get_license_by_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene la licencia de un tenant específico
|
||||||
|
"""
|
||||||
|
service = LicenseService(db)
|
||||||
|
license = service.get_license_by_tenant(tenant_id)
|
||||||
|
if not license:
|
||||||
|
raise HTTPException(status_code=404, detail="License not found")
|
||||||
|
return license
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tenant/{tenant_id}", response_model=LicenseResponseDTO)
|
||||||
|
async def update_license(
|
||||||
|
tenant_id: int,
|
||||||
|
license_data: LicenseUpdateDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(has_role("admin")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza la licencia de un tenant
|
||||||
|
|
||||||
|
Requiere rol: admin
|
||||||
|
"""
|
||||||
|
service = LicenseService(db)
|
||||||
|
license = service.update_license(tenant_id, license_data)
|
||||||
|
if not license:
|
||||||
|
raise HTTPException(status_code=404, detail="License not found")
|
||||||
|
return license
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/validate/{tenant_id}", response_model=LicenseValidationResponseDTO)
|
||||||
|
async def validate_license(
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Valida si la licencia de un tenant está activa y vigente
|
||||||
|
"""
|
||||||
|
service = LicenseService(db)
|
||||||
|
validation = service.validate_license(tenant_id)
|
||||||
|
return LicenseValidationResponseDTO(**validation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage/{tenant_id}", response_model=LicenseUsageResponseDTO)
|
||||||
|
async def get_license_usage(
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene el uso actual de la licencia de un tenant
|
||||||
|
"""
|
||||||
|
service = LicenseService(db)
|
||||||
|
usage = service.get_usage(tenant_id)
|
||||||
|
if not usage:
|
||||||
|
raise HTTPException(status_code=404, detail="License not found")
|
||||||
|
return usage
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-license", response_model=LicenseResponseDTO)
|
||||||
|
async def get_my_license(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene la licencia del tenant del usuario actual
|
||||||
|
"""
|
||||||
|
tenant_id = getattr(request.state, "tenant_id", None)
|
||||||
|
if not tenant_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Tenant ID not found in request")
|
||||||
|
|
||||||
|
service = LicenseService(db)
|
||||||
|
license = service.get_license_by_tenant(tenant_id)
|
||||||
|
if not license:
|
||||||
|
raise HTTPException(status_code=404, detail="License not found")
|
||||||
|
return license
|
||||||
260
backend/api/v1/modules/core/licenses/service.py
Normal file
260
backend/api/v1/modules/core/licenses/service.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Servicio de lógica de negocio para licencias
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
LicenseCreateDTO,
|
||||||
|
LicenseResponseDTO,
|
||||||
|
LicenseUpdateDTO,
|
||||||
|
LicenseUsageResponseDTO,
|
||||||
|
)
|
||||||
|
from .models import License, LicensePlan, LicenseStatus, LicenseUsage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseService:
|
||||||
|
"""Servicio para gestión de licencias"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_license(self, license_data: LicenseCreateDTO) -> LicenseResponseDTO:
|
||||||
|
"""
|
||||||
|
Crea una nueva licencia para un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
license_data: Datos de la licencia
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LicenseResponseDTO
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: Si el tenant ya tiene licencia o hay error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verificar que el tenant no tenga ya una licencia
|
||||||
|
existing = (
|
||||||
|
self.db.query(License)
|
||||||
|
.filter(License.tenant_id == license_data.tenant_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Tenant {license_data.tenant_id} already has a license",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear licencia
|
||||||
|
db_license = License(
|
||||||
|
tenant_id=license_data.tenant_id,
|
||||||
|
plan=LicensePlan(license_data.plan.value),
|
||||||
|
status=LicenseStatus.ACTIVE,
|
||||||
|
max_users=license_data.max_users,
|
||||||
|
max_storage_gb=license_data.max_storage_gb,
|
||||||
|
max_monthly_operations=license_data.max_monthly_operations,
|
||||||
|
feature_api_access=license_data.feature_api_access,
|
||||||
|
feature_advanced_reports=license_data.feature_advanced_reports,
|
||||||
|
feature_integrations=license_data.feature_integrations,
|
||||||
|
feature_dedicated_support=license_data.feature_dedicated_support,
|
||||||
|
starts_at=license_data.starts_at,
|
||||||
|
expires_at=license_data.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(db_license)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(db_license)
|
||||||
|
|
||||||
|
return LicenseResponseDTO.model_validate(db_license)
|
||||||
|
|
||||||
|
except IntegrityError as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"IntegrityError creating license: {str(e)}")
|
||||||
|
raise HTTPException(status_code=400, detail="Database integrity error")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Error creating license: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error creating license")
|
||||||
|
|
||||||
|
def get_license_by_tenant(self, tenant_id: int) -> Optional[LicenseResponseDTO]:
|
||||||
|
"""
|
||||||
|
Obtiene la licencia de un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LicenseResponseDTO o None si no existe
|
||||||
|
"""
|
||||||
|
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
|
||||||
|
if not license:
|
||||||
|
return None
|
||||||
|
return LicenseResponseDTO.model_validate(license)
|
||||||
|
|
||||||
|
def update_license(
|
||||||
|
self, tenant_id: int, license_data: LicenseUpdateDTO
|
||||||
|
) -> Optional[LicenseResponseDTO]:
|
||||||
|
"""
|
||||||
|
Actualiza una licencia
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
license_data: Datos a actualizar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LicenseResponseDTO actualizado o None si no existe
|
||||||
|
"""
|
||||||
|
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
|
||||||
|
if not license:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Actualizar campos proporcionados
|
||||||
|
update_data = license_data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
if field in ["plan", "status"]:
|
||||||
|
# Convertir enums
|
||||||
|
value = LicensePlan(value) if field == "plan" else LicenseStatus(value)
|
||||||
|
setattr(license, field, value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(license)
|
||||||
|
return LicenseResponseDTO.model_validate(license)
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Error updating license for tenant {tenant_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error updating license")
|
||||||
|
|
||||||
|
def validate_license(self, tenant_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Valida si la licencia de un tenant está activa y vigente
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con información de validación
|
||||||
|
"""
|
||||||
|
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
|
||||||
|
|
||||||
|
if not license:
|
||||||
|
return {
|
||||||
|
"is_valid": False,
|
||||||
|
"status": "not_found",
|
||||||
|
"plan": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"reason": "License not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Verificar estado
|
||||||
|
if license.status != LicenseStatus.ACTIVE:
|
||||||
|
return {
|
||||||
|
"is_valid": False,
|
||||||
|
"status": license.status.value,
|
||||||
|
"plan": license.plan.value,
|
||||||
|
"expires_at": license.expires_at,
|
||||||
|
"reason": f"License status is {license.status.value}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verificar vigencia
|
||||||
|
if license.expires_at < now:
|
||||||
|
# Auto-actualizar a expirada
|
||||||
|
license.status = LicenseStatus.EXPIRED
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_valid": False,
|
||||||
|
"status": "expired",
|
||||||
|
"plan": license.plan.value,
|
||||||
|
"expires_at": license.expires_at,
|
||||||
|
"reason": "License has expired",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Licencia válida
|
||||||
|
return {
|
||||||
|
"is_valid": True,
|
||||||
|
"status": license.status.value,
|
||||||
|
"plan": license.plan.value,
|
||||||
|
"expires_at": license.expires_at,
|
||||||
|
"reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_usage(self, tenant_id: int) -> Optional[LicenseUsageResponseDTO]:
|
||||||
|
"""
|
||||||
|
Obtiene el uso actual de la licencia de un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LicenseUsageResponseDTO o None
|
||||||
|
"""
|
||||||
|
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
|
||||||
|
if not license:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Obtener último registro de uso
|
||||||
|
usage = (
|
||||||
|
self.db.query(LicenseUsage)
|
||||||
|
.filter(LicenseUsage.tenant_id == tenant_id)
|
||||||
|
.order_by(LicenseUsage.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not usage:
|
||||||
|
# Crear registro inicial si no existe
|
||||||
|
usage = LicenseUsage(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
period_start=datetime.now(timezone.utc),
|
||||||
|
period_end=datetime.now(timezone.utc),
|
||||||
|
active_users=0,
|
||||||
|
storage_used_gb=0,
|
||||||
|
operations_count=0,
|
||||||
|
api_calls_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calcular porcentajes
|
||||||
|
users_usage = (
|
||||||
|
(usage.active_users / license.max_users * 100)
|
||||||
|
if license.max_users > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
storage_usage = (
|
||||||
|
(usage.storage_used_gb / license.max_storage_gb * 100)
|
||||||
|
if license.max_storage_gb > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
operations_usage = (
|
||||||
|
(usage.operations_count / license.max_monthly_operations * 100)
|
||||||
|
if license.max_monthly_operations > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return LicenseUsageResponseDTO(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
period_start=usage.period_start,
|
||||||
|
period_end=usage.period_end,
|
||||||
|
active_users=usage.active_users,
|
||||||
|
storage_used_gb=usage.storage_used_gb,
|
||||||
|
operations_count=usage.operations_count,
|
||||||
|
api_calls_count=usage.api_calls_count,
|
||||||
|
max_users=license.max_users,
|
||||||
|
max_storage_gb=license.max_storage_gb,
|
||||||
|
max_monthly_operations=license.max_monthly_operations,
|
||||||
|
users_usage_percent=round(users_usage, 2),
|
||||||
|
storage_usage_percent=round(storage_usage, 2),
|
||||||
|
operations_usage_percent=round(operations_usage, 2),
|
||||||
|
)
|
||||||
324
backend/api/v1/modules/core/permissions/README.md
Normal file
324
backend/api/v1/modules/core/permissions/README.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Módulo de Permisos Multi-Tenant
|
||||||
|
|
||||||
|
Sistema completo de permisos granulares para aplicaciones multi-tenant con FastAPI y SQLAlchemy.
|
||||||
|
|
||||||
|
## 📁 Estructura del Módulo
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/api/v1/modules/core/permissions/
|
||||||
|
├── __init__.py # Exports del módulo
|
||||||
|
├── models.py # Modelos SQLAlchemy
|
||||||
|
├── service.py # Lógica de negocio
|
||||||
|
├── dependencies.py # Dependencias FastAPI
|
||||||
|
├── schemas.py # Modelos Pydantic (request/response)
|
||||||
|
└── routes.py # Endpoints de la API
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Componentes
|
||||||
|
|
||||||
|
### **models.py**
|
||||||
|
|
||||||
|
Define los modelos de base de datos:
|
||||||
|
|
||||||
|
- `Permission` - Permisos del sistema (ej: "invoice.view", "invoice.edit")
|
||||||
|
- `ClientRole` - Roles personalizados por cliente
|
||||||
|
- `RolePermission` - Relación roles-permisos
|
||||||
|
- `UserClientRole` - Asignación usuario-rol-cliente
|
||||||
|
- `UserClientPermission` - Permisos directos por usuario
|
||||||
|
|
||||||
|
### **service.py**
|
||||||
|
|
||||||
|
Contiene la clase `PermissionService` con métodos:
|
||||||
|
|
||||||
|
- `get_user_permissions()` - Obtiene todos los permisos de un usuario
|
||||||
|
- `has_permission()` - Verifica un permiso específico
|
||||||
|
- `has_all_permissions()` - Verifica múltiples permisos (AND)
|
||||||
|
- `has_any_permission()` - Verifica múltiples permisos (OR)
|
||||||
|
- `assign_role_to_user()` - Asigna roles a usuarios
|
||||||
|
- `grant_direct_permission()` - Concede permisos directos
|
||||||
|
|
||||||
|
### **dependencies.py**
|
||||||
|
|
||||||
|
Dependencias para proteger rutas:
|
||||||
|
|
||||||
|
- `PermissionChecker` - Clase para verificar múltiples permisos
|
||||||
|
- `RequirePermission` - Clase para verificar un solo permiso
|
||||||
|
- `get_client_id()` - Extrae el ID del cliente del header
|
||||||
|
- `get_permission_service()` - Proporciona instancia del servicio
|
||||||
|
- `get_current_user_permissions()` - Devuelve permisos del usuario
|
||||||
|
|
||||||
|
### **schemas.py**
|
||||||
|
|
||||||
|
Modelos Pydantic para request/response:
|
||||||
|
|
||||||
|
- Responses: `PermissionResponse`, `ClientRoleResponse`, `UserPermissionsResponse`, etc.
|
||||||
|
- Requests: `AssignRoleRequest`, `GrantPermissionRequest`, `CreateRoleRequest`, etc.
|
||||||
|
|
||||||
|
### **routes.py**
|
||||||
|
|
||||||
|
Endpoints de la API:
|
||||||
|
|
||||||
|
- `GET /permissions/me` - Permisos del usuario actual
|
||||||
|
- `GET /permissions/available` - Lista todos los permisos
|
||||||
|
- `GET /permissions/roles` - Lista roles del cliente
|
||||||
|
- `POST /permissions/roles` - Crea un rol
|
||||||
|
- `POST /permissions/assign-role` - Asigna rol a usuario
|
||||||
|
- `POST /permissions/grant-permission` - Concede permiso directo
|
||||||
|
- Ejemplos de rutas protegidas
|
||||||
|
|
||||||
|
## 🚀 Uso Rápido
|
||||||
|
|
||||||
|
### Importar el módulo
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api.v1.modules.core.permissions import (
|
||||||
|
Permission,
|
||||||
|
ClientRole,
|
||||||
|
PermissionService,
|
||||||
|
PermissionChecker,
|
||||||
|
RequirePermission,
|
||||||
|
router
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registrar las rutas
|
||||||
|
|
||||||
|
```python
|
||||||
|
# En backend/api/v1/router.py
|
||||||
|
from api.v1.modules.core.permissions import router as permissions_router
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(permissions_router)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proteger una ruta con permiso único
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from api.v1.modules.core.permissions import RequirePermission
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/invoices")
|
||||||
|
async def list_invoices(
|
||||||
|
_: None = Depends(RequirePermission("invoice.view"))
|
||||||
|
):
|
||||||
|
return {"invoices": [...]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proteger con múltiples permisos
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api.v1.modules.core.permissions import PermissionChecker
|
||||||
|
|
||||||
|
@router.post("/invoices")
|
||||||
|
async def create_invoice(
|
||||||
|
_: None = Depends(PermissionChecker(
|
||||||
|
["invoice.view", "invoice.create"],
|
||||||
|
require_all=True # Requiere TODOS
|
||||||
|
))
|
||||||
|
):
|
||||||
|
return {"created": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usar permisos en la lógica
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api.v1.modules.core.permissions import get_current_user_permissions
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
async def dashboard(
|
||||||
|
permissions: set = Depends(get_current_user_permissions)
|
||||||
|
):
|
||||||
|
widgets = []
|
||||||
|
|
||||||
|
if "invoice.view" in permissions:
|
||||||
|
widgets.append({"type": "invoices", "data": [...]})
|
||||||
|
|
||||||
|
return {"widgets": widgets}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Base de Datos
|
||||||
|
|
||||||
|
### Ejecutar migración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto crea las tablas y permisos iniciales:
|
||||||
|
|
||||||
|
- **invoice.*** - view, create, edit, delete, approve
|
||||||
|
- **user.*** - view, create, edit, delete
|
||||||
|
- **report.*** - financial.view, admin.view, export
|
||||||
|
- **roles.*** - view, create, edit, delete, assign
|
||||||
|
- **permissions.*** - view, grant
|
||||||
|
|
||||||
|
## 🔐 Flujo de Autenticación
|
||||||
|
|
||||||
|
1. Usuario hace request con token JWT de Keycloak
|
||||||
|
2. Header `X-Client-ID` indica el cliente/tenant
|
||||||
|
3. Sistema extrae `user_id` del token
|
||||||
|
4. Consulta permisos del usuario en ese cliente
|
||||||
|
5. Valida si tiene el permiso requerido
|
||||||
|
6. Devuelve 200 OK o 403 Forbidden
|
||||||
|
|
||||||
|
## 💡 Ejemplos Prácticos
|
||||||
|
|
||||||
|
### Crear un rol personalizado
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api.v1.modules.core.permissions import PermissionService
|
||||||
|
from core.database import get_db
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
service = PermissionService(db)
|
||||||
|
|
||||||
|
# Crear rol
|
||||||
|
role = ClientRole(
|
||||||
|
client_id=1,
|
||||||
|
name="Contador",
|
||||||
|
code="accountant",
|
||||||
|
description="Acceso a módulo contable"
|
||||||
|
)
|
||||||
|
db.add(role)
|
||||||
|
db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asignar permisos a un rol
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api.v1.modules.core.permissions.models import RolePermission
|
||||||
|
|
||||||
|
# Obtener permisos de facturación
|
||||||
|
invoice_perms = db.query(Permission).filter(
|
||||||
|
Permission.module == "invoice"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Asignar al rol
|
||||||
|
for perm in invoice_perms:
|
||||||
|
role_perm = RolePermission(
|
||||||
|
client_role_id=role.id,
|
||||||
|
permission_id=perm.id
|
||||||
|
)
|
||||||
|
db.add(role_perm)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asignar rol a usuario
|
||||||
|
|
||||||
|
```python
|
||||||
|
service.assign_role_to_user(
|
||||||
|
user_id="user-uuid-from-keycloak",
|
||||||
|
client_id=1,
|
||||||
|
role_id=role.id,
|
||||||
|
assigned_by="admin-uuid"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conceder permiso temporal
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
service.grant_direct_permission(
|
||||||
|
user_id="user-uuid",
|
||||||
|
client_id=1,
|
||||||
|
permission_code="invoice.delete",
|
||||||
|
assigned_by="admin-uuid",
|
||||||
|
expires_at=datetime.utcnow() + timedelta(days=7)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ Optimización de Rendimiento
|
||||||
|
|
||||||
|
### 1. Caché con Redis
|
||||||
|
|
||||||
|
```python
|
||||||
|
import redis
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
redis_client = redis.Redis(host='localhost', port=6379)
|
||||||
|
|
||||||
|
def get_cached_permissions(user_id: str, client_id: int) -> set:
|
||||||
|
cache_key = f"perms:{user_id}:{client_id}"
|
||||||
|
|
||||||
|
cached = redis_client.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return set(cached.decode().split(','))
|
||||||
|
|
||||||
|
# Consultar DB
|
||||||
|
service = PermissionService(db)
|
||||||
|
permissions = service.get_user_permissions(user_id, client_id)
|
||||||
|
|
||||||
|
# Cachear por 5 minutos
|
||||||
|
redis_client.setex(cache_key, 300, ','.join(permissions))
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Índices de Base de Datos
|
||||||
|
|
||||||
|
Ya están definidos en los modelos:
|
||||||
|
|
||||||
|
- Índices compuestos para consultas eficientes
|
||||||
|
- Índices únicos para prevenir duplicados
|
||||||
|
- Índices en foreign keys
|
||||||
|
|
||||||
|
### 3. Query Optimization
|
||||||
|
|
||||||
|
El servicio usa JOINs eficientes en lugar de N+1 queries.
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from api.v1.modules.core.permissions import PermissionService
|
||||||
|
from api.v1.modules.core.permissions.models import Permission, ClientRole
|
||||||
|
|
||||||
|
def test_user_has_permission_from_role(db_session):
|
||||||
|
# Setup
|
||||||
|
perm = Permission(code="invoice.view", module="invoice", action="view")
|
||||||
|
db_session.add(perm)
|
||||||
|
|
||||||
|
role = ClientRole(client_id=1, code="viewer", name="Viewer")
|
||||||
|
db_session.add(role)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Test
|
||||||
|
service = PermissionService(db_session)
|
||||||
|
assert service.has_permission("user-123", 1, "invoice.view")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Notas Importantes
|
||||||
|
|
||||||
|
- **Client ID**: Por defecto se obtiene del header `X-Client-ID`, pero puede adaptarse a subdominios o JWT
|
||||||
|
- **User ID**: Se extrae del campo `sub` del token JWT de Keycloak
|
||||||
|
- **Permisos Directos**: Pueden revocar permisos heredados de roles (`is_granted=False`)
|
||||||
|
- **Soft Delete**: Los roles y permisos se desactivan (`is_active=False`) en lugar de eliminarse
|
||||||
|
|
||||||
|
## 🔗 Integración con Keycloak
|
||||||
|
|
||||||
|
Los roles globales de Keycloak pueden coexistir con los roles locales:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/protected")
|
||||||
|
async def protected_route(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
permissions: set = Depends(get_current_user_permissions)
|
||||||
|
):
|
||||||
|
# Verificar rol global de Keycloak
|
||||||
|
keycloak_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||||
|
|
||||||
|
if "super_admin" in keycloak_roles:
|
||||||
|
# Super admin tiene acceso total
|
||||||
|
return {"access": "granted", "level": "global"}
|
||||||
|
|
||||||
|
# Verificar permisos a nivel de cliente
|
||||||
|
if "invoice.view" in permissions:
|
||||||
|
return {"access": "granted", "level": "client"}
|
||||||
|
|
||||||
|
raise HTTPException(403, "No access")
|
||||||
|
```
|
||||||
38
backend/api/v1/modules/core/permissions/__init__.py
Normal file
38
backend/api/v1/modules/core/permissions/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Módulo de permisos multi-tenant.
|
||||||
|
Proporciona modelos, servicios y rutas para gestión de permisos granulares por companye.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Permission,
|
||||||
|
CompanyRole,
|
||||||
|
RolePermission,
|
||||||
|
UserCompanyRole,
|
||||||
|
UserCompanyPermission,
|
||||||
|
)
|
||||||
|
from .service import PermissionService
|
||||||
|
from .dependencies import (
|
||||||
|
PermissionChecker,
|
||||||
|
RequirePermission,
|
||||||
|
get_permission_service,
|
||||||
|
get_current_user_permissions,
|
||||||
|
)
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Models
|
||||||
|
"Permission",
|
||||||
|
"CompanyRole",
|
||||||
|
"RolePermission",
|
||||||
|
"UserCompanyRole",
|
||||||
|
"UserCompanyPermission",
|
||||||
|
# Service
|
||||||
|
"PermissionService",
|
||||||
|
# Dependencies
|
||||||
|
"PermissionChecker",
|
||||||
|
"RequirePermission",
|
||||||
|
"get_permission_service",
|
||||||
|
"get_current_user_permissions",
|
||||||
|
# Router
|
||||||
|
"router",
|
||||||
|
]
|
||||||
205
backend/api/v1/modules/core/permissions/cache.py
Normal file
205
backend/api/v1/modules/core/permissions/cache.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional, Set, Iterable
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis # type: ignore
|
||||||
|
except Exception: # pragma: no cover - redis is optional in some envs
|
||||||
|
redis = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionCache:
|
||||||
|
"""
|
||||||
|
Caché de permisos basada en Valkey/Redis.
|
||||||
|
|
||||||
|
- Clave por combinación (tenant_id, company_id, user_id)
|
||||||
|
- Guarda el set de códigos de permiso como string CSV
|
||||||
|
- TTL controlado por configuración (`PERMISSION_CACHE_TTL_SECONDS`)
|
||||||
|
|
||||||
|
Todas las operaciones fallan en modo silencioso para no afectar el flujo
|
||||||
|
principal de la aplicación si Redis/Valkey no está disponible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
KEY_PREFIX = "permissions:v1"
|
||||||
|
|
||||||
|
def __init__(self, client: "redis.Redis | None" = None) -> None: # type: ignore[name-defined]
|
||||||
|
self.ttl_seconds = int(getattr(settings, "PERMISSION_CACHE_TTL_SECONDS", 300) or 300)
|
||||||
|
enabled_flag = bool(getattr(settings, "PERMISSION_CACHE_ENABLED", True))
|
||||||
|
|
||||||
|
# Si redis no está instalado, deshabilitar caché
|
||||||
|
if redis is None:
|
||||||
|
self._client = None
|
||||||
|
self.enabled = False
|
||||||
|
return
|
||||||
|
|
||||||
|
if client is not None:
|
||||||
|
self._client = client
|
||||||
|
self.enabled = enabled_flag
|
||||||
|
return
|
||||||
|
|
||||||
|
url = (
|
||||||
|
os.getenv("VALKEY_URL")
|
||||||
|
or os.getenv("REDIS_URL")
|
||||||
|
or getattr(settings, "VALKEY_URL", "redis://valkey:6379/0")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# decode_responses=True para trabajar con str en lugar de bytes
|
||||||
|
self._client = redis.Redis.from_url(url, decode_responses=True)
|
||||||
|
# Probar conexión rápida (no crítico si falla)
|
||||||
|
if enabled_flag:
|
||||||
|
try:
|
||||||
|
self._client.ping()
|
||||||
|
self.enabled = True
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_ping_failed",
|
||||||
|
extra={"url": url},
|
||||||
|
)
|
||||||
|
self.enabled = False
|
||||||
|
else:
|
||||||
|
self.enabled = False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_init_failed",
|
||||||
|
extra={"url": url, "error": str(exc)},
|
||||||
|
)
|
||||||
|
self._client = None
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers de clave
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def build_permissions_key(
|
||||||
|
self,
|
||||||
|
tenant_id: Optional[int],
|
||||||
|
company_id: int,
|
||||||
|
user_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Construye la clave única de caché para un usuario en una compañía.
|
||||||
|
"""
|
||||||
|
tenant_part = str(tenant_id) if tenant_id is not None else "global"
|
||||||
|
return f"{self.KEY_PREFIX}:tenant:{tenant_part}:company:{company_id}:user:{user_id}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Operaciones de lectura/escritura
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_permissions(
|
||||||
|
self,
|
||||||
|
cache_key: str,
|
||||||
|
**context: object,
|
||||||
|
) -> Optional[Set[str]]:
|
||||||
|
"""
|
||||||
|
Obtiene el set de permisos desde caché.
|
||||||
|
|
||||||
|
Devuelve:
|
||||||
|
- set[str] si hay caché válido
|
||||||
|
- None si no hay entrada o si la caché está deshabilitada
|
||||||
|
"""
|
||||||
|
if not self.enabled or not self._client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = self._client.get(cache_key)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not raw:
|
||||||
|
return set()
|
||||||
|
return set(raw.split(","))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_get_failed",
|
||||||
|
extra={"cache_key": cache_key, "error": str(exc), **context},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_permissions(
|
||||||
|
self,
|
||||||
|
cache_key: str,
|
||||||
|
permissions: Iterable[str],
|
||||||
|
**context: object,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Escribe el set de permisos en caché con TTL.
|
||||||
|
"""
|
||||||
|
if not self.enabled or not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = ",".join(sorted(set(permissions)))
|
||||||
|
self._client.setex(cache_key, self.ttl_seconds, value)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_set_failed",
|
||||||
|
extra={"cache_key": cache_key, "error": str(exc), **context},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Invalidaciones
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _delete_pattern(self, pattern: str) -> None:
|
||||||
|
"""
|
||||||
|
Elimina todas las llaves que coincidan con un patrón.
|
||||||
|
"""
|
||||||
|
if not self.enabled or not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# scan_iter evita bloquear Redis en grandes keyspaces
|
||||||
|
keys = list(self._client.scan_iter(match=pattern))
|
||||||
|
if keys:
|
||||||
|
self._client.delete(*keys)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_delete_pattern_failed",
|
||||||
|
extra={"pattern": pattern, "error": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def invalidate_user(
|
||||||
|
self,
|
||||||
|
tenant_id: Optional[int],
|
||||||
|
company_id: int,
|
||||||
|
user_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Invalida el caché de permisos para un usuario específico.
|
||||||
|
"""
|
||||||
|
if not self.enabled or not self._client:
|
||||||
|
return
|
||||||
|
|
||||||
|
cache_key = self.build_permissions_key(tenant_id, company_id, user_id)
|
||||||
|
try:
|
||||||
|
self._client.delete(cache_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_user_failed",
|
||||||
|
extra={
|
||||||
|
"cache_key": cache_key,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"company_id": company_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def invalidate_company(self, company_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Invalida el caché de permisos para todos los usuarios de una compañía.
|
||||||
|
"""
|
||||||
|
pattern = f"{self.KEY_PREFIX}:tenant:*:company:{company_id}:user:*"
|
||||||
|
self._delete_pattern(pattern)
|
||||||
|
|
||||||
|
def invalidate_all(self) -> None:
|
||||||
|
"""
|
||||||
|
Elimina TODAS las entradas del caché de permisos.
|
||||||
|
Úsese con precaución (ej. cleanup_cli).
|
||||||
|
"""
|
||||||
|
pattern = f"{self.KEY_PREFIX}:*"
|
||||||
|
self._delete_pattern(pattern)
|
||||||
|
|
||||||
68
backend/api/v1/modules/core/permissions/cleanup_cli.py
Normal file
68
backend/api/v1/modules/core/permissions/cleanup_cli.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Script CLI para LIMPIEZA TOTAL del sistema de permisos.
|
||||||
|
Borra todos los roles, asignaciones y el catálogo de permisos.
|
||||||
|
Úselo con precaución.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
docker exec -it <container> python3 -m api.v1.modules.core.permissions.cleanup_cli
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# Configurar logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Asegurar que el backend esté en el path
|
||||||
|
sys.path.append(os.path.abspath("."))
|
||||||
|
sys.path.append(os.path.abspath("backend"))
|
||||||
|
|
||||||
|
from core.database import CoreSessionLocal
|
||||||
|
from api.v1.modules.core.permissions.cache import PermissionCache
|
||||||
|
|
||||||
|
def run_cleanup():
|
||||||
|
"""Ejecuta el borrado de tablas en orden de dependencias."""
|
||||||
|
logger.warning("INICIANDO LIMPIEZA TOTAL DE PERMISOS Y ROLES...")
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
# 1. Borrar asignaciones directas de permisos a usuarios
|
||||||
|
logger.info("Borrando asignaciones directas de usuario...")
|
||||||
|
db.execute(text("DELETE FROM core.user_company_permissions"))
|
||||||
|
|
||||||
|
# 2. Borrar relación entre roles y permisos
|
||||||
|
logger.info("Borrando mapeo de roles y permisos...")
|
||||||
|
db.execute(text("DELETE FROM core.role_permissions"))
|
||||||
|
|
||||||
|
# 3. Borrar asignación de roles a usuarios
|
||||||
|
logger.info("Borrando asignación de roles a usuarios...")
|
||||||
|
db.execute(text("DELETE FROM core.user_company_roles"))
|
||||||
|
|
||||||
|
# 4. Borrar los roles mismos
|
||||||
|
logger.info("Borrando el catálogo de roles...")
|
||||||
|
db.execute(text("DELETE FROM core.company_roles"))
|
||||||
|
|
||||||
|
# 5. Borrar el catálogo base de permisos
|
||||||
|
logger.info("Borrando el catálogo base de permisos...")
|
||||||
|
db.execute(text("DELETE FROM core.permissions"))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
# Limpiar también el caché de permisos en Valkey
|
||||||
|
PermissionCache().invalidate_all()
|
||||||
|
logger.info("=" * 40)
|
||||||
|
logger.info("LIMPIEZA COMPLETADA CON ÉXITO")
|
||||||
|
logger.info("El sistema de permisos está ahora en blanco.")
|
||||||
|
logger.info("=" * 40)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Error crítico durante la limpieza: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_cleanup()
|
||||||
205
backend/api/v1/modules/core/permissions/dependencies.py
Normal file
205
backend/api/v1/modules/core/permissions/dependencies.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Dependencias de FastAPI para verificación de permisos multi-tenant.
|
||||||
|
Proporciona decoradores y funciones para proteger rutas con permisos específicos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Callable
|
||||||
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from functools import wraps
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user # Asumiendo que existe esta función
|
||||||
|
from .service import PermissionService
|
||||||
|
|
||||||
|
# Dependencia para obtener el servicio de permisos
|
||||||
|
def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService:
|
||||||
|
"""
|
||||||
|
Crea una instancia del servicio de permisos con la sesión de base de datos.
|
||||||
|
"""
|
||||||
|
return PermissionService(db)
|
||||||
|
|
||||||
|
|
||||||
|
# Clase para verificación de permisos (puede usarse como dependencia)
|
||||||
|
class PermissionChecker:
|
||||||
|
"""
|
||||||
|
Verificador de permisos que puede usarse como dependencia de FastAPI.
|
||||||
|
|
||||||
|
Ejemplo de uso:
|
||||||
|
@app.get("/invoices")
|
||||||
|
async def list_invoices(
|
||||||
|
_: None = Depends(PermissionChecker(["invoice.view"]))
|
||||||
|
):
|
||||||
|
return {"invoices": [...]}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, required_permissions: List[str], require_all: bool = True):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
required_permissions: Lista de permisos requeridos
|
||||||
|
require_all: Si True, requiere TODOS los permisos.
|
||||||
|
Si False, requiere AL MENOS UNO.
|
||||||
|
"""
|
||||||
|
self.required_permissions = required_permissions
|
||||||
|
self.require_all = require_all
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
company_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
permission_service: PermissionService = Depends(get_permission_service),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verifica que el usuario tenga los permisos requeridos.
|
||||||
|
|
||||||
|
Lanza HTTPException 403 si no tiene permisos.
|
||||||
|
"""
|
||||||
|
user_id = current_user.get("sub") or current_user.get("id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not found in token",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar permisos sobre la compañía
|
||||||
|
if self.require_all:
|
||||||
|
has_access = permission_service.has_all_permissions(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
permission_codes=self.required_permissions,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_access = permission_service.has_any_permission(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
permission_codes=self.required_permissions,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_access:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing required permissions: {', '.join(self.required_permissions)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Función alternativa para verificar un solo permiso
|
||||||
|
class RequirePermission:
|
||||||
|
"""
|
||||||
|
Verificador simple para un único permiso.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
@app.post("/invoices")
|
||||||
|
async def create_invoice(
|
||||||
|
_: None = Depends(RequirePermission("invoice.create"))
|
||||||
|
):
|
||||||
|
return {"created": True}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, permission_code: str):
|
||||||
|
self.permission_code = permission_code
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
company_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
permission_service: PermissionService = Depends(get_permission_service),
|
||||||
|
):
|
||||||
|
user_id = current_user.get("sub") or current_user.get("id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not found in token",
|
||||||
|
)
|
||||||
|
|
||||||
|
has_permission = permission_service.has_permission(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
permission_code=self.permission_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_permission:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing required permission: {self.permission_code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Decorador personalizado para aplicar a funciones (opcional)
|
||||||
|
def require_permissions(*permissions: str, require_all: bool = True):
|
||||||
|
"""
|
||||||
|
Decorador para verificar permisos en funciones.
|
||||||
|
Útil para lógica de negocio fuera de rutas FastAPI.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
@require_permissions("invoice.edit", "invoice.view")
|
||||||
|
def update_invoice_logic(invoice_id: int, user_id: str, company_id: int, db: Session):
|
||||||
|
# Lógica de actualización
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# Extraer user_id, company_id y db de los argumentos
|
||||||
|
user_id = kwargs.get("user_id")
|
||||||
|
company_id = kwargs.get("company_id")
|
||||||
|
db = kwargs.get("db")
|
||||||
|
|
||||||
|
if not all([user_id, company_id, db]):
|
||||||
|
raise ValueError(
|
||||||
|
"Function must receive 'user_id', 'company_id', and 'db' as keyword arguments"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar permisos
|
||||||
|
permission_service = PermissionService(db)
|
||||||
|
|
||||||
|
if require_all:
|
||||||
|
has_access = permission_service.has_all_permissions(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
permission_codes=list(permissions),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_access = permission_service.has_any_permission(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
permission_codes=list(permissions),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_access:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Missing required permissions: {', '.join(permissions)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# Función helper para obtener permisos del usuario actual
|
||||||
|
async def get_current_user_permissions(
|
||||||
|
company_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
permission_service: PermissionService = Depends(get_permission_service),
|
||||||
|
) -> set:
|
||||||
|
"""
|
||||||
|
Devuelve todos los permisos del usuario actual en la compañía.
|
||||||
|
Útil para endpoints que necesitan conocer los permisos disponibles.
|
||||||
|
"""
|
||||||
|
user_id = current_user.get("sub") or current_user.get("id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User ID not found in token",
|
||||||
|
)
|
||||||
|
|
||||||
|
return permission_service.get_user_permissions(user_id, company_id, use_cache=True)
|
||||||
247
backend/api/v1/modules/core/permissions/models.py
Normal file
247
backend/api/v1/modules/core/permissions/models.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Modelos de permisos multi-tenant para el sistema.
|
||||||
|
Este módulo define el sistema de permisos granular por compañia/tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import (
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
ForeignKey,
|
||||||
|
DateTime,
|
||||||
|
Boolean,
|
||||||
|
UniqueConstraint,
|
||||||
|
Index,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||||
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
# Modelo para permisos del sistema
|
||||||
|
# Representa acciones específicas como "invoice.view", "invoice.edit", etc.
|
||||||
|
class Permission(Base, TimestampMixin):
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(
|
||||||
|
String(100), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
# Código único del permiso (ej: "invoice.view", "user.edit")
|
||||||
|
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
|
# Descripción legible del permiso
|
||||||
|
|
||||||
|
module: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
# Módulo al que pertenece (ej: "invoice", "user", "report")
|
||||||
|
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
# Acción específica (ej: "view", "edit", "delete", "create")
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default="true", nullable=False
|
||||||
|
)
|
||||||
|
# Permite desactivar permisos sin eliminarlos
|
||||||
|
|
||||||
|
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
role_permissions: Mapped[list["RolePermission"]] = relationship(
|
||||||
|
"RolePermission", back_populates="permission", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
user_company_permissions: Mapped[list["UserCompanyPermission"]] = relationship(
|
||||||
|
"UserCompanyPermission",
|
||||||
|
back_populates="permission",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Modelo para roles personalizados por compañia/tenant
|
||||||
|
# Cada compañia puede definir sus propios roles con nombres personalizados
|
||||||
|
class CompanyRole(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "company_roles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
# Nombre del rol (ej: "Administrador", "Contador", "Vendedor")
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
# Código único del rol dentro del compañia (ej: "admin", "accountant")
|
||||||
|
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String(255))
|
||||||
|
# Descripción del rol
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default="true", nullable=False
|
||||||
|
)
|
||||||
|
# Permite desactivar roles sin eliminarlos
|
||||||
|
|
||||||
|
# Restricción: el código del rol debe ser único por compañia
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"company_id", "tenant_id", "code", name="uq_company_role_code"
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_company_roles_company_id_is_active",
|
||||||
|
"company_id",
|
||||||
|
"tenant_id",
|
||||||
|
"is_active",
|
||||||
|
),
|
||||||
|
{"schema": "core", "extend_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
role_permissions: Mapped[list["RolePermission"]] = relationship(
|
||||||
|
"RolePermission", back_populates="company_role", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
user_company_roles: Mapped[list["UserCompanyRole"]] = relationship(
|
||||||
|
"UserCompanyRole",
|
||||||
|
back_populates="company_role",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Tabla de relación entre roles de compañia y permisos
|
||||||
|
# Define qué permisos tiene cada rol
|
||||||
|
class RolePermission(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "role_permissions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
company_role_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("core.company_roles.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
permission_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("core.permissions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restricción: un permiso no puede estar duplicado en el mismo rol
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("company_role_id", "permission_id", name="uq_role_permission"),
|
||||||
|
Index("ix_role_permissions_composite", "company_role_id", "permission_id"),
|
||||||
|
{"schema": "core", "extend_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
company_role: Mapped["CompanyRole"] = relationship(
|
||||||
|
"CompanyRole", back_populates="role_permissions"
|
||||||
|
)
|
||||||
|
permission: Mapped["Permission"] = relationship(
|
||||||
|
"Permission", back_populates="role_permissions"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Tabla de relación entre usuarios y roles de compañia
|
||||||
|
# Define qué roles tiene cada usuario en cada compañia
|
||||||
|
class UserCompanyRole(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "user_company_roles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
# ID del usuario (puede ser UUID de Keycloak u otro identificador)
|
||||||
|
|
||||||
|
company_role_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("core.company_roles.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default="true", nullable=False
|
||||||
|
)
|
||||||
|
# Permite desactivar asignaciones sin eliminarlas
|
||||||
|
|
||||||
|
assigned_by: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
# ID del usuario que asignó este rol
|
||||||
|
|
||||||
|
# Restricción: un usuario no puede tener el mismo rol duplicado en un compañia
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"company_id",
|
||||||
|
"tenant_id",
|
||||||
|
"company_role_id",
|
||||||
|
name="uq_user_company_role",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_user_company_roles_user_company",
|
||||||
|
"user_id",
|
||||||
|
"company_id",
|
||||||
|
"tenant_id",
|
||||||
|
"is_active",
|
||||||
|
),
|
||||||
|
{"schema": "core", "extend_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
company_role: Mapped["CompanyRole"] = relationship(
|
||||||
|
"CompanyRole", back_populates="user_company_roles"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Tabla para permisos directos de usuario por compañia (opcional)
|
||||||
|
# Permite asignar permisos específicos a un usuario sin necesidad de un rol
|
||||||
|
# Útil para casos excepcionales o permisos temporales
|
||||||
|
class UserCompanyPermission(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "user_company_permissions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
# ID del usuario
|
||||||
|
|
||||||
|
permission_id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("core.permissions.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_granted: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default="true", nullable=False
|
||||||
|
)
|
||||||
|
# True = permiso concedido, False = permiso revocado explícitamente
|
||||||
|
# Permite revocar permisos que vienen de roles
|
||||||
|
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default="true", nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assigned_by: Mapped[Optional[str]] = mapped_column(String(100))
|
||||||
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||||
|
# Permite permisos temporales con fecha de expiración
|
||||||
|
|
||||||
|
# Restricción: un usuario no puede tener el mismo permiso duplicado en un compañia
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"company_id",
|
||||||
|
"tenant_id",
|
||||||
|
"permission_id",
|
||||||
|
name="uq_user_company_permission",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_user_company_permissions_composite",
|
||||||
|
"user_id",
|
||||||
|
"company_id",
|
||||||
|
"tenant_id",
|
||||||
|
"is_active",
|
||||||
|
),
|
||||||
|
{"schema": "core", "extend_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relaciones
|
||||||
|
permission: Mapped["Permission"] = relationship(
|
||||||
|
"Permission", back_populates="user_company_permissions"
|
||||||
|
)
|
||||||
101
backend/api/v1/modules/core/permissions/registry.py
Normal file
101
backend/api/v1/modules/core/permissions/registry.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Registro centralizado para la modulación de permisos.
|
||||||
|
Permite que cada módulo registre sus propios permisos de forma dinámica.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PermissionDefinition:
|
||||||
|
"""Representa la definición de un permiso en un módulo."""
|
||||||
|
code: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
module: Optional[str] = None
|
||||||
|
action: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Lógica de autocompletado para evitar redundancia."""
|
||||||
|
# Si el código tiene el formato "modulo.sub.accion" o "modulo.accion"
|
||||||
|
parts = self.code.split(".")
|
||||||
|
|
||||||
|
# Extraer módulo si no se especificó
|
||||||
|
if not self.module and len(parts) > 1:
|
||||||
|
self.module = parts[0]
|
||||||
|
elif not self.module:
|
||||||
|
self.module = "system" # Default fallback
|
||||||
|
|
||||||
|
# Extraer acción si no se especificó (es la última parte del código)
|
||||||
|
if not self.action and len(parts) > 1:
|
||||||
|
self.action = parts[-1]
|
||||||
|
elif not self.action:
|
||||||
|
self.action = "view" # Default fallback
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionRegistry:
|
||||||
|
"""
|
||||||
|
Registro Singleton para permisos de la aplicación.
|
||||||
|
Cada módulo de la API debe importar este registro y dar de alta sus permisos.
|
||||||
|
"""
|
||||||
|
_instance = None
|
||||||
|
_permissions: Dict[str, PermissionDefinition] = {}
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(PermissionRegistry, cls).__new__(cls)
|
||||||
|
cls._permissions = {}
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls,
|
||||||
|
code: str,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
module: Optional[str] = None,
|
||||||
|
action: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Registra un nuevo permiso en el sistema.
|
||||||
|
"""
|
||||||
|
if code in cls._permissions:
|
||||||
|
logger.debug(f"Permiso {code} ya está registrado, actualizando metadatos.")
|
||||||
|
|
||||||
|
cls._permissions[code] = PermissionDefinition(
|
||||||
|
code=code,
|
||||||
|
description=description,
|
||||||
|
module=module,
|
||||||
|
action=action
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register_many(cls, permissions_list: List[tuple]) -> None:
|
||||||
|
"""
|
||||||
|
Registra múltiples permisos desde una lista de tuplas.
|
||||||
|
Útil para migrar seeds estáticos.
|
||||||
|
"""
|
||||||
|
for item in permissions_list:
|
||||||
|
if len(item) == 2: # (code, description)
|
||||||
|
cls.register(code=item[0], description=item[1])
|
||||||
|
elif len(item) >= 3: # (code, description, module, ...)
|
||||||
|
cls.register(
|
||||||
|
code=item[0],
|
||||||
|
description=item[1],
|
||||||
|
module=item[2],
|
||||||
|
action=item[3] if len(item) > 3 else None
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls) -> List[PermissionDefinition]:
|
||||||
|
"""Retorna todos los permisos registrados."""
|
||||||
|
return list(cls._permissions.values())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_module(cls, module_name: str) -> List[PermissionDefinition]:
|
||||||
|
"""Retorna los permisos de un módulo específico."""
|
||||||
|
return [p for p in cls._permissions.values() if p.module == module_name]
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia global para facilitar el acceso
|
||||||
|
registry = PermissionRegistry()
|
||||||
1364
backend/api/v1/modules/core/permissions/routes.py
Normal file
1364
backend/api/v1/modules/core/permissions/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
331
backend/api/v1/modules/core/permissions/schemas.py
Normal file
331
backend/api/v1/modules/core/permissions/schemas.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
Esquemas Pydantic para el módulo de permisos.
|
||||||
|
Define los modelos de request/response para las APIs de permisos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SCHEMAS DE RESPONSE
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionResponse(BaseModel):
|
||||||
|
"""Esquema de respuesta para un permiso individual."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str = Field(..., description="Código único del permiso (ej: 'invoice.view')")
|
||||||
|
description: Optional[str] = Field(None, description="Descripción del permiso")
|
||||||
|
module: str = Field(..., description="Módulo al que pertenece (ej: 'invoice')")
|
||||||
|
action: str = Field(..., description="Acción específica (ej: 'view', 'edit')")
|
||||||
|
is_active: bool = Field(..., description="Si el permiso está activo")
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyRoleResponse(BaseModel):
|
||||||
|
"""Esquema de respuesta para un rol de companye."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
company_id: int = Field(..., description="ID del companye al que pertenece el rol")
|
||||||
|
name: str = Field(..., description="Nombre del rol (ej: 'Administrador')")
|
||||||
|
code: str = Field(..., description="Código del rol (ej: 'admin')")
|
||||||
|
description: Optional[str] = Field(None, description="Descripción del rol")
|
||||||
|
is_active: bool = Field(..., description="Si el rol está activo")
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyRoleWithPermissionsResponse(CompanyRoleResponse):
|
||||||
|
"""Esquema de respuesta para un rol con sus permisos incluidos."""
|
||||||
|
|
||||||
|
permissions: List[PermissionResponse] = Field(
|
||||||
|
default_factory=list, description="Lista de permisos asignados a este rol"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermissionsResponse(BaseModel):
|
||||||
|
"""Esquema de respuesta para los permisos de un usuario."""
|
||||||
|
|
||||||
|
user_id: str = Field(..., description="ID del usuario")
|
||||||
|
company_id: int = Field(..., description="ID del companye")
|
||||||
|
tenant_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"ID del tenant resuelto en backend para esta compañía. Permite "
|
||||||
|
"al frontend dejar de leer tenant_id desde claims del JWT."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
permissions: List[str] = Field(
|
||||||
|
default_factory=list, description="Lista de códigos de permisos del usuario"
|
||||||
|
)
|
||||||
|
roles: List[str] = Field(
|
||||||
|
default_factory=list, description="Lista de nombres de roles del usuario"
|
||||||
|
)
|
||||||
|
allowed_systems: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCompanyRoleResponse(BaseModel):
|
||||||
|
"""Esquema de respuesta para la asignación de rol a usuario."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: str
|
||||||
|
company_id: int
|
||||||
|
company_role_id: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
assigned_by: Optional[str] = None
|
||||||
|
company_role: Optional[CompanyRoleResponse] = Field(None, description="Información del rol asignado")
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCompanyPermissionResponse(BaseModel):
|
||||||
|
"""Esquema de respuesta para un permiso directo de usuario."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: str
|
||||||
|
company_id: int
|
||||||
|
permission_id: int
|
||||||
|
permission_code: Optional[str] = None
|
||||||
|
is_granted: bool = Field(
|
||||||
|
..., description="True si está concedido, False si está revocado"
|
||||||
|
)
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
assigned_by: Optional[str] = None
|
||||||
|
expires_at: Optional[datetime] = Field(
|
||||||
|
None, description="Fecha de expiración del permiso"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SCHEMAS DE REQUEST
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class AssignRoleRequest(BaseModel):
|
||||||
|
"""Esquema de request para asignar un rol a un usuario."""
|
||||||
|
|
||||||
|
user_id: str = Field(..., description="ID del usuario al que se asignará el rol")
|
||||||
|
role_id: int = Field(..., description="ID del rol a asignar")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveRoleRequest(BaseModel):
|
||||||
|
"""Esquema de request para remover un rol de un usuario."""
|
||||||
|
|
||||||
|
user_id: str = Field(..., description="ID del usuario")
|
||||||
|
role_id: int = Field(..., description="ID del rol a remover")
|
||||||
|
|
||||||
|
|
||||||
|
class GrantPermissionRequest(BaseModel):
|
||||||
|
"""Esquema de request para conceder un permiso directo a un usuario."""
|
||||||
|
|
||||||
|
user_id: str = Field(..., description="ID del usuario")
|
||||||
|
permission_code: str = Field(
|
||||||
|
..., description="Código del permiso a conceder (ej: 'invoice.delete')"
|
||||||
|
)
|
||||||
|
expires_at: Optional[datetime] = Field(
|
||||||
|
None, description="Fecha de expiración del permiso (opcional)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevokePermissionRequest(BaseModel):
|
||||||
|
"""Esquema de request para revocar un permiso directo."""
|
||||||
|
|
||||||
|
user_id: str = Field(..., description="ID del usuario")
|
||||||
|
permission_code: str = Field(..., description="Código del permiso a revocar")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRoleRequest(BaseModel):
|
||||||
|
"""Esquema de request para crear un rol personalizado."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=100, description="Nombre del rol")
|
||||||
|
code: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
description="Código único del rol (ej: 'custom_admin')",
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
None, max_length=255, description="Descripción del rol"
|
||||||
|
)
|
||||||
|
permission_ids: List[int] = Field(
|
||||||
|
default_factory=list, description="IDs de permisos a asignar al rol"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateRoleRequest(BaseModel):
|
||||||
|
"""Esquema de request para actualizar un rol existente."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=255)
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AssignPermissionsToRoleRequest(BaseModel):
|
||||||
|
"""Esquema de request para asignar permisos a un rol."""
|
||||||
|
|
||||||
|
permission_ids: List[int] = Field(
|
||||||
|
..., description="Lista de IDs de permisos a asignar al rol"
|
||||||
|
)
|
||||||
|
replace_existing: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Si True, reemplaza los permisos existentes. Si False, los agrega.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePermissionRequest(BaseModel):
|
||||||
|
"""Esquema de request para crear un nuevo permiso (uso administrativo)."""
|
||||||
|
|
||||||
|
code: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
max_length=100,
|
||||||
|
description="Código único del permiso (ej: 'custom_module.action')",
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(None, max_length=255)
|
||||||
|
module: str = Field(
|
||||||
|
..., min_length=1, max_length=50, description="Módulo del permiso"
|
||||||
|
)
|
||||||
|
action: str = Field(
|
||||||
|
..., min_length=1, max_length=50, description="Acción del permiso"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SCHEMAS DE RESPUESTA GENÉRICOS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse(BaseModel):
|
||||||
|
"""Respuesta genérica de éxito."""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
message: str = Field(..., description="Mensaje descriptivo de la operación")
|
||||||
|
data: Optional[dict] = Field(None, description="Datos adicionales opcionales")
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
"""Respuesta genérica de error."""
|
||||||
|
|
||||||
|
success: bool = False
|
||||||
|
detail: str = Field(..., description="Descripción del error")
|
||||||
|
error_code: Optional[str] = Field(None, description="Código de error específico")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SCHEMAS DE PAGINACIÓN
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel):
|
||||||
|
"""Esquema genérico para respuestas paginadas."""
|
||||||
|
|
||||||
|
items: List[dict] = Field(default_factory=list)
|
||||||
|
total: int = Field(..., description="Total de items disponibles")
|
||||||
|
page: int = Field(..., description="Página actual")
|
||||||
|
page_size: int = Field(..., description="Tamaño de página")
|
||||||
|
total_pages: int = Field(..., description="Total de páginas disponibles")
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionListResponse(BaseModel):
|
||||||
|
"""Lista paginada de permisos."""
|
||||||
|
|
||||||
|
items: List[PermissionResponse]
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class RoleListResponse(BaseModel):
|
||||||
|
"""Lista paginada de roles."""
|
||||||
|
|
||||||
|
items: List[CompanyRoleResponse]
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleListResponse(BaseModel):
|
||||||
|
"""Lista paginada de asignaciones de roles a usuarios."""
|
||||||
|
|
||||||
|
items: List[UserCompanyRoleResponse]
|
||||||
|
total: int
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
class AssignUserRoleRequest(BaseModel):
|
||||||
|
"""Esquema de request para asignar un rol a un usuario."""
|
||||||
|
|
||||||
|
user_id: str = Field(..., description="ID del usuario")
|
||||||
|
company_role_id: int = Field(..., description="ID del rol a asignar")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SCHEMAS PARA PERMISOS INDIVIDUALES DE USUARIO
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermissionResponse(BaseModel):
|
||||||
|
"""Esquema de respuesta para un permiso individual de usuario."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: str
|
||||||
|
permission_id: int
|
||||||
|
company_id: int
|
||||||
|
tenant_id: int
|
||||||
|
is_granted: bool = Field(..., description="True = permiso concedido, False = permiso revocado")
|
||||||
|
is_active: bool
|
||||||
|
assigned_by: Optional[str] = None
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
permission: Optional[PermissionResponse] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssignUserPermissionRequest(BaseModel):
|
||||||
|
"""Esquema de request para asignar un permiso individual a un usuario."""
|
||||||
|
|
||||||
|
permission_id: int = Field(..., description="ID del permiso")
|
||||||
|
is_granted: bool = Field(True, description="True para conceder, False para revocar")
|
||||||
|
expires_at: Optional[datetime] = Field(None, description="Fecha de expiración (opcional)")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPermissionsListResponse(BaseModel):
|
||||||
|
"""Lista de permisos individuales de un usuario."""
|
||||||
|
|
||||||
|
items: List[UserPermissionResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class EffectiveUserPermissionsResponse(BaseModel):
|
||||||
|
"""Permisos efectivos de un usuario (roles + individuales - revocados)."""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
company_id: int
|
||||||
|
role_permissions: List[PermissionResponse] = Field(
|
||||||
|
default_factory=list, description="Permisos heredados de roles"
|
||||||
|
)
|
||||||
|
granted_permissions: List[PermissionResponse] = Field(
|
||||||
|
default_factory=list, description="Permisos individuales concedidos"
|
||||||
|
)
|
||||||
|
revoked_permissions: List[PermissionResponse] = Field(
|
||||||
|
default_factory=list, description="Permisos revocados explícitamente"
|
||||||
|
)
|
||||||
|
effective_permissions: List[PermissionResponse] = Field(
|
||||||
|
default_factory=list, description="Permisos finales efectivos"
|
||||||
|
)
|
||||||
585
backend/api/v1/modules/core/permissions/service.py
Normal file
585
backend/api/v1/modules/core/permissions/service.py
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
"""
|
||||||
|
Servicio de gestión de permisos multi-tenant.
|
||||||
|
Proporciona funciones para verificar y obtener permisos de usuarios por companye.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Set, Optional, List
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from core.database import RLS_TENANT_KEY
|
||||||
|
from .cache import PermissionCache
|
||||||
|
from .models import (
|
||||||
|
Permission,
|
||||||
|
CompanyRole,
|
||||||
|
RolePermission,
|
||||||
|
UserCompanyRole,
|
||||||
|
UserCompanyPermission,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionService:
|
||||||
|
"""
|
||||||
|
Servicio para gestionar permisos de usuarios en contextos multi-tenant.
|
||||||
|
Combina permisos de roles y permisos directos del usuario.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self._cache = PermissionCache()
|
||||||
|
|
||||||
|
def _ensure_user_tenant_row_for_company(
|
||||||
|
self, user_id: str, company_id: int
|
||||||
|
) -> None:
|
||||||
|
"""STUB — implementa con el modelo de compañía de tu proyecto."""
|
||||||
|
|
||||||
|
def _resolve_tenant_id_for_company(self, company_id: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Resuelve tenant_id efectivo para una compañía usando primero el contexto RLS
|
||||||
|
de la sesión y, como fallback, la tabla de compañías.
|
||||||
|
"""
|
||||||
|
tenant_id = self.db.info.get(RLS_TENANT_KEY)
|
||||||
|
if tenant_id is not None:
|
||||||
|
try:
|
||||||
|
return int(tenant_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Sin modelo de compañía en la plantilla — implementa la consulta aquí.
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"resolve_tenant_id_for_company_failed",
|
||||||
|
extra={
|
||||||
|
"company_id": company_id,
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_user_permissions_uncached(
|
||||||
|
self, user_id: str, company_id: int
|
||||||
|
) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Lógica de cálculo de permisos sin caché.
|
||||||
|
"""
|
||||||
|
role_permissions = self._get_permissions_from_roles(user_id, company_id)
|
||||||
|
direct_permissions = self._get_direct_permissions(user_id, company_id)
|
||||||
|
|
||||||
|
all_permissions = role_permissions.copy()
|
||||||
|
|
||||||
|
for perm_code, is_granted in direct_permissions.items():
|
||||||
|
if is_granted:
|
||||||
|
all_permissions.add(perm_code)
|
||||||
|
else:
|
||||||
|
all_permissions.discard(perm_code)
|
||||||
|
|
||||||
|
return all_permissions
|
||||||
|
|
||||||
|
def get_user_permissions(
|
||||||
|
self, user_id: str, company_id: int, use_cache: bool = False
|
||||||
|
) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Obtiene todos los permisos de un usuario para un companye específico.
|
||||||
|
Puede usar caché de Valkey cuando use_cache=True.
|
||||||
|
"""
|
||||||
|
if not use_cache:
|
||||||
|
return self._get_user_permissions_uncached(user_id, company_id)
|
||||||
|
|
||||||
|
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||||
|
cache_key = self._cache.build_permissions_key(tenant_id, company_id, user_id)
|
||||||
|
|
||||||
|
cached = self._cache.get_permissions(
|
||||||
|
cache_key,
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
permissions = self._get_user_permissions_uncached(user_id, company_id)
|
||||||
|
self._cache.set_permissions(
|
||||||
|
cache_key,
|
||||||
|
permissions,
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
def _get_permissions_from_roles(self, user_id: str, company_id: int) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Obtiene permisos derivados de los roles del usuario en el companye.
|
||||||
|
|
||||||
|
Realiza un JOIN eficiente para obtener todos los permisos de los roles activos.
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
self.db.query(Permission.code)
|
||||||
|
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||||
|
.join(CompanyRole, CompanyRole.id == RolePermission.company_role_id)
|
||||||
|
.join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyRole.user_id == user_id,
|
||||||
|
UserCompanyRole.company_id == company_id,
|
||||||
|
UserCompanyRole.is_active == True,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
Permission.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = query.all()
|
||||||
|
return {perm_code for (perm_code,) in results}
|
||||||
|
|
||||||
|
def _get_direct_permissions(self, user_id: str, company_id: int) -> dict:
|
||||||
|
"""
|
||||||
|
Obtiene permisos directos asignados al usuario.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con código de permiso como key y is_granted como value
|
||||||
|
{
|
||||||
|
"invoice.delete": True, # Permiso concedido
|
||||||
|
"user.delete": False # Permiso revocado explícitamente
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
query = (
|
||||||
|
self.db.query(Permission.code, UserCompanyPermission.is_granted)
|
||||||
|
.join(
|
||||||
|
UserCompanyPermission,
|
||||||
|
UserCompanyPermission.permission_id == Permission.id,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyPermission.user_id == user_id,
|
||||||
|
UserCompanyPermission.company_id == company_id,
|
||||||
|
UserCompanyPermission.is_active == True,
|
||||||
|
Permission.is_active == True,
|
||||||
|
or_(
|
||||||
|
UserCompanyPermission.expires_at.is_(None),
|
||||||
|
UserCompanyPermission.expires_at > now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = query.all()
|
||||||
|
return {perm_code: is_granted for perm_code, is_granted in results}
|
||||||
|
|
||||||
|
def has_permission(
|
||||||
|
self, user_id: str, company_id: int, permission_code: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verifica si un usuario tiene un permiso específico en un companye.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID del usuario
|
||||||
|
company_id: ID del companye/tenant
|
||||||
|
permission_code: Código del permiso (ej: "invoice.edit")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si el usuario tiene el permiso, False en caso contrario
|
||||||
|
"""
|
||||||
|
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
|
||||||
|
return permission_code in permissions
|
||||||
|
|
||||||
|
def has_any_permission(
|
||||||
|
self, user_id: str, company_id: int, permission_codes: List[str]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verifica si el usuario tiene al menos uno de los permisos especificados.
|
||||||
|
"""
|
||||||
|
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
|
||||||
|
return any(perm in permissions for perm in permission_codes)
|
||||||
|
|
||||||
|
def has_all_permissions(
|
||||||
|
self, user_id: str, company_id: int, permission_codes: List[str]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Verifica si el usuario tiene todos los permisos especificados.
|
||||||
|
"""
|
||||||
|
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
|
||||||
|
return all(perm in permissions for perm in permission_codes)
|
||||||
|
|
||||||
|
def get_user_roles(self, user_id: str, company_id: int) -> List[CompanyRole]:
|
||||||
|
"""
|
||||||
|
Obtiene los roles activos de un usuario en un companye.
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
self.db.query(CompanyRole)
|
||||||
|
.join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyRole.user_id == user_id,
|
||||||
|
UserCompanyRole.company_id == company_id,
|
||||||
|
UserCompanyRole.is_active == True,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
def assign_role_to_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
company_id: int,
|
||||||
|
role_id: int,
|
||||||
|
assigned_by: Optional[str] = None,
|
||||||
|
) -> UserCompanyRole:
|
||||||
|
"""
|
||||||
|
Asigna un rol a un usuario en un companye específico.
|
||||||
|
"""
|
||||||
|
# Verificar que el rol pertenece al companye
|
||||||
|
role = (
|
||||||
|
self.db.query(CompanyRole)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
CompanyRole.id == role_id,
|
||||||
|
CompanyRole.company_id == company_id,
|
||||||
|
CompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not role:
|
||||||
|
raise ValueError(f"Role {role_id} not found for company {company_id}")
|
||||||
|
|
||||||
|
# Verificar si ya existe la asignación
|
||||||
|
existing = (
|
||||||
|
self.db.query(UserCompanyRole)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyRole.user_id == user_id,
|
||||||
|
UserCompanyRole.company_id == company_id,
|
||||||
|
UserCompanyRole.company_role_id == role_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if not existing.is_active:
|
||||||
|
existing.is_active = True
|
||||||
|
existing.assigned_at = datetime.utcnow()
|
||||||
|
existing.assigned_by = assigned_by
|
||||||
|
self.db.commit()
|
||||||
|
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||||
|
# Invalidar caché del usuario tras reactivar el rol
|
||||||
|
try:
|
||||||
|
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||||
|
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_user_after_assign_role_failed",
|
||||||
|
extra={"user_id": user_id, "company_id": company_id},
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Crear nueva asignación
|
||||||
|
user_role = UserCompanyRole(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
company_role_id=role_id,
|
||||||
|
assigned_by=assigned_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(user_role)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_role)
|
||||||
|
|
||||||
|
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||||
|
|
||||||
|
# Invalidar caché del usuario tras nueva asignación de rol
|
||||||
|
try:
|
||||||
|
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||||
|
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_user_after_assign_role_failed",
|
||||||
|
extra={"user_id": user_id, "company_id": company_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_role
|
||||||
|
|
||||||
|
def grant_direct_permission(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
company_id: int,
|
||||||
|
permission_code: str,
|
||||||
|
assigned_by: Optional[str] = None,
|
||||||
|
expires_at: Optional[datetime] = None,
|
||||||
|
) -> UserCompanyPermission:
|
||||||
|
"""
|
||||||
|
Concede un permiso directo a un usuario en un companye.
|
||||||
|
"""
|
||||||
|
# Obtener el permiso por código
|
||||||
|
permission = (
|
||||||
|
self.db.query(Permission)
|
||||||
|
.filter(
|
||||||
|
and_(Permission.code == permission_code, Permission.is_active == True)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not permission:
|
||||||
|
raise ValueError(f"Permission {permission_code} not found")
|
||||||
|
|
||||||
|
# Verificar si ya existe
|
||||||
|
existing = (
|
||||||
|
self.db.query(UserCompanyPermission)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyPermission.user_id == user_id,
|
||||||
|
UserCompanyPermission.company_id == company_id,
|
||||||
|
UserCompanyPermission.permission_id == permission.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.is_granted = True
|
||||||
|
existing.is_active = True
|
||||||
|
existing.assigned_at = datetime.utcnow()
|
||||||
|
existing.assigned_by = assigned_by
|
||||||
|
existing.expires_at = expires_at
|
||||||
|
self.db.commit()
|
||||||
|
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||||
|
# Invalidar caché del usuario tras mutación
|
||||||
|
try:
|
||||||
|
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||||
|
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||||
|
except Exception:
|
||||||
|
# La invalidación de caché nunca debe romper la operación principal
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_user_after_grant_failed",
|
||||||
|
extra={"user_id": user_id, "company_id": company_id},
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Crear nuevo permiso directo
|
||||||
|
user_permission = UserCompanyPermission(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
permission_id=permission.id,
|
||||||
|
is_granted=True,
|
||||||
|
assigned_by=assigned_by,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(user_permission)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_permission)
|
||||||
|
|
||||||
|
self._ensure_user_tenant_row_for_company(user_id, company_id)
|
||||||
|
|
||||||
|
# Invalidar caché del usuario tras mutación
|
||||||
|
try:
|
||||||
|
tenant_id = self._resolve_tenant_id_for_company(company_id)
|
||||||
|
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_user_after_grant_failed",
|
||||||
|
extra={"user_id": user_id, "company_id": company_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_permission
|
||||||
|
|
||||||
|
def sync_permissions(self) -> dict:
|
||||||
|
"""
|
||||||
|
Sincroniza los permisos registrados en el registry con la base de datos.
|
||||||
|
Inserta nuevos permisos y actualiza los existentes.
|
||||||
|
"""
|
||||||
|
from .registry import registry
|
||||||
|
# IMPORTANTE: Importar seed_v2 para que se ejecute register_core_permissions()
|
||||||
|
from . import seed_v2
|
||||||
|
|
||||||
|
registered_permissions = registry.get_all()
|
||||||
|
synced_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for p_def in registered_permissions:
|
||||||
|
# Buscar permiso existente
|
||||||
|
db_permission = self.db.query(Permission).filter(Permission.code == p_def.code).first()
|
||||||
|
|
||||||
|
if db_permission:
|
||||||
|
# Actualizar si hay cambios
|
||||||
|
changed = False
|
||||||
|
if db_permission.description != p_def.description:
|
||||||
|
db_permission.description = p_def.description
|
||||||
|
changed = True
|
||||||
|
if db_permission.module != p_def.module:
|
||||||
|
db_permission.module = p_def.module
|
||||||
|
changed = True
|
||||||
|
if db_permission.action != p_def.action:
|
||||||
|
db_permission.action = p_def.action
|
||||||
|
changed = True
|
||||||
|
if db_permission.is_active != p_def.is_active:
|
||||||
|
db_permission.is_active = p_def.is_active
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
# Crear nuevo
|
||||||
|
new_permission = Permission(
|
||||||
|
code=p_def.code,
|
||||||
|
description=p_def.description,
|
||||||
|
module=p_def.module,
|
||||||
|
action=p_def.action,
|
||||||
|
is_active=p_def.is_active
|
||||||
|
)
|
||||||
|
self.db.add(new_permission)
|
||||||
|
synced_count += 1
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"synced": synced_count,
|
||||||
|
"updated": updated_count,
|
||||||
|
"total_registered": len(registered_permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
def bootstrap_super_admin(self, user_id: str, company_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Crea un rol de Super Administrador con todos los permisos y se lo asigna al usuario.
|
||||||
|
Diseñado para el primer inicio de una compañía o para asegurar acceso a administradores.
|
||||||
|
"""
|
||||||
|
from .models import CompanyRole, RolePermission, UserCompanyRole, Permission
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 0. Sincronizar permisos por si la tabla esta vacía
|
||||||
|
# Esto puebla la tabla 'permissions' desde el registry de código
|
||||||
|
logger.info("Bootstrap: Sincronizando catálogo de permisos desde el registry...")
|
||||||
|
from . import seed_v2
|
||||||
|
sync_res = self.sync_permissions()
|
||||||
|
logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.")
|
||||||
|
|
||||||
|
# 1. Obtener el tenant_id (implementa con tu modelo de compañía)
|
||||||
|
tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1
|
||||||
|
|
||||||
|
# 2. Buscar si ya existe el rol "super_admin"
|
||||||
|
admin_role = self.db.query(CompanyRole).filter(
|
||||||
|
CompanyRole.company_id == company_id,
|
||||||
|
CompanyRole.code == "super_admin"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not admin_role:
|
||||||
|
logger.info(f"Bootstrap: Creando Super Administrador para usuario {user_id} (Company: {company_id})")
|
||||||
|
# Crear el rol Super Administrador si no existe
|
||||||
|
admin_role = CompanyRole(
|
||||||
|
company_id=company_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name="Super Administrador",
|
||||||
|
code="super_admin",
|
||||||
|
description="Rol con acceso total al sistema (generado automáticamente)",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
self.db.add(admin_role)
|
||||||
|
self.db.flush()
|
||||||
|
|
||||||
|
# 4. Asignar TODOS los permisos activos al rol
|
||||||
|
all_perms = self.db.query(Permission).filter(Permission.is_active == True).all()
|
||||||
|
if not all_perms:
|
||||||
|
logger.warning("Bootstrap: ¡ALERTA! No se encontraron permisos en la DB ni tras la sincronización.")
|
||||||
|
|
||||||
|
for perm in all_perms:
|
||||||
|
role_perm = RolePermission(
|
||||||
|
company_role_id=admin_role.id,
|
||||||
|
permission_id=perm.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=company_id
|
||||||
|
)
|
||||||
|
self.db.add(role_perm)
|
||||||
|
else:
|
||||||
|
logger.info(f"Bootstrap: El rol super_admin ya existe para la compañía {company_id}. Sincronizando permisos nuevos si es necesario.")
|
||||||
|
# Asegurar que el rol tenga TODOS los permisos activos (incluidos los agregados después)
|
||||||
|
all_perms = self.db.query(Permission).filter(Permission.is_active == True).all()
|
||||||
|
existing_perm_ids = {
|
||||||
|
pid
|
||||||
|
for (pid,) in self.db.query(RolePermission.permission_id).filter(
|
||||||
|
RolePermission.company_role_id == admin_role.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
added = 0
|
||||||
|
for perm in all_perms:
|
||||||
|
if perm.id in existing_perm_ids:
|
||||||
|
continue
|
||||||
|
role_perm = RolePermission(
|
||||||
|
company_role_id=admin_role.id,
|
||||||
|
permission_id=perm.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=company_id,
|
||||||
|
)
|
||||||
|
self.db.add(role_perm)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
logger.info(
|
||||||
|
"Bootstrap: sincronicé %s permisos nuevos en super_admin company_id=%s",
|
||||||
|
added,
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Asegurar que el usuario tenga el rol asignado
|
||||||
|
user_has_role = self.db.query(UserCompanyRole).filter(
|
||||||
|
UserCompanyRole.user_id == user_id,
|
||||||
|
UserCompanyRole.company_id == company_id,
|
||||||
|
UserCompanyRole.company_role_id == admin_role.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user_has_role:
|
||||||
|
logger.info(f"Bootstrap: Asignando rol Super Administrador al usuario {user_id}")
|
||||||
|
user_role = UserCompanyRole(
|
||||||
|
user_id=user_id,
|
||||||
|
company_id=company_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_role_id=admin_role.id,
|
||||||
|
is_active=True,
|
||||||
|
assigned_by="SYSTEM_BOOTSTRAP"
|
||||||
|
)
|
||||||
|
self.db.add(user_role)
|
||||||
|
self.db.commit()
|
||||||
|
# Invalida caché de permisos de la compañía y del usuario afectado
|
||||||
|
try:
|
||||||
|
self._cache.invalidate_company(company_id)
|
||||||
|
self._cache.invalidate_user(tenant_id, company_id, user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_after_bootstrap_failed",
|
||||||
|
extra={"user_id": user_id, "company_id": company_id},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.info(f"Bootstrap: El usuario {user_id} ya tiene el rol asignado.")
|
||||||
|
self.db.commit()
|
||||||
|
# Invalida caché de permisos de la compañía para reflejar cambios de catálogo
|
||||||
|
try:
|
||||||
|
self._cache.invalidate_company(company_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"permission_cache_invalidate_after_bootstrap_failed",
|
||||||
|
extra={"user_id": user_id, "company_id": company_id},
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Bootstrap: ERROR CRÍTICO - {str(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False
|
||||||
50
backend/api/v1/modules/core/permissions/sync_cli.py
Normal file
50
backend/api/v1/modules/core/permissions/sync_cli.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Script CLI para sincronizar los permisos de la aplicación.
|
||||||
|
Útil para el bootstrap inicial cuando el endpoint /sync aún no es accesible
|
||||||
|
o cuando se desea forzar una actualización desde la consola/Docker.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
docker exec -it <container> python3 -m api.v1.modules.core.permissions.sync_cli
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configurar logging básico para ver resultados en consola
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Asegurar que el backend esté en el path
|
||||||
|
sys.path.append(os.path.abspath("."))
|
||||||
|
sys.path.append(os.path.abspath("backend"))
|
||||||
|
|
||||||
|
from core.database import CoreSessionLocal
|
||||||
|
from api.v1.modules.core.permissions.service import PermissionService
|
||||||
|
|
||||||
|
def run_sync():
|
||||||
|
"""Ejecuta la lógica de sincronización modular."""
|
||||||
|
logger.info("Iniciando Sincronización Modular de Permisos...")
|
||||||
|
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
try:
|
||||||
|
service = PermissionService(db)
|
||||||
|
result = service.sync_permissions()
|
||||||
|
|
||||||
|
logger.info("-" * 40)
|
||||||
|
logger.info(f"Sincronización Exitosa!")
|
||||||
|
logger.info(f" - Nuevos insertados: {result['synced']}")
|
||||||
|
logger.info(f" - Existentes actualizados: {result['updated']}")
|
||||||
|
logger.info(f" - Total en Registry: {result['total_registered']}")
|
||||||
|
logger.info("-" * 40)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error crítico durante la sincronización: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_sync()
|
||||||
26
backend/api/v1/modules/core/router.py
Normal file
26
backend/api/v1/modules/core/router.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from .auth.routes import router as auth_router
|
||||||
|
from .invite_codes.routes import router as invite_codes_router
|
||||||
|
from .invites.routes import router as invites_router
|
||||||
|
from .licenses.routes import router as licenses_router
|
||||||
|
from .permissions.routes import router as permissions_router
|
||||||
|
from .tenants.routes import router as tenants_router
|
||||||
|
from .user_tenant.routes import router as user_tenant_router
|
||||||
|
from .users.routes import router as users_router
|
||||||
|
from .dashboard.routes import router as dashboard_router
|
||||||
|
from .help_center.routes import router as help_center_router
|
||||||
|
from .tasks_tracking.routes import router as tasks_tracking_router
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(auth_router)
|
||||||
|
router.include_router(invites_router, prefix="/core", tags=["core / invites"])
|
||||||
|
router.include_router(invite_codes_router, prefix="/core", tags=["core / invite-codes"])
|
||||||
|
router.include_router(tenants_router, prefix="/core", tags=["core / tenants"])
|
||||||
|
router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"])
|
||||||
|
router.include_router(users_router, prefix="/core", tags=["core / users"])
|
||||||
|
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||||
|
router.include_router(permissions_router, prefix="/core", tags=["core / permissions"])
|
||||||
|
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
||||||
|
router.include_router(help_center_router, prefix="/core", tags=["core / help-center"])
|
||||||
|
router.include_router(tasks_tracking_router, prefix="/core", tags=["core / tasks"])
|
||||||
4
backend/api/v1/modules/core/tasks_tracking/__init__.py
Normal file
4
backend/api/v1/modules/core/tasks_tracking/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .dispatch import track_and_dispatch
|
||||||
|
from .service import TaskTrackerService
|
||||||
|
|
||||||
|
__all__ = ["track_and_dispatch", "TaskTrackerService"]
|
||||||
75
backend/api/v1/modules/core/tasks_tracking/dispatch.py
Normal file
75
backend/api/v1/modules/core/tasks_tracking/dispatch.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from typing import Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import Task
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.database import rls_company_var, rls_tenant_var
|
||||||
|
|
||||||
|
from .service import TaskTrackerService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def track_and_dispatch(
|
||||||
|
*,
|
||||||
|
db: Session,
|
||||||
|
task: Task,
|
||||||
|
tenant_id: int,
|
||||||
|
task_name: str,
|
||||||
|
task_group: str,
|
||||||
|
company_id: int | None = None,
|
||||||
|
requested_by_user: str | None = None,
|
||||||
|
task_origin: str | None = None,
|
||||||
|
args: list[Any] | None = None,
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
meta_payload: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
# Propaga contexto RLS vía Celery headers (leídos en task_prerun) y
|
||||||
|
# ContextVars (para modo eager, donde before_task_publish no dispara).
|
||||||
|
headers = {"rls_tenant_id": str(int(tenant_id))}
|
||||||
|
if company_id is not None:
|
||||||
|
headers["rls_company_id"] = str(int(company_id))
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Dispatching task without company_id in RLS headers task=%s tenant_id=%s",
|
||||||
|
getattr(task, "name", "<unknown>"),
|
||||||
|
tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
prev_tenant = rls_tenant_var.get()
|
||||||
|
prev_company = rls_company_var.get()
|
||||||
|
rls_tenant_var.set(int(tenant_id))
|
||||||
|
rls_company_var.set(int(company_id) if company_id is not None else None)
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Dispatching Celery task task=%s task_id=%s tenant_id=%s company_id=%s headers=%s",
|
||||||
|
getattr(task, "name", "<unknown>"),
|
||||||
|
task_id,
|
||||||
|
tenant_id,
|
||||||
|
company_id,
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
celery_task = task.apply_async(
|
||||||
|
args=args or [],
|
||||||
|
kwargs=kwargs or {},
|
||||||
|
task_id=task_id,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
rls_tenant_var.set(prev_tenant)
|
||||||
|
rls_company_var.set(prev_company)
|
||||||
|
|
||||||
|
tracker = TaskTrackerService(db)
|
||||||
|
tracker.register_dispatch(
|
||||||
|
task_id=celery_task.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=company_id,
|
||||||
|
requested_by_user=requested_by_user,
|
||||||
|
task_name=task_name,
|
||||||
|
task_group=task_group,
|
||||||
|
task_origin=task_origin,
|
||||||
|
meta_payload=meta_payload,
|
||||||
|
)
|
||||||
|
return celery_task
|
||||||
62
backend/api/v1/modules/core/tasks_tracking/models.py
Normal file
62
backend/api/v1/modules/core/tasks_tracking/models.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ACTIVE = "active"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRun(Base):
|
||||||
|
__tablename__ = "task_runs"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_core_task_runs_task_id", "task_id", unique=True),
|
||||||
|
Index("ix_core_task_runs_tenant_updated", "tenant_id", "updated_at"),
|
||||||
|
Index("ix_core_task_runs_tenant_status_updated", "tenant_id", "status", "updated_at"),
|
||||||
|
Index("ix_core_task_runs_tenant_group_updated", "tenant_id", "task_group", "updated_at"),
|
||||||
|
Index("ix_core_task_runs_tenant_company_updated", "tenant_id", "company_id", "updated_at"),
|
||||||
|
{"schema": "core"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
task_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("core.tenants.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
company_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, nullable=True, index=True
|
||||||
|
)
|
||||||
|
requested_by_user: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
task_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
task_group: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
task_origin: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[TaskStatus] = mapped_column(
|
||||||
|
String(20), nullable=False, default=TaskStatus.PENDING.value
|
||||||
|
)
|
||||||
|
celery_state_raw: Mapped[str] = mapped_column(String(30), nullable=False, default="PENDING")
|
||||||
|
progress_current: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
progress_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
progress_percent: Mapped[float | None] = mapped_column(nullable=True)
|
||||||
|
progress_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
retries: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
exception_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
exception_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
traceback_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
result_summary: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
meta_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
149
backend/api/v1/modules/core/tasks_tracking/routes.py
Normal file
149
backend/api/v1/modules/core/tasks_tracking/routes.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, resolve_tenant_id_required
|
||||||
|
|
||||||
|
from .models import TaskRun, TaskStatus
|
||||||
|
from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest
|
||||||
|
from .service import TaskTrackerService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _map_row(row: TaskRun) -> TaskRunListItem:
|
||||||
|
pct = row.progress_percent
|
||||||
|
if row.status == TaskStatus.COMPLETED.value and (pct is None or pct == 0):
|
||||||
|
pct = 100.0
|
||||||
|
return TaskRunListItem(
|
||||||
|
task_id=row.task_id,
|
||||||
|
task_name=row.task_name,
|
||||||
|
task_group=row.task_group,
|
||||||
|
task_origin=row.task_origin,
|
||||||
|
status=row.status,
|
||||||
|
celery_state_raw=row.celery_state_raw,
|
||||||
|
progress={
|
||||||
|
"current": row.progress_current,
|
||||||
|
"total": row.progress_total,
|
||||||
|
"percent": pct,
|
||||||
|
"message": row.progress_message,
|
||||||
|
},
|
||||||
|
retries=row.retries,
|
||||||
|
error=(
|
||||||
|
{"type": row.exception_type, "message": row.exception_message}
|
||||||
|
if row.exception_type or row.exception_message
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
requested_by_user=row.requested_by_user,
|
||||||
|
started_at=row.started_at,
|
||||||
|
finished_at=row.finished_at,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks", response_model=TaskRunsResponse)
|
||||||
|
def list_tasks(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
status: list[str] | None = Query(None),
|
||||||
|
task_group: list[str] | None = Query(None),
|
||||||
|
task_name: list[str] | None = Query(None),
|
||||||
|
company_id: int | None = Query(None),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
sync_active: bool = Query(False),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = resolve_tenant_id_required(current_user)
|
||||||
|
|
||||||
|
tracker = TaskTrackerService(db)
|
||||||
|
if sync_active:
|
||||||
|
tracker.sync_active_tasks(tenant_id=tenant_id)
|
||||||
|
|
||||||
|
rows, total = tracker.list_tasks(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
status=status,
|
||||||
|
task_group=task_group,
|
||||||
|
task_name=task_name,
|
||||||
|
company_id=company_id,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
items = [_map_row(row) for row in rows]
|
||||||
|
return TaskRunsResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
has_next=(page * page_size) < total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}", response_model=TaskRunDetail)
|
||||||
|
def get_task_detail(
|
||||||
|
task_id: str,
|
||||||
|
sync: bool = Query(True),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = resolve_tenant_id_required(current_user)
|
||||||
|
|
||||||
|
query = db.query(TaskRun).filter(TaskRun.task_id == task_id)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(TaskRun.tenant_id == tenant_id)
|
||||||
|
row = query.first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
tracker = TaskTrackerService(db)
|
||||||
|
if sync:
|
||||||
|
row = tracker.sync_task(row)
|
||||||
|
|
||||||
|
item = _map_row(row)
|
||||||
|
return TaskRunDetail(
|
||||||
|
**item.model_dump(),
|
||||||
|
traceback_excerpt=row.traceback_excerpt,
|
||||||
|
result_summary=row.result_summary,
|
||||||
|
meta_payload=row.meta_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/sync")
|
||||||
|
def sync_tasks(
|
||||||
|
body: TaskSyncRequest,
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = resolve_tenant_id_required(current_user)
|
||||||
|
tracker = TaskTrackerService(db)
|
||||||
|
updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids)
|
||||||
|
return {"updated": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/catalogs", response_model=TaskCatalogsResponse)
|
||||||
|
def get_catalogs(
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = resolve_tenant_id_required(current_user)
|
||||||
|
|
||||||
|
groups_q = db.query(TaskRun.task_group)
|
||||||
|
names_q = db.query(TaskRun.task_name)
|
||||||
|
statuses_q = db.query(TaskRun.status)
|
||||||
|
if tenant_id is not None:
|
||||||
|
groups_q = groups_q.filter(TaskRun.tenant_id == tenant_id)
|
||||||
|
names_q = names_q.filter(TaskRun.tenant_id == tenant_id)
|
||||||
|
statuses_q = statuses_q.filter(TaskRun.tenant_id == tenant_id)
|
||||||
|
groups = groups_q.distinct().order_by(TaskRun.task_group).all()
|
||||||
|
names = names_q.distinct().order_by(TaskRun.task_name).all()
|
||||||
|
statuses = statuses_q.distinct().order_by(TaskRun.status).all()
|
||||||
|
return TaskCatalogsResponse(
|
||||||
|
task_groups=[g[0] for g in groups if g[0]],
|
||||||
|
task_names=[n[0] for n in names if n[0]],
|
||||||
|
statuses=[s[0] for s in statuses if s[0]],
|
||||||
|
)
|
||||||
58
backend/api/v1/modules/core/tasks_tracking/schemas.py
Normal file
58
backend/api/v1/modules/core/tasks_tracking/schemas.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProgress(BaseModel):
|
||||||
|
current: int | None = None
|
||||||
|
total: int | None = None
|
||||||
|
percent: float | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskError(BaseModel):
|
||||||
|
type: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRunListItem(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
task_name: str
|
||||||
|
task_group: str
|
||||||
|
task_origin: str | None = None
|
||||||
|
status: str
|
||||||
|
celery_state_raw: str
|
||||||
|
progress: TaskProgress
|
||||||
|
retries: int | None = None
|
||||||
|
error: TaskError | None = None
|
||||||
|
tenant_id: int
|
||||||
|
requested_by_user: str | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRunDetail(TaskRunListItem):
|
||||||
|
traceback_excerpt: str | None = None
|
||||||
|
result_summary: dict[str, Any] | None = None
|
||||||
|
meta_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRunsResponse(BaseModel):
|
||||||
|
items: list[TaskRunListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
has_next: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSyncRequest(BaseModel):
|
||||||
|
task_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCatalogsResponse(BaseModel):
|
||||||
|
task_groups: list[str]
|
||||||
|
task_names: list[str]
|
||||||
|
statuses: list[str]
|
||||||
246
backend/api/v1/modules/core/tasks_tracking/service.py
Normal file
246
backend/api/v1/modules/core/tasks_tracking/service.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from sqlalchemy import asc, desc, func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
from .models import TaskRun, TaskStatus
|
||||||
|
|
||||||
|
# No usar valid_rows/total_rows del resultado final: en SUCCESS distorsiona el % (errores de scan).
|
||||||
|
_CELERY_TERMINAL_RAW = frozenset({"SUCCESS", "FAILURE", "REVOKED", "REJECTED"})
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_from_row_count_dict(d: dict[str, Any]) -> tuple[int, int] | None:
|
||||||
|
total = d.get("total_rows")
|
||||||
|
if not isinstance(total, (int, float)) or total <= 0:
|
||||||
|
return None
|
||||||
|
valid = d.get("valid_rows")
|
||||||
|
if isinstance(valid, (int, float)):
|
||||||
|
return int(valid), int(total)
|
||||||
|
processed = d.get("processed_rows")
|
||||||
|
if isinstance(processed, (int, float)):
|
||||||
|
return int(processed), int(total)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_celery_state(state: str | None) -> TaskStatus:
|
||||||
|
raw = (state or "PENDING").upper()
|
||||||
|
if raw in {"STARTED", "PROGRESS", "PROCESSING", "RETRY"}:
|
||||||
|
return TaskStatus.ACTIVE
|
||||||
|
if raw == "SUCCESS":
|
||||||
|
return TaskStatus.COMPLETED
|
||||||
|
if raw in {"FAILURE", "REVOKED", "REJECTED"}:
|
||||||
|
return TaskStatus.FAILED
|
||||||
|
return TaskStatus.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_progress(result: AsyncResult, raw_state_upper: str) -> tuple[int | None, int | None, float | None, str | None]:
|
||||||
|
payload = result.info if isinstance(result.info, dict) else {}
|
||||||
|
current = payload.get("current")
|
||||||
|
total = payload.get("total")
|
||||||
|
message = payload.get("status")
|
||||||
|
|
||||||
|
if current is None and isinstance(result.result, dict):
|
||||||
|
current = result.result.get("current")
|
||||||
|
if total is None and isinstance(result.result, dict):
|
||||||
|
total = result.result.get("total")
|
||||||
|
if message is None and isinstance(result.result, dict):
|
||||||
|
message = result.result.get("status")
|
||||||
|
|
||||||
|
percent = None
|
||||||
|
if isinstance(current, (int, float)) and isinstance(total, (int, float)) and total > 0:
|
||||||
|
percent = min(100.0, max(0.0, (float(current) / float(total)) * 100.0))
|
||||||
|
|
||||||
|
raw = (raw_state_upper or "PENDING").upper()
|
||||||
|
if percent is None and raw not in _CELERY_TERMINAL_RAW:
|
||||||
|
for src in (payload, result.result if isinstance(result.result, dict) else {}):
|
||||||
|
if not isinstance(src, dict):
|
||||||
|
continue
|
||||||
|
pair = _progress_from_row_count_dict(src)
|
||||||
|
if pair is None:
|
||||||
|
continue
|
||||||
|
cur_i, tot_i = pair
|
||||||
|
current, total = cur_i, tot_i
|
||||||
|
percent = min(100.0, max(0.0, (float(cur_i) / float(tot_i)) * 100.0))
|
||||||
|
break
|
||||||
|
|
||||||
|
return (
|
||||||
|
int(current) if isinstance(current, (int, float)) else None,
|
||||||
|
int(total) if isinstance(total, (int, float)) else None,
|
||||||
|
percent,
|
||||||
|
str(message) if message is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_failure(result: AsyncResult) -> tuple[str | None, str | None, str | None]:
|
||||||
|
exception_type = None
|
||||||
|
exception_message = None
|
||||||
|
traceback_excerpt = None
|
||||||
|
|
||||||
|
err = result.result
|
||||||
|
if isinstance(err, Exception):
|
||||||
|
exception_type = type(err).__name__
|
||||||
|
exception_message = str(err)
|
||||||
|
elif isinstance(err, dict):
|
||||||
|
exception_type = err.get("exc_type")
|
||||||
|
exception_message = err.get("exc_message") or err.get("error") or err.get("message")
|
||||||
|
elif err is not None:
|
||||||
|
exception_message = str(err)
|
||||||
|
|
||||||
|
tb = getattr(result, "traceback", None)
|
||||||
|
if isinstance(tb, str):
|
||||||
|
traceback_excerpt = tb[-4000:]
|
||||||
|
|
||||||
|
return exception_type, exception_message, traceback_excerpt
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTrackerService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def register_dispatch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
tenant_id: int,
|
||||||
|
task_name: str,
|
||||||
|
task_group: str,
|
||||||
|
company_id: int | None = None,
|
||||||
|
requested_by_user: str | None = None,
|
||||||
|
task_origin: str | None = None,
|
||||||
|
meta_payload: dict[str, Any] | None = None,
|
||||||
|
) -> TaskRun:
|
||||||
|
current = self.db.query(TaskRun).filter(TaskRun.task_id == task_id).first()
|
||||||
|
if current:
|
||||||
|
return current
|
||||||
|
|
||||||
|
row = TaskRun(
|
||||||
|
task_id=task_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
company_id=company_id,
|
||||||
|
requested_by_user=requested_by_user,
|
||||||
|
task_name=task_name,
|
||||||
|
task_group=task_group,
|
||||||
|
task_origin=task_origin,
|
||||||
|
status=TaskStatus.PENDING.value,
|
||||||
|
celery_state_raw="PENDING",
|
||||||
|
progress_current=0,
|
||||||
|
progress_total=100,
|
||||||
|
progress_percent=0.0,
|
||||||
|
progress_message="Queued",
|
||||||
|
retries=0,
|
||||||
|
meta_payload=meta_payload,
|
||||||
|
started_at=None,
|
||||||
|
finished_at=None,
|
||||||
|
)
|
||||||
|
self.db.add(row)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def sync_task(self, task_run: TaskRun) -> TaskRun:
|
||||||
|
from core.celery_app import celery_app
|
||||||
|
async_result = celery_app.AsyncResult(task_run.task_id)
|
||||||
|
raw_state = (async_result.state or "PENDING").upper()
|
||||||
|
normalized = normalize_celery_state(raw_state)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
task_run.celery_state_raw = raw_state
|
||||||
|
task_run.status = normalized.value
|
||||||
|
task_run.retries = int(getattr(async_result, "retries", 0) or 0)
|
||||||
|
|
||||||
|
current, total, percent, message = _extract_progress(async_result, raw_state)
|
||||||
|
if current is not None:
|
||||||
|
task_run.progress_current = current
|
||||||
|
if total is not None:
|
||||||
|
task_run.progress_total = total
|
||||||
|
if percent is not None:
|
||||||
|
task_run.progress_percent = percent
|
||||||
|
if message:
|
||||||
|
task_run.progress_message = message
|
||||||
|
|
||||||
|
if normalized == TaskStatus.ACTIVE and task_run.started_at is None:
|
||||||
|
task_run.started_at = now
|
||||||
|
|
||||||
|
if normalized == TaskStatus.COMPLETED:
|
||||||
|
if task_run.started_at is None:
|
||||||
|
task_run.started_at = now
|
||||||
|
task_run.finished_at = now
|
||||||
|
task_run.exception_type = None
|
||||||
|
task_run.exception_message = None
|
||||||
|
task_run.traceback_excerpt = None
|
||||||
|
if isinstance(async_result.result, dict):
|
||||||
|
task_run.result_summary = async_result.result
|
||||||
|
else:
|
||||||
|
task_run.result_summary = {"result": str(async_result.result)}
|
||||||
|
# register_dispatch seeds progress_percent=0; Celery SUCCESS often has no current/total meta
|
||||||
|
if percent is None:
|
||||||
|
task_run.progress_percent = 100.0
|
||||||
|
|
||||||
|
if normalized == TaskStatus.FAILED:
|
||||||
|
if task_run.started_at is None:
|
||||||
|
task_run.started_at = now
|
||||||
|
task_run.finished_at = now
|
||||||
|
etype, emsg, tb = _extract_failure(async_result)
|
||||||
|
task_run.exception_type = etype
|
||||||
|
task_run.exception_message = emsg
|
||||||
|
task_run.traceback_excerpt = tb
|
||||||
|
|
||||||
|
self.db.add(task_run)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(task_run)
|
||||||
|
return task_run
|
||||||
|
|
||||||
|
def sync_active_tasks(self, tenant_id: int | None, task_ids: list[str] | None = None) -> int:
|
||||||
|
query = self.db.query(TaskRun).filter(
|
||||||
|
TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value])
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(TaskRun.tenant_id == tenant_id)
|
||||||
|
if task_ids:
|
||||||
|
query = query.filter(TaskRun.task_id.in_(task_ids))
|
||||||
|
rows = query.limit(200).all()
|
||||||
|
for row in rows:
|
||||||
|
self.sync_task(row)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
def list_tasks(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: int | None,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
status: list[str] | None = None,
|
||||||
|
task_group: list[str] | None = None,
|
||||||
|
task_name: list[str] | None = None,
|
||||||
|
company_id: int | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
order: str = "desc",
|
||||||
|
) -> tuple[list[TaskRun], int]:
|
||||||
|
query = self.db.query(TaskRun)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(TaskRun.tenant_id == tenant_id)
|
||||||
|
if status:
|
||||||
|
query = query.filter(TaskRun.status.in_(status))
|
||||||
|
if task_group:
|
||||||
|
query = query.filter(TaskRun.task_group.in_(task_group))
|
||||||
|
if task_name:
|
||||||
|
query = query.filter(TaskRun.task_name.in_(task_name))
|
||||||
|
if company_id is not None:
|
||||||
|
# Tareas sin company_id (p. ej. reportes solo por tenant) deben seguir visibles
|
||||||
|
query = query.filter(or_(TaskRun.company_id == company_id, TaskRun.company_id.is_(None)))
|
||||||
|
if search:
|
||||||
|
term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(TaskRun.task_id.ilike(term))
|
||||||
|
| (TaskRun.task_name.ilike(term))
|
||||||
|
| (TaskRun.task_origin.ilike(term))
|
||||||
|
| (TaskRun.exception_message.ilike(term))
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.with_entities(func.count(TaskRun.id)).scalar() or 0
|
||||||
|
order_expr = asc(TaskRun.updated_at) if order == "asc" else desc(TaskRun.updated_at)
|
||||||
|
items = query.order_by(order_expr).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return items, total
|
||||||
7
backend/api/v1/modules/core/tenants/__init__.py
Normal file
7
backend/api/v1/modules/core/tenants/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Módulo de Tenants
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
119
backend/api/v1/modules/core/tenants/dto.py
Normal file
119
backend/api/v1/modules/core/tenants/dto.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
DTOs (Data Transfer Objects) para módulo de tenants
|
||||||
|
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TenantTypeDTO(str, Enum):
|
||||||
|
"""Tipo de tenant"""
|
||||||
|
|
||||||
|
SHARED = "shared"
|
||||||
|
DEDICATED = "dedicated"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCreateDTO(BaseModel):
|
||||||
|
"""DTO para crear un nuevo tenant"""
|
||||||
|
|
||||||
|
name: str = Field(
|
||||||
|
..., min_length=3, max_length=255, description="Nombre del tenant"
|
||||||
|
)
|
||||||
|
slug: str = Field(
|
||||||
|
..., min_length=3, max_length=100, description="Identificador único del tenant"
|
||||||
|
)
|
||||||
|
keycloak_realm: str = Field(
|
||||||
|
..., min_length=3, max_length=255, description="Nombre del realm en Keycloak"
|
||||||
|
)
|
||||||
|
type: TenantTypeDTO = Field(
|
||||||
|
default=TenantTypeDTO.SHARED, description="Tipo de tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
contact_name: Optional[str] = Field(
|
||||||
|
None, max_length=255, description="Nombre de contacto"
|
||||||
|
)
|
||||||
|
contact_email: Optional[EmailStr] = Field(None, description="Email de contacto")
|
||||||
|
contact_phone: Optional[str] = Field(
|
||||||
|
None, max_length=50, description="Teléfono de contacto"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"name": "Empresa ABC S.A. de C.V.",
|
||||||
|
"slug": "empresa-abc",
|
||||||
|
"keycloak_realm": "empresa-abc-realm",
|
||||||
|
"type": "shared",
|
||||||
|
"contact_name": "Juan Pérez",
|
||||||
|
"contact_email": "juan.perez@empresa-abc.com",
|
||||||
|
"contact_phone": "+52 55 1234 5678",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUpdateDTO(BaseModel):
|
||||||
|
"""DTO para actualizar un tenant"""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, min_length=3, max_length=255)
|
||||||
|
contact_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
contact_email: Optional[EmailStr] = None
|
||||||
|
contact_phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"name": "Empresa ABC S.A. de C.V. - Actualizado",
|
||||||
|
"contact_email": "nuevo@empresa-abc.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantResponseDTO(BaseModel):
|
||||||
|
"""DTO para respuesta de tenant"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
type: TenantTypeDTO
|
||||||
|
keycloak_realm: str
|
||||||
|
contact_name: Optional[str]
|
||||||
|
contact_email: Optional[str]
|
||||||
|
contact_phone: Optional[str]
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Empresa ABC S.A. de C.V.",
|
||||||
|
"slug": "empresa-abc",
|
||||||
|
"type": "shared",
|
||||||
|
"keycloak_realm": "empresa-abc-realm",
|
||||||
|
"contact_name": "Juan Pérez",
|
||||||
|
"contact_email": "juan.perez@empresa-abc.com",
|
||||||
|
"contact_phone": "+52 55 1234 5678",
|
||||||
|
"is_active": True,
|
||||||
|
"created_at": "2025-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2025-01-15T10:30:00Z",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantListResponseDTO(BaseModel):
|
||||||
|
"""DTO para lista de tenants"""
|
||||||
|
|
||||||
|
tenants: list[TenantResponseDTO]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
66
backend/api/v1/modules/core/tenants/models.py
Normal file
66
backend/api/v1/modules/core/tenants/models.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Modelos ORM para gestión de tenants
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
from api.v1.common.base_models import TimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
from sqlalchemy import Boolean, Column
|
||||||
|
from sqlalchemy import Enum as SQLEnum
|
||||||
|
from sqlalchemy import Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, relationship
|
||||||
|
|
||||||
|
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||||
|
|
||||||
|
|
||||||
|
class TenantType(enum.Enum):
|
||||||
|
"""Tipo de tenant según tamaño y necesidades"""
|
||||||
|
|
||||||
|
SHARED = "shared" # BD compartida
|
||||||
|
DEDICATED = "dedicated" # BD dedicada
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(Base, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Modelo de Tenant - Cliente/Organización en el sistema
|
||||||
|
Cada tenant puede tener BD compartida o dedicada
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
__table_args__ = {"schema": "core", "extend_existing": True}
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(255), nullable=False, index=True)
|
||||||
|
slug = Column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Tipo de tenant (compartido o dedicado)
|
||||||
|
type = Column(
|
||||||
|
SQLEnum(TenantType),
|
||||||
|
default=TenantType.SHARED,
|
||||||
|
server_default="SHARED",
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keycloak realm asociado
|
||||||
|
keycloak_realm = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Configuración de BD dedicada (JSON string o NULL si usa BD compartida)
|
||||||
|
db_config = Column(Text, nullable=True) # JSON: {host, port, name, user, password}
|
||||||
|
|
||||||
|
# Información de contacto
|
||||||
|
contact_name = Column(String(255))
|
||||||
|
contact_email = Column(String(255))
|
||||||
|
contact_phone = Column(String(50))
|
||||||
|
|
||||||
|
# Estado
|
||||||
|
is_active = Column(Boolean, default=True, server_default="true", nullable=False)
|
||||||
|
|
||||||
|
# Relación con UserTenant
|
||||||
|
user_relations: Mapped[List["UserTenant"]] = relationship(
|
||||||
|
"UserTenant", back_populates="tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Tenant(id={self.id}, name={self.name}, type={self.type.value})>"
|
||||||
131
backend/api/v1/modules/core/tenants/routes.py
Normal file
131
backend/api/v1/modules/core/tenants/routes.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Endpoints API para gestión de tenants
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user, has_role
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
TenantCreateDTO,
|
||||||
|
TenantListResponseDTO,
|
||||||
|
TenantResponseDTO,
|
||||||
|
TenantUpdateDTO,
|
||||||
|
)
|
||||||
|
from .service import TenantService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tenants")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=TenantResponseDTO, status_code=201)
|
||||||
|
async def create_tenant(
|
||||||
|
tenant_data: TenantCreateDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(has_role("admin")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Crea un nuevo tenant en el sistema
|
||||||
|
|
||||||
|
Requiere rol: admin
|
||||||
|
"""
|
||||||
|
service = TenantService(db)
|
||||||
|
return service.create_tenant(tenant_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=TenantListResponseDTO)
|
||||||
|
async def list_tenants(
|
||||||
|
page: int = Query(1, ge=1, description="Número de página"),
|
||||||
|
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
|
||||||
|
active_only: bool = Query(False, description="Solo tenants activos"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(has_role("admin")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Lista todos los tenants
|
||||||
|
|
||||||
|
Requiere rol: admin
|
||||||
|
"""
|
||||||
|
service = TenantService(db)
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
tenants = service.list_tenants(skip=skip, limit=page_size, active_only=active_only)
|
||||||
|
|
||||||
|
# Contar total
|
||||||
|
from .models import Tenant
|
||||||
|
|
||||||
|
query = db.query(Tenant)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(Tenant.is_active)
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
return TenantListResponseDTO(
|
||||||
|
tenants=tenants, total=total, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tenant_id}", response_model=TenantResponseDTO)
|
||||||
|
async def get_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene información de un tenant por ID
|
||||||
|
"""
|
||||||
|
service = TenantService(db)
|
||||||
|
tenant = service.get_tenant(tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{tenant_id}", response_model=TenantResponseDTO)
|
||||||
|
async def update_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
tenant_data: TenantUpdateDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(has_role("admin")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza un tenant
|
||||||
|
|
||||||
|
Requiere rol: admin
|
||||||
|
"""
|
||||||
|
service = TenantService(db)
|
||||||
|
tenant = service.update_tenant(tenant_id, tenant_data)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tenant_id}", status_code=204)
|
||||||
|
async def delete_tenant(
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(has_role("admin")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Elimina (desactiva) un tenant
|
||||||
|
|
||||||
|
Requiere rol: admin
|
||||||
|
"""
|
||||||
|
service = TenantService(db)
|
||||||
|
if not service.delete_tenant(tenant_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/slug/{slug}", response_model=TenantResponseDTO)
|
||||||
|
async def get_tenant_by_slug(
|
||||||
|
slug: str,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene un tenant por su slug
|
||||||
|
"""
|
||||||
|
service = TenantService(db)
|
||||||
|
tenant = service.get_tenant_by_slug(slug)
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
return tenant
|
||||||
207
backend/api/v1/modules/core/tenants/service.py
Normal file
207
backend/api/v1/modules/core/tenants/service.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""
|
||||||
|
Capa de servicio para lógica de negocio de tenants
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import TenantCreateDTO, TenantResponseDTO, TenantUpdateDTO
|
||||||
|
from .models import Tenant, TenantType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantService:
|
||||||
|
"""Servicio para gestión de tenants"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_tenant(self, tenant_data: TenantCreateDTO) -> TenantResponseDTO:
|
||||||
|
"""
|
||||||
|
Crea un nuevo tenant en el sistema
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_data: Datos del tenant a crear
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TenantResponseDTO con información del tenant creado
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: Si el slug o realm ya existen
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verificar que no exista el slug
|
||||||
|
existing = (
|
||||||
|
self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Tenant with slug '{tenant_data.slug}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear tenant
|
||||||
|
db_tenant = Tenant(
|
||||||
|
name=tenant_data.name,
|
||||||
|
slug=tenant_data.slug,
|
||||||
|
keycloak_realm=tenant_data.keycloak_realm,
|
||||||
|
type=TenantType(tenant_data.type.value),
|
||||||
|
contact_name=tenant_data.contact_name,
|
||||||
|
contact_email=tenant_data.contact_email,
|
||||||
|
contact_phone=tenant_data.contact_phone,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(db_tenant)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(db_tenant)
|
||||||
|
|
||||||
|
return TenantResponseDTO.model_validate(db_tenant)
|
||||||
|
|
||||||
|
except IntegrityError as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"IntegrityError creating tenant: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Tenant with this slug or realm already exists"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Error creating tenant: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error creating tenant")
|
||||||
|
|
||||||
|
def get_tenant(self, tenant_id: int) -> Optional[TenantResponseDTO]:
|
||||||
|
"""
|
||||||
|
Obtiene un tenant por ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TenantResponseDTO o None si no existe
|
||||||
|
"""
|
||||||
|
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||||
|
if not tenant:
|
||||||
|
return None
|
||||||
|
return TenantResponseDTO.model_validate(tenant)
|
||||||
|
|
||||||
|
def get_tenant_by_slug(self, slug: str) -> Optional[TenantResponseDTO]:
|
||||||
|
"""Obtiene un tenant por slug"""
|
||||||
|
tenant = self.db.query(Tenant).filter(Tenant.slug == slug).first()
|
||||||
|
if not tenant:
|
||||||
|
return None
|
||||||
|
return TenantResponseDTO.model_validate(tenant)
|
||||||
|
|
||||||
|
def list_tenants(
|
||||||
|
self, skip: int = 0, limit: int = 100, active_only: bool = False
|
||||||
|
) -> List[TenantResponseDTO]:
|
||||||
|
"""
|
||||||
|
Lista todos los tenants
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skip: Número de registros a omitir
|
||||||
|
limit: Número máximo de registros a retornar
|
||||||
|
active_only: Si True, solo retorna tenants activos
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de TenantResponseDTO
|
||||||
|
"""
|
||||||
|
query = self.db.query(Tenant)
|
||||||
|
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(Tenant.is_active)
|
||||||
|
|
||||||
|
tenants = query.offset(skip).limit(limit).all()
|
||||||
|
return [TenantResponseDTO.model_validate(t) for t in tenants]
|
||||||
|
|
||||||
|
def update_tenant(
|
||||||
|
self, tenant_id: int, tenant_data: TenantUpdateDTO
|
||||||
|
) -> Optional[TenantResponseDTO]:
|
||||||
|
"""
|
||||||
|
Actualiza un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant a actualizar
|
||||||
|
tenant_data: Datos a actualizar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TenantResponseDTO actualizado o None si no existe
|
||||||
|
"""
|
||||||
|
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||||
|
if not tenant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Actualizar solo campos proporcionados
|
||||||
|
update_data = tenant_data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(tenant, field, value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(tenant)
|
||||||
|
return TenantResponseDTO.model_validate(tenant)
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Error updating tenant {tenant_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error updating tenant")
|
||||||
|
|
||||||
|
def delete_tenant(self, tenant_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Elimina (desactiva) un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant a eliminar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se eliminó, False si no existe
|
||||||
|
"""
|
||||||
|
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||||
|
if not tenant:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Soft delete: marcar como inactivo
|
||||||
|
tenant.is_active = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Error deleting tenant {tenant_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error deleting tenant")
|
||||||
|
|
||||||
|
def upgrade_to_dedicated(
|
||||||
|
self, tenant_id: int, db_config: dict
|
||||||
|
) -> Optional[TenantResponseDTO]:
|
||||||
|
"""
|
||||||
|
Actualiza un tenant de BD compartida a BD dedicada
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
db_config: Configuración de BD dedicada
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TenantResponseDTO actualizado
|
||||||
|
"""
|
||||||
|
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||||
|
if not tenant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tenant.type = TenantType.DEDICATED
|
||||||
|
tenant.db_config = json.dumps(db_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(tenant)
|
||||||
|
return TenantResponseDTO.model_validate(tenant)
|
||||||
|
except Exception as e:
|
||||||
|
self.db.rollback()
|
||||||
|
logger.error(f"Error upgrading tenant {tenant_id}: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error upgrading tenant")
|
||||||
67
backend/api/v1/modules/core/user_tenant/dto.py
Normal file
67
backend/api/v1/modules/core/user_tenant/dto.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
DTOs para gestión de relaciones usuario-tenant
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AddUserToTenantRequestDTO(BaseModel):
|
||||||
|
"""Request para agregar un usuario a un tenant"""
|
||||||
|
|
||||||
|
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
|
||||||
|
tenant_id: int = Field(..., description="ID del tenant")
|
||||||
|
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveUserFromTenantRequestDTO(BaseModel):
|
||||||
|
"""Request para eliminar un usuario de un tenant"""
|
||||||
|
|
||||||
|
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
|
||||||
|
tenant_id: int = Field(..., description="ID del tenant")
|
||||||
|
soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRoleRequestDTO(BaseModel):
|
||||||
|
"""Request para actualizar el rol de un usuario en un tenant"""
|
||||||
|
|
||||||
|
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
|
||||||
|
tenant_id: int = Field(..., description="ID del tenant")
|
||||||
|
role: str = Field(..., description="Nuevo rol del usuario")
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenantResponseDTO(BaseModel):
|
||||||
|
"""Response con información de relación usuario-tenant"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
keycloak_user_id: str
|
||||||
|
tenant_id: int
|
||||||
|
is_active: bool
|
||||||
|
role: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBasicInfoDTO(BaseModel):
|
||||||
|
"""Información básica de un tenant"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
is_active: bool
|
||||||
|
keycloak_realm: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenantsResponseDTO(BaseModel):
|
||||||
|
"""Response con los tenants de un usuario"""
|
||||||
|
|
||||||
|
keycloak_user_id: str
|
||||||
|
tenants: list[TenantBasicInfoDTO]
|
||||||
91
backend/api/v1/modules/core/user_tenant/models.py
Normal file
91
backend/api/v1/modules/core/user_tenant/models.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Modelo de relación entre usuarios (Keycloak) y tenants
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
DateTime,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from api.v1.modules.core.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenant(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Relación muchos-a-muchos entre usuarios de Keycloak y tenants
|
||||||
|
|
||||||
|
Un usuario puede pertenecer a múltiples tenants
|
||||||
|
Un tenant puede tener múltiples usuarios
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_tenants"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"keycloak_user_id", "tenant_id", "company_id", name="uq_user_tenant"
|
||||||
|
),
|
||||||
|
{"schema": "core", "extend_existing": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Primary Key
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
|
||||||
|
# ID del usuario en Keycloak (UUID string)
|
||||||
|
keycloak_user_id: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estado de la relación
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default="true", nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Información adicional - Rol del usuario en este tenant (opcional)
|
||||||
|
role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
|
||||||
|
# Campos de perfil de usuario
|
||||||
|
avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500), nullable=True, comment="URL de la imagen de perfil"
|
||||||
|
)
|
||||||
|
workspace_user_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
|
||||||
|
)
|
||||||
|
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
|
||||||
|
)
|
||||||
|
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="Última sincronización de perfil con Workspace",
|
||||||
|
)
|
||||||
|
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
|
||||||
|
# se sincroniza al editar perfil desde Anexo76)
|
||||||
|
first_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100), nullable=True, comment="Nombre (caché local de Keycloak)"
|
||||||
|
)
|
||||||
|
last_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100), nullable=True, comment="Apellido (caché local de Keycloak)"
|
||||||
|
)
|
||||||
|
phone: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(20), nullable=True, comment="Teléfono del usuario"
|
||||||
|
)
|
||||||
|
bio: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True, comment="Biografía del usuario"
|
||||||
|
)
|
||||||
|
preferences: Mapped[Optional[dict]] = mapped_column(
|
||||||
|
JSON, nullable=True, comment="Preferencias del usuario (tema, idioma, etc.)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relación con Tenant
|
||||||
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations")
|
||||||
141
backend/api/v1/modules/core/user_tenant/routes.py
Normal file
141
backend/api/v1/modules/core/user_tenant/routes.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Rutas para gestión de relaciones usuario-tenant
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
AddUserToTenantRequestDTO,
|
||||||
|
RemoveUserFromTenantRequestDTO,
|
||||||
|
TenantBasicInfoDTO,
|
||||||
|
UpdateUserRoleRequestDTO,
|
||||||
|
UserTenantResponseDTO,
|
||||||
|
UserTenantsResponseDTO,
|
||||||
|
)
|
||||||
|
from .service import UserTenantService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/user-tenants")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add", response_model=UserTenantResponseDTO)
|
||||||
|
def add_user_to_tenant(
|
||||||
|
data: AddUserToTenantRequestDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Agrega un usuario a un tenant
|
||||||
|
|
||||||
|
Requiere permisos de administrador
|
||||||
|
"""
|
||||||
|
service = UserTenantService(db)
|
||||||
|
result = service.add_user_to_tenant(
|
||||||
|
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/remove")
|
||||||
|
def remove_user_from_tenant(
|
||||||
|
data: RemoveUserFromTenantRequestDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Elimina un usuario de un tenant
|
||||||
|
|
||||||
|
Requiere permisos de administrador
|
||||||
|
"""
|
||||||
|
service = UserTenantService(db)
|
||||||
|
service.remove_user_from_tenant(
|
||||||
|
keycloak_user_id=data.keycloak_user_id,
|
||||||
|
tenant_id=data.tenant_id,
|
||||||
|
soft_delete=data.soft_delete,
|
||||||
|
)
|
||||||
|
return {"message": "User removed from tenant successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/update-role", response_model=UserTenantResponseDTO)
|
||||||
|
def update_user_role(
|
||||||
|
data: UpdateUserRoleRequestDTO,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza el rol de un usuario en un tenant
|
||||||
|
|
||||||
|
Requiere permisos de administrador
|
||||||
|
"""
|
||||||
|
service = UserTenantService(db)
|
||||||
|
result = service.update_user_role_in_tenant(
|
||||||
|
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO)
|
||||||
|
def get_user_tenants(
|
||||||
|
keycloak_user_id: str,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene todos los tenants a los que tiene acceso un usuario
|
||||||
|
|
||||||
|
Los usuarios solo pueden ver sus propios tenants, a menos que sean admin
|
||||||
|
"""
|
||||||
|
# Verificar que el usuario solo pueda ver sus propios tenants (excepto admin)
|
||||||
|
if current_user.get("sub") != keycloak_user_id:
|
||||||
|
# TODO: Verificar si es admin
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="You can only view your own tenants"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = UserTenantService(db)
|
||||||
|
tenants = service.get_user_tenants(keycloak_user_id)
|
||||||
|
|
||||||
|
return UserTenantsResponseDTO(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO])
|
||||||
|
def get_tenant_users(
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene todos los usuarios que tienen acceso a un tenant
|
||||||
|
|
||||||
|
Requiere permisos de administrador del tenant
|
||||||
|
"""
|
||||||
|
service = UserTenantService(db)
|
||||||
|
user_tenants = service.get_tenant_users(tenant_id)
|
||||||
|
return user_tenants
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/check-access/{keycloak_user_id}/{tenant_id}")
|
||||||
|
def check_user_access(
|
||||||
|
keycloak_user_id: str,
|
||||||
|
tenant_id: int,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verifica si un usuario tiene acceso a un tenant
|
||||||
|
"""
|
||||||
|
service = UserTenantService(db)
|
||||||
|
has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"keycloak_user_id": keycloak_user_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"has_access": has_access,
|
||||||
|
}
|
||||||
225
backend/api/v1/modules/core/user_tenant/service.py
Normal file
225
backend/api/v1/modules/core/user_tenant/service.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
Servicio para gestionar relaciones entre usuarios y tenants
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..tenants.models import Tenant
|
||||||
|
from .models import UserTenant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenantService:
|
||||||
|
"""Servicio para gestionar acceso de usuarios a tenants"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def add_user_to_tenant(
|
||||||
|
self, keycloak_user_id: str, tenant_id: int, role: Optional[str] = None
|
||||||
|
) -> UserTenant:
|
||||||
|
"""
|
||||||
|
Agrega un usuario a un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keycloak_user_id: ID del usuario en Keycloak
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
role: Rol opcional del usuario en este tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserTenant creado
|
||||||
|
"""
|
||||||
|
# Verificar que el tenant existe
|
||||||
|
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||||
|
if not tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||||
|
|
||||||
|
# Verificar si la relación ya existe
|
||||||
|
existing = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Si existe pero está inactiva, reactivarla
|
||||||
|
if not existing.is_active:
|
||||||
|
existing.is_active = True
|
||||||
|
existing.role = role
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="User already has access to this tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crear nueva relación
|
||||||
|
user_tenant = UserTenant(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(user_tenant)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_tenant)
|
||||||
|
return user_tenant
|
||||||
|
|
||||||
|
def remove_user_from_tenant(
|
||||||
|
self, keycloak_user_id: str, tenant_id: int, soft_delete: bool = True
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Elimina un usuario de un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keycloak_user_id: ID del usuario en Keycloak
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se eliminó correctamente
|
||||||
|
"""
|
||||||
|
user_tenant = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="User-tenant relationship not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if soft_delete:
|
||||||
|
user_tenant.is_active = False
|
||||||
|
self.db.commit()
|
||||||
|
else:
|
||||||
|
self.db.delete(user_tenant)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]:
|
||||||
|
"""
|
||||||
|
Obtiene todos los tenants a los que tiene acceso un usuario
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keycloak_user_id: ID del usuario en Keycloak
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de tenants
|
||||||
|
"""
|
||||||
|
user_tenants = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_ids = [ut.tenant_id for ut in user_tenants]
|
||||||
|
|
||||||
|
tenants = (
|
||||||
|
self.db.query(Tenant)
|
||||||
|
.filter(and_(Tenant.id.in_(tenant_ids), Tenant.is_active))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return tenants
|
||||||
|
|
||||||
|
def get_tenant_users(self, tenant_id: int) -> List[UserTenant]:
|
||||||
|
"""
|
||||||
|
Obtiene todos los usuarios que tienen acceso a un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de relaciones UserTenant
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(and_(UserTenant.tenant_id == tenant_id, UserTenant.is_active))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def user_has_access_to_tenant(self, keycloak_user_id: str, tenant_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Verifica si un usuario tiene acceso a un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keycloak_user_id: ID del usuario en Keycloak
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si tiene acceso, False en caso contrario
|
||||||
|
"""
|
||||||
|
user_tenant = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
UserTenant.is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_tenant is not None
|
||||||
|
|
||||||
|
def update_user_role_in_tenant(
|
||||||
|
self, keycloak_user_id: str, tenant_id: int, role: str
|
||||||
|
) -> UserTenant:
|
||||||
|
"""
|
||||||
|
Actualiza el rol de un usuario en un tenant
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keycloak_user_id: ID del usuario en Keycloak
|
||||||
|
tenant_id: ID del tenant
|
||||||
|
role: Nuevo rol
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserTenant actualizado
|
||||||
|
"""
|
||||||
|
user_tenant = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="User-tenant relationship not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_tenant.role = role
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_tenant)
|
||||||
|
return user_tenant
|
||||||
3
backend/api/v1/modules/core/users/__init__.py
Normal file
3
backend/api/v1/modules/core/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Módulo de gestión de usuarios (Keycloak)
|
||||||
|
"""
|
||||||
105
backend/api/v1/modules/core/users/dto.py
Normal file
105
backend/api/v1/modules/core/users/dto.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
DTOs para gestión de usuarios de Keycloak
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequestDTO(BaseModel):
|
||||||
|
"""Request para crear un nuevo usuario en Keycloak"""
|
||||||
|
|
||||||
|
email: EmailStr = Field(..., description="Email del usuario")
|
||||||
|
username: str = Field(
|
||||||
|
..., min_length=3, max_length=50, description="Nombre de usuario"
|
||||||
|
)
|
||||||
|
first_name: str = Field(..., min_length=1, max_length=100, description="Nombre")
|
||||||
|
last_name: str = Field(..., min_length=1, max_length=100, description="Apellido")
|
||||||
|
password: str = Field(..., min_length=8, description="Contraseña temporal")
|
||||||
|
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
|
||||||
|
enabled: bool = Field(True, description="Si el usuario está habilitado")
|
||||||
|
email_verified: bool = Field(False, description="Si el email está verificado")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequestDTO(BaseModel):
|
||||||
|
"""Request para actualizar un usuario en Keycloak"""
|
||||||
|
|
||||||
|
first_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
last_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
email_verified: Optional[bool] = None
|
||||||
|
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||||
|
|
||||||
|
# Campos de perfil
|
||||||
|
avatar_url: Optional[str] = Field(
|
||||||
|
None, max_length=500, description="URL del avatar"
|
||||||
|
)
|
||||||
|
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
|
||||||
|
bio: Optional[str] = Field(None, description="Biografía")
|
||||||
|
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
|
||||||
|
|
||||||
|
@field_validator("first_name", "last_name", "email")
|
||||||
|
@classmethod
|
||||||
|
def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Valida que si el string está presente, no esté vacío"""
|
||||||
|
if v is not None and v.strip() == "":
|
||||||
|
return None # Convertir strings vacíos a None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponseDTO(BaseModel):
|
||||||
|
"""Response con información de usuario de Keycloak"""
|
||||||
|
|
||||||
|
id: str = Field(..., description="ID de Keycloak del usuario")
|
||||||
|
username: str
|
||||||
|
email: str = Field(default="", description="Email del usuario")
|
||||||
|
first_name: str = Field(default="", description="Nombre del usuario")
|
||||||
|
last_name: str = Field(default="", description="Apellido del usuario")
|
||||||
|
enabled: bool
|
||||||
|
email_verified: bool
|
||||||
|
created_timestamp: Optional[int] = None
|
||||||
|
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||||
|
|
||||||
|
# Campos de perfil
|
||||||
|
avatar_url: Optional[str] = Field(None, description="URL del avatar")
|
||||||
|
phone: Optional[str] = Field(None, description="Teléfono")
|
||||||
|
bio: Optional[str] = Field(None, description="Biografía")
|
||||||
|
preferences: Optional[dict] = Field(
|
||||||
|
default_factory=dict, description="Preferencias"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponseDTO(BaseModel):
|
||||||
|
"""Response con lista de usuarios"""
|
||||||
|
|
||||||
|
users: List[UserResponseDTO]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequestDTO(BaseModel):
|
||||||
|
"""Request para cambiar contraseña de un usuario"""
|
||||||
|
|
||||||
|
password: str = Field(..., min_length=8, description="Nueva contraseña")
|
||||||
|
temporary: bool = Field(
|
||||||
|
True, description="Si es temporal (usuario debe cambiarla al login)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatsDTO(BaseModel):
|
||||||
|
"""Estadísticas de usuarios del tenant"""
|
||||||
|
|
||||||
|
total_users: int
|
||||||
|
active_users: int
|
||||||
|
inactive_users: int
|
||||||
|
max_users_allowed: int
|
||||||
|
users_available: int
|
||||||
|
usage_percentage: float
|
||||||
478
backend/api/v1/modules/core/users/routes.py
Normal file
478
backend/api/v1/modules/core/users/routes.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
"""
|
||||||
|
Rutas para gestión de usuarios de Keycloak
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.s3_keys import public_user_avatar_api_path, user_avatar_key
|
||||||
|
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
|
||||||
|
from core.security import (
|
||||||
|
get_current_user,
|
||||||
|
is_hub_admin,
|
||||||
|
resolve_hub_tenant_id_for_api,
|
||||||
|
validate_access_to_resource,
|
||||||
|
)
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..user_tenant.models import UserTenant
|
||||||
|
from .dto import (
|
||||||
|
ChangePasswordRequestDTO,
|
||||||
|
CreateUserRequestDTO,
|
||||||
|
UpdateUserRequestDTO,
|
||||||
|
UserListResponseDTO,
|
||||||
|
UserResponseDTO,
|
||||||
|
UserStatsDTO,
|
||||||
|
)
|
||||||
|
from .service import UserService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["Users"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=UserStatsDTO)
|
||||||
|
async def get_user_statistics(
|
||||||
|
request: Request,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene estadísticas de usuarios del tenant actual
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
token = (
|
||||||
|
auth_header[7:].strip()
|
||||||
|
if auth_header.lower().startswith("bearer ")
|
||||||
|
else auth_header.strip()
|
||||||
|
)
|
||||||
|
hub_tid = resolve_hub_tenant_id_for_api(
|
||||||
|
tenant_id, request.headers.get("X-Tenant-Override")
|
||||||
|
)
|
||||||
|
return service.get_user_stats(
|
||||||
|
access_token=token or None,
|
||||||
|
hub_tenant_id=hub_tid,
|
||||||
|
x_tenant_override=request.headers.get("X-Tenant-Override"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=UserListResponseDTO)
|
||||||
|
async def list_users(
|
||||||
|
request: Request,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
page: int = Query(1, ge=1, description="Número de página"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
|
||||||
|
search: Optional[str] = Query(None, description="Término de búsqueda"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Lista todos los usuarios del tenant con paginación
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
token = (
|
||||||
|
auth_header[7:].strip()
|
||||||
|
if auth_header.lower().startswith("bearer ")
|
||||||
|
else auth_header.strip()
|
||||||
|
)
|
||||||
|
hub_tid = resolve_hub_tenant_id_for_api(
|
||||||
|
tenant_id, request.headers.get("X-Tenant-Override")
|
||||||
|
)
|
||||||
|
result = await service.get_tenant_users(
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
search=search,
|
||||||
|
access_token=token,
|
||||||
|
hub_tenant_id=hub_tid,
|
||||||
|
x_tenant_override=request.headers.get("X-Tenant-Override"),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/avatar/{tenant_id}/{keycloak_user_id}")
|
||||||
|
def get_user_avatar_image(
|
||||||
|
tenant_id: int,
|
||||||
|
keycloak_user_id: str,
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Sirve la imagen de avatar (público para poder usarla en <img src> sin Bearer).
|
||||||
|
El almacenamiento interno puede ser clave S3 o ruta bajo uploads/.
|
||||||
|
"""
|
||||||
|
ut = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ut or not ut.avatar_url:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
|
||||||
|
raw = ut.avatar_url
|
||||||
|
if raw.startswith("tenants/"):
|
||||||
|
try:
|
||||||
|
data = get_object_bytes(raw)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||||
|
media = mimetypes.guess_type(raw)[0] or "image/jpeg"
|
||||||
|
return Response(content=data, media_type=media)
|
||||||
|
|
||||||
|
rel = raw.lstrip("/")
|
||||||
|
path = Path(rel)
|
||||||
|
if not path.is_file():
|
||||||
|
path = Path.cwd() / rel
|
||||||
|
if not path.is_file():
|
||||||
|
alt = Path("/app") / rel
|
||||||
|
if alt.is_file():
|
||||||
|
path = alt
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Avatar file not found")
|
||||||
|
data = path.read_bytes()
|
||||||
|
media = mimetypes.guess_type(str(path))[0] or "image/jpeg"
|
||||||
|
return Response(content=data, media_type=media)
|
||||||
|
|
||||||
|
|
||||||
|
# === Endpoints de Perfil del Usuario Actual ===
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||||
|
async def get_my_profile(
|
||||||
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene el perfil completo del usuario actual
|
||||||
|
"""
|
||||||
|
keycloak_user_id = current_user.get("sub")
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||||
|
|
||||||
|
# Obtener user_tenant para crear servicio
|
||||||
|
user_tenant = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="User does not belong to any tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
access_token = (
|
||||||
|
auth_header[7:].strip()
|
||||||
|
if auth_header.lower().startswith("bearer ")
|
||||||
|
else auth_header.strip() or None
|
||||||
|
)
|
||||||
|
return await service.get_current_user_profile(
|
||||||
|
keycloak_user_id,
|
||||||
|
current_user=current_user,
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||||
|
async def update_my_profile(
|
||||||
|
request: Request,
|
||||||
|
data: UpdateUserRequestDTO,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza el perfil del usuario actual.
|
||||||
|
Campos editables: first_name, last_name, phone.
|
||||||
|
Email, username y otros campos de identidad solo se cambian desde el Hub.
|
||||||
|
"""
|
||||||
|
keycloak_user_id = current_user.get("sub")
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||||
|
|
||||||
|
# Obtener user_tenant
|
||||||
|
user_tenant = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="User does not belong to any tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
|
||||||
|
# y no debe mutarse localmente en Anexo76.
|
||||||
|
if current_user.get("sub"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Avatar is managed by Workspace for this user",
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
access_token = (
|
||||||
|
auth_header[7:].strip()
|
||||||
|
if auth_header.lower().startswith("bearer ")
|
||||||
|
else auth_header.strip() or None
|
||||||
|
)
|
||||||
|
|
||||||
|
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||||
|
return await service.update_current_user_profile(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
current_user=current_user,
|
||||||
|
access_token=access_token,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
phone=data.phone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/avatar")
|
||||||
|
async def upload_my_avatar(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Sube un avatar para el usuario actual.
|
||||||
|
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
|
||||||
|
Retorna URL pública para <img src> (GET /users/avatar/...).
|
||||||
|
"""
|
||||||
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
|
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
||||||
|
|
||||||
|
keycloak_user_id = current_user.get("sub")
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||||
|
|
||||||
|
user_tenant = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="User does not belong to any tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
|
||||||
|
if ext not in _AVATAR_EXT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > 2 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
|
||||||
|
|
||||||
|
tenant_id = user_tenant.tenant_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings.use_s3_object_storage:
|
||||||
|
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
|
||||||
|
"tenants/"
|
||||||
|
):
|
||||||
|
delete_object_if_exists(str(user_tenant.avatar_url))
|
||||||
|
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
|
||||||
|
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
|
||||||
|
put_object_bytes(key, contents, content_type=ct)
|
||||||
|
user_tenant.avatar_url = key
|
||||||
|
logger.info(
|
||||||
|
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
upload_dir = Path("uploads/avatars")
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = f"{keycloak_user_id}{ext}"
|
||||||
|
file_path = upload_dir / filename
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
|
||||||
|
|
||||||
|
db.add(user_tenant)
|
||||||
|
db.commit()
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
|
||||||
|
return {"avatar_url": public_url}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponseDTO)
|
||||||
|
async def get_user_detail(
|
||||||
|
user_id: str,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene información detallada de un usuario específico
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
return await service.get_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=UserResponseDTO, status_code=201)
|
||||||
|
async def create_new_user(
|
||||||
|
data: CreateUserRequestDTO,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Crea un nuevo usuario a través del Hub y lo asocia al tenant
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
user = await service.create_user(
|
||||||
|
email=data.email,
|
||||||
|
username=data.username,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
password=data.password,
|
||||||
|
role=data.role,
|
||||||
|
enabled=data.enabled,
|
||||||
|
email_verified=data.email_verified,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=UserResponseDTO)
|
||||||
|
async def update_user_detail(
|
||||||
|
user_id: str,
|
||||||
|
data: UpdateUserRequestDTO,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza información de un usuario
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
user = await service.update_user(
|
||||||
|
user_id=user_id,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
email=data.email,
|
||||||
|
enabled=data.enabled,
|
||||||
|
email_verified=data.email_verified,
|
||||||
|
role=data.role,
|
||||||
|
avatar_url=data.avatar_url,
|
||||||
|
phone=data.phone,
|
||||||
|
bio=data.bio,
|
||||||
|
preferences=data.preferences,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}/tenant-count")
|
||||||
|
async def get_user_tenant_count(
|
||||||
|
user_id: str,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Retorna en cuántos tenants está registrado el usuario.
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(
|
||||||
|
db, company_id, current_user, required_permissions=["user.view"]
|
||||||
|
)
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
count = service.get_user_tenant_count(user_id)
|
||||||
|
return {"tenant_count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}")
|
||||||
|
async def delete_user_route(
|
||||||
|
request: Request,
|
||||||
|
user_id: str,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
soft_delete: bool = Query(
|
||||||
|
True,
|
||||||
|
description="Si es True, solo desactiva. Si es False, elimina permanentemente",
|
||||||
|
),
|
||||||
|
scope: str = Query(
|
||||||
|
"current",
|
||||||
|
description="'current' para borrar solo del tenant activo, 'all' para borrar de todos los tenants",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Elimina un usuario del tenant.
|
||||||
|
scope='current' (default): solo del tenant activo.
|
||||||
|
scope='all': de todos los tenants en los que aparece el usuario.
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
token = (
|
||||||
|
auth_header[7:].strip()
|
||||||
|
if auth_header.lower().startswith("bearer ")
|
||||||
|
else auth_header.strip()
|
||||||
|
)
|
||||||
|
hub_tid = resolve_hub_tenant_id_for_api(
|
||||||
|
tenant_id, request.headers.get("X-Tenant-Override")
|
||||||
|
)
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
await service.delete_user(
|
||||||
|
user_id,
|
||||||
|
soft_delete=soft_delete,
|
||||||
|
scope=scope,
|
||||||
|
access_token=token or None,
|
||||||
|
hub_tenant_id=hub_tid,
|
||||||
|
)
|
||||||
|
return {"message": "User deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/change-password")
|
||||||
|
async def change_user_password(
|
||||||
|
user_id: str,
|
||||||
|
data: ChangePasswordRequestDTO,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Cambia la contraseña de un usuario a través del Hub
|
||||||
|
"""
|
||||||
|
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
|
||||||
|
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||||
|
await service.change_password(user_id, data.password, data.temporary)
|
||||||
|
return {"message": "Password changed successfully"}
|
||||||
808
backend/api/v1/modules/core/users/service.py
Normal file
808
backend/api/v1/modules/core/users/service.py
Normal file
@@ -0,0 +1,808 @@
|
|||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_, func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
from ..licenses.models import License, LicenseStatus
|
||||||
|
from ..user_tenant.models import UserTenant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_http_url(url: Optional[str]) -> bool:
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
parsed = urlparse(url.strip())
|
||||||
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
|
||||||
|
if not user_tenant or not user_tenant.avatar_url:
|
||||||
|
return None
|
||||||
|
avatar_out = str(user_tenant.avatar_url)
|
||||||
|
|
||||||
|
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
|
||||||
|
return avatar_out if _is_valid_http_url(avatar_out) else None
|
||||||
|
|
||||||
|
from core.s3_keys import public_user_avatar_api_path
|
||||||
|
|
||||||
|
# Entregamos siempre el endpoint público del backend para assets locales/S3.
|
||||||
|
return public_user_avatar_api_path(
|
||||||
|
user_tenant.tenant_id,
|
||||||
|
user_tenant.keycloak_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_user(
|
||||||
|
user_data: Dict[str, Any],
|
||||||
|
role: Optional[str] = None,
|
||||||
|
user_tenant: Optional[Any] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Normaliza los datos de usuario al formato esperado por el DTO.
|
||||||
|
Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'.
|
||||||
|
"""
|
||||||
|
name_parts = (user_data.get("name") or "").split(" ", 1)
|
||||||
|
# Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76
|
||||||
|
local_first = getattr(user_tenant, "first_name", None) if user_tenant else None
|
||||||
|
local_last = getattr(user_tenant, "last_name", None) if user_tenant else None
|
||||||
|
|
||||||
|
normalized = {
|
||||||
|
"id": user_data.get("id") or user_data.get("sub"),
|
||||||
|
"username": user_data.get("username") or user_data.get("preferred_username", ""),
|
||||||
|
"email": user_data.get("email", ""),
|
||||||
|
"first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""),
|
||||||
|
"last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""),
|
||||||
|
"enabled": user_data.get("enabled", True),
|
||||||
|
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
|
||||||
|
"created_timestamp": user_data.get("createdTimestamp"),
|
||||||
|
"role": role,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar campos de perfil si user_tenant está disponible
|
||||||
|
if user_tenant:
|
||||||
|
workspace_avatar = (
|
||||||
|
user_tenant.workspace_avatar_url
|
||||||
|
if _is_valid_http_url(user_tenant.workspace_avatar_url)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
legacy_avatar = _legacy_avatar_public_url(user_tenant)
|
||||||
|
avatar_out = workspace_avatar or legacy_avatar
|
||||||
|
|
||||||
|
normalized.update(
|
||||||
|
{
|
||||||
|
"avatar_url": avatar_out,
|
||||||
|
"workspace_avatar_url": workspace_avatar,
|
||||||
|
"legacy_avatar_url": legacy_avatar,
|
||||||
|
"workspace_user_id": user_tenant.workspace_user_id,
|
||||||
|
"phone": user_tenant.phone,
|
||||||
|
"bio": user_tenant.bio,
|
||||||
|
"preferences": user_tenant.preferences or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""Servicio para gestionar usuarios vía Hub"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None, *, is_hub_admin: bool = False):
|
||||||
|
self.db = db
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.company_id = company_id
|
||||||
|
self.is_hub_admin = is_hub_admin
|
||||||
|
|
||||||
|
def _get_license(self) -> License:
|
||||||
|
"""Obtiene la licencia del tenant actual"""
|
||||||
|
license = (
|
||||||
|
self.db.query(License).filter(License.tenant_id == self.tenant_id).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not license:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail="License not found for this tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
if license.status != LicenseStatus.ACTIVE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"License is not active. Current status: {license.status.value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verificar si la licencia está vigente
|
||||||
|
now = datetime.now(license.expires_at.tzinfo)
|
||||||
|
if license.expires_at < now:
|
||||||
|
raise HTTPException(status_code=403, detail="License has expired")
|
||||||
|
|
||||||
|
return license
|
||||||
|
|
||||||
|
def _check_user_limit(self) -> None:
|
||||||
|
"""Verifica si se puede crear un nuevo usuario según la licencia"""
|
||||||
|
if self.is_hub_admin:
|
||||||
|
return
|
||||||
|
license = self._get_license()
|
||||||
|
|
||||||
|
# Contar usuarios activos del tenant
|
||||||
|
active_users = (
|
||||||
|
self.db.query(func.count(UserTenant.id))
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.tenant_id == self.tenant_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
# max_users=NULL en BD indica licencia sin cuota (ilimitada).
|
||||||
|
# Comparar con None lanzaría TypeError — salida temprana explícita.
|
||||||
|
if license.max_users is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if active_users >= license.max_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"User limit reached. Your license allows {license.max_users} users. "
|
||||||
|
f"Currently active: {active_users}. Please upgrade your license.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
username: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
password: str,
|
||||||
|
role: Optional[str] = None,
|
||||||
|
enabled: bool = True,
|
||||||
|
email_verified: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Crea un nuevo usuario a través del Hub y lo asocia localmente
|
||||||
|
"""
|
||||||
|
# Verificar límite de usuarios
|
||||||
|
self._check_user_limit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Mandar al Hub para creación en Keycloak
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
hub_response = await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": email,
|
||||||
|
"username": username,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"password": password,
|
||||||
|
"tenant_slug": "default", # TODO: Get real slug if needed
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if hub_response.status_code != 201:
|
||||||
|
logger.error(f"Hub registration failed: {hub_response.text}")
|
||||||
|
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
|
||||||
|
|
||||||
|
user_data = hub_response.json()
|
||||||
|
user_id = user_data.get("user_id")
|
||||||
|
|
||||||
|
# Obtener company_id — implementa con tu modelo de compañía si company_id es None.
|
||||||
|
if not self.company_id:
|
||||||
|
raise HTTPException(status_code=400, detail="company_id requerido")
|
||||||
|
company_id = self.company_id
|
||||||
|
|
||||||
|
# Crear relación local
|
||||||
|
user_tenant = UserTenant(
|
||||||
|
keycloak_user_id=user_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
company_id=company_id,
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.db.add(user_tenant)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
return _normalize_user(user_data, role, user_tenant)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating user: {str(e)}")
|
||||||
|
self.db.rollback()
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
raise e
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
async def _fetch_hub_tenant_users_with_info(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
hub_tenant_id: int,
|
||||||
|
x_tenant_override: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants)."""
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info"
|
||||||
|
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
if x_tenant_override and str(x_tenant_override).strip():
|
||||||
|
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise HTTPException(status_code=401, detail="No autorizado en el Hub")
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub"
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.error(
|
||||||
|
"Hub users-with-info error status=%s body=%s",
|
||||||
|
response.status_code,
|
||||||
|
response.text[:500],
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="No se pudo obtener el catálogo de usuarios desde el Hub",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502, detail="Respuesta inválida del Hub al listar usuarios"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_tenant_users(
|
||||||
|
self,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
access_token: str,
|
||||||
|
hub_tenant_id: int,
|
||||||
|
x_tenant_override: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y
|
||||||
|
perfil extendido desde BD local (user_tenants / user_company_roles).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ..permissions.models import UserCompanyRole
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
if not access_token or not hub_tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Token o tenant Hub requerido para listar usuarios",
|
||||||
|
)
|
||||||
|
|
||||||
|
hub_rows = await self._fetch_hub_tenant_users_with_info(
|
||||||
|
access_token, hub_tenant_id, x_tenant_override
|
||||||
|
)
|
||||||
|
|
||||||
|
user_roles_query = (
|
||||||
|
self.db.query(UserCompanyRole)
|
||||||
|
.options(joinedload(UserCompanyRole.company_role))
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyRole.company_id == self.company_id,
|
||||||
|
UserCompanyRole.tenant_id == self.tenant_id,
|
||||||
|
UserCompanyRole.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_roles_map: Dict[str, List[str]] = {}
|
||||||
|
for user_role in user_roles_query.all():
|
||||||
|
uid = user_role.user_id
|
||||||
|
if uid not in user_roles_map:
|
||||||
|
user_roles_map[uid] = []
|
||||||
|
user_roles_map[uid].append(user_role.company_role.name)
|
||||||
|
|
||||||
|
local_by_kc = {
|
||||||
|
ut.keycloak_user_id: ut
|
||||||
|
for ut in self.db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.tenant_id == self.tenant_id,
|
||||||
|
UserTenant.company_id == self.company_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
needle = (search or "").strip().lower()
|
||||||
|
filtered: List[Dict[str, Any]] = []
|
||||||
|
for u in hub_rows:
|
||||||
|
if not u.get("is_active", True):
|
||||||
|
continue
|
||||||
|
kc = u.get("keycloak_user_id")
|
||||||
|
if not kc:
|
||||||
|
continue
|
||||||
|
# Filtrar usuarios soft-deleted localmente (is_active=False en user_tenants local)
|
||||||
|
local_ut_check = local_by_kc.get(kc)
|
||||||
|
if local_ut_check is not None and not local_ut_check.is_active:
|
||||||
|
continue
|
||||||
|
if needle:
|
||||||
|
blob = " ".join(
|
||||||
|
[
|
||||||
|
str(u.get("email") or ""),
|
||||||
|
str(u.get("username") or ""),
|
||||||
|
str(u.get("first_name") or ""),
|
||||||
|
str(u.get("last_name") or ""),
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
ut_loc = local_by_kc.get(kc)
|
||||||
|
if ut_loc:
|
||||||
|
blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower()
|
||||||
|
if needle not in blob:
|
||||||
|
continue
|
||||||
|
filtered.append(u)
|
||||||
|
|
||||||
|
total = len(filtered)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
page_rows = filtered[offset : offset + page_size]
|
||||||
|
|
||||||
|
users: List[Dict[str, Any]] = []
|
||||||
|
for u in page_rows:
|
||||||
|
kc = u["keycloak_user_id"]
|
||||||
|
role_names = user_roles_map.get(kc, [])
|
||||||
|
role_str = ", ".join(role_names) if role_names else u.get("role")
|
||||||
|
local_ut = local_by_kc.get(kc)
|
||||||
|
normalized_user = _normalize_user(
|
||||||
|
{
|
||||||
|
"id": kc,
|
||||||
|
"username": u.get("username") or "",
|
||||||
|
"email": u.get("email") or "",
|
||||||
|
"firstName": u.get("first_name") or "",
|
||||||
|
"lastName": u.get("last_name") or "",
|
||||||
|
"enabled": u.get("is_active", True),
|
||||||
|
"emailVerified": False,
|
||||||
|
},
|
||||||
|
role_str,
|
||||||
|
local_ut,
|
||||||
|
)
|
||||||
|
users.append(normalized_user)
|
||||||
|
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size) if total else 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": users,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting tenant users: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error getting users: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
async def get_user(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
"""Obtiene un usuario específico"""
|
||||||
|
from ..permissions.models import UserCompanyRole
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == user_id,
|
||||||
|
UserTenant.tenant_id == self.tenant_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Roles locales
|
||||||
|
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
|
||||||
|
and_(
|
||||||
|
UserCompanyRole.user_id == user_id,
|
||||||
|
UserCompanyRole.company_id == self.company_id,
|
||||||
|
UserCompanyRole.tenant_id == self.tenant_id,
|
||||||
|
UserCompanyRole.is_active == True
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
roles = [ur.company_role.name for ur in user_roles]
|
||||||
|
role_str = ", ".join(roles) if roles else None
|
||||||
|
|
||||||
|
# TODO: Call Hub if more info is needed
|
||||||
|
return _normalize_user({"id": user_id}, role_str, user_tenant)
|
||||||
|
|
||||||
|
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
|
||||||
|
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
|
||||||
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
|
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Actualizar campos locales
|
||||||
|
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
|
||||||
|
if field in kwargs and kwargs[field] is not None:
|
||||||
|
setattr(user_tenant, field, kwargs[field])
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_tenant)
|
||||||
|
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
|
||||||
|
|
||||||
|
def get_user_tenant_count(self, user_id: str) -> int:
|
||||||
|
"""Cuenta en cuántos tenants activos está registrado el usuario."""
|
||||||
|
return (
|
||||||
|
self.db.query(func.count(UserTenant.id))
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
soft_delete: bool = True,
|
||||||
|
scope: str = "current",
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
hub_tenant_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Elimina/Desactiva usuario.
|
||||||
|
scope='current': solo del tenant activo.
|
||||||
|
scope='all': de todos los tenants (útil cuando el usuario pertenece a múltiples tenants).
|
||||||
|
"""
|
||||||
|
if scope == "all":
|
||||||
|
rows = (
|
||||||
|
self.db.query(UserTenant)
|
||||||
|
.filter(UserTenant.keycloak_user_id == user_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
# Collect unique hub_tenant_ids to notify Hub for each tenant
|
||||||
|
hub_tenant_ids = {row.tenant_id for row in rows}
|
||||||
|
for row in rows:
|
||||||
|
row.is_active = False
|
||||||
|
if not soft_delete:
|
||||||
|
row.deleted_at = now
|
||||||
|
self.db.commit()
|
||||||
|
# Propagate to Hub for every tenant the user belonged to
|
||||||
|
if access_token:
|
||||||
|
for tid in hub_tenant_ids:
|
||||||
|
await self._hub_remove_user(user_id, tid, soft_delete, access_token)
|
||||||
|
return
|
||||||
|
|
||||||
|
# scope == "current" (default)
|
||||||
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.keycloak_user_id == user_id,
|
||||||
|
UserTenant.tenant_id == self.tenant_id,
|
||||||
|
UserTenant.company_id == self.company_id,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
# No local record — user exists in Hub but not synced locally yet.
|
||||||
|
# Create tombstone so user is filtered from future listings.
|
||||||
|
user_tenant = UserTenant(
|
||||||
|
keycloak_user_id=user_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
company_id=self.company_id,
|
||||||
|
is_active=False,
|
||||||
|
deleted_at=None if soft_delete else datetime.utcnow(),
|
||||||
|
)
|
||||||
|
self.db.add(user_tenant)
|
||||||
|
self.db.commit()
|
||||||
|
else:
|
||||||
|
user_tenant.is_active = False
|
||||||
|
if not soft_delete:
|
||||||
|
user_tenant.deleted_at = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
# Propagate to Hub
|
||||||
|
if access_token and hub_tenant_id:
|
||||||
|
await self._hub_remove_user(user_id, hub_tenant_id, soft_delete, access_token)
|
||||||
|
|
||||||
|
async def _hub_remove_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
hub_tenant_id: int,
|
||||||
|
soft_delete: bool,
|
||||||
|
access_token: str,
|
||||||
|
) -> None:
|
||||||
|
"""Calls Hub POST /api/v1/hub/user-tenants/remove to sync the deletion."""
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
url = f"{base}/api/v1/hub/user-tenants/remove"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"keycloak_user_id": user_id,
|
||||||
|
"tenant_id": hub_tenant_id,
|
||||||
|
"soft_delete": soft_delete,
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.warning(
|
||||||
|
"Hub remove user-tenant returned %s for user %s tenant %s: %s",
|
||||||
|
resp.status_code, user_id, hub_tenant_id, resp.text[:200],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error calling Hub remove user-tenant: %s", exc)
|
||||||
|
# Do not raise — local deletion already committed; Hub sync is best-effort.
|
||||||
|
|
||||||
|
async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None:
|
||||||
|
"""Cambia contraseña vía Hub"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{settings.HUB_URL}api/v1/auth/change-password",
|
||||||
|
json={"user_id": user_id, "password": password, "temporary": temporary}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error changing password: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Error changing password")
|
||||||
|
|
||||||
|
def _count_active_user_tenants_local(self) -> int:
|
||||||
|
return (
|
||||||
|
self.db.query(func.count(UserTenant.id))
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.tenant_id == self.tenant_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_stats(
|
||||||
|
self,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
hub_tenant_id: Optional[int] = None,
|
||||||
|
x_tenant_override: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license)
|
||||||
|
con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token;
|
||||||
|
inactivos y fallback de conteos en BD local.
|
||||||
|
"""
|
||||||
|
max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
|
||||||
|
hub_max_ok = False
|
||||||
|
active_users = 0
|
||||||
|
active_from_hub = False
|
||||||
|
|
||||||
|
if access_token and hub_tenant_id:
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
if x_tenant_override and str(x_tenant_override).strip():
|
||||||
|
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
lic_resp = client.get(
|
||||||
|
f"{base}/api/v1/auth/verify-license",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if lic_resp.status_code == 200:
|
||||||
|
lic_body = lic_resp.json()
|
||||||
|
if lic_body.get("valid"):
|
||||||
|
raw_max = lic_body.get("max_users")
|
||||||
|
# max_users=null → hub_admin sin cuota; None indica ilimitado
|
||||||
|
max_users_allowed = int(raw_max) if raw_max is not None else None
|
||||||
|
hub_max_ok = True
|
||||||
|
|
||||||
|
users_resp = client.get(
|
||||||
|
f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if users_resp.status_code == 200:
|
||||||
|
payload = users_resp.json()
|
||||||
|
if isinstance(payload, list):
|
||||||
|
active_users = sum(
|
||||||
|
1 for row in payload if row.get("is_active", True)
|
||||||
|
)
|
||||||
|
active_from_hub = True
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Hub users-with-info stats: respuesta no lista"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Hub users-with-info stats status=%s",
|
||||||
|
users_resp.status_code,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Hub stats (verify-license / users-with-info): %s", e)
|
||||||
|
|
||||||
|
if not hub_max_ok:
|
||||||
|
license = self._get_license()
|
||||||
|
max_users_allowed = license.max_users
|
||||||
|
|
||||||
|
if not active_from_hub:
|
||||||
|
active_users = self._count_active_user_tenants_local()
|
||||||
|
|
||||||
|
inactive_users = (
|
||||||
|
self.db.query(func.count(UserTenant.id))
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
UserTenant.tenant_id == self.tenant_id,
|
||||||
|
UserTenant.is_active == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
total_users = active_users + inactive_users
|
||||||
|
# Cuando max_users_allowed es None la cuota es ilimitada (hub_admin)
|
||||||
|
users_available = (
|
||||||
|
max(0, max_users_allowed - active_users)
|
||||||
|
if max_users_allowed is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
usage_percentage = (
|
||||||
|
(active_users / max_users_allowed * 100) if max_users_allowed else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_users": active_users,
|
||||||
|
"inactive_users": inactive_users,
|
||||||
|
"max_users_allowed": max_users_allowed,
|
||||||
|
"users_available": users_available,
|
||||||
|
"usage_percentage": round(usage_percentage, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_current_user_profile(
|
||||||
|
self,
|
||||||
|
keycloak_user_id: str,
|
||||||
|
current_user: Dict[str, Any] = None,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Obtiene el perfil completo del usuario actual"""
|
||||||
|
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
|
||||||
|
user_info = current_user or {"id": keycloak_user_id}
|
||||||
|
|
||||||
|
# Perfil "me": sincronización con cache corto (5 min).
|
||||||
|
if access_token:
|
||||||
|
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||||
|
|
||||||
|
await sync_workspace_profile_for_user(
|
||||||
|
self.db,
|
||||||
|
access_token=access_token,
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
|
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
|
||||||
|
|
||||||
|
async def update_current_user_profile(
|
||||||
|
self,
|
||||||
|
keycloak_user_id: str,
|
||||||
|
current_user: Dict[str, Any] = None,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Actualiza el perfil del usuario actual.
|
||||||
|
- first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub.
|
||||||
|
- phone: persiste solo localmente.
|
||||||
|
"""
|
||||||
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
|
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Campos locales — incluye first_name/last_name como caché
|
||||||
|
for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]:
|
||||||
|
if field in kwargs and kwargs[field] is not None:
|
||||||
|
setattr(user_tenant, field, kwargs[field])
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_tenant)
|
||||||
|
|
||||||
|
# Sincronizar nombre/apellido con Keycloak vía Hub (best-effort)
|
||||||
|
first_name = kwargs.get("first_name")
|
||||||
|
last_name = kwargs.get("last_name")
|
||||||
|
if access_token and (first_name or last_name):
|
||||||
|
await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token)
|
||||||
|
|
||||||
|
user_info = current_user or {"id": keycloak_user_id}
|
||||||
|
return _normalize_user(user_info, user_tenant.role, user_tenant)
|
||||||
|
|
||||||
|
async def _hub_get_service_token(self) -> Optional[str]:
|
||||||
|
"""Obtiene un token de la cuenta de servicio Hub para operaciones admin."""
|
||||||
|
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{base}/api/v1/auth/login",
|
||||||
|
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("access_token")
|
||||||
|
logger.warning("Hub service account login failed status=%s", resp.status_code)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Hub service account login error: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _hub_update_user_profile(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
first_name: Optional[str],
|
||||||
|
last_name: Optional[str],
|
||||||
|
access_token: str,
|
||||||
|
) -> None:
|
||||||
|
"""Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea).
|
||||||
|
|
||||||
|
Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no
|
||||||
|
tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en
|
||||||
|
HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD.
|
||||||
|
"""
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
url = f"{base}/api/v1/hub/admins/{user_id}"
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if first_name:
|
||||||
|
payload["first_name"] = first_name
|
||||||
|
if last_name:
|
||||||
|
payload["last_name"] = last_name
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 403:
|
||||||
|
# El usuario no es Hub admin — reintentar con cuenta de servicio
|
||||||
|
logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id)
|
||||||
|
service_token = await self._hub_get_service_token()
|
||||||
|
if service_token:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {service_token}"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if resp.status_code not in (200, 204):
|
||||||
|
logger.warning(
|
||||||
|
"Hub profile sync failed status=%s body=%s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:300],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Hub profile sync OK user_id=%s", user_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc)
|
||||||
|
|
||||||
0
backend/api/v1/modules/example/__init__.py
Normal file
0
backend/api/v1/modules/example/__init__.py
Normal file
21
backend/api/v1/modules/example/dto.py
Normal file
21
backend/api/v1/modules/example/dto.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
tenant_id: int
|
||||||
|
company_id: int
|
||||||
16
backend/api/v1/modules/example/models.py
Normal file
16
backend/api/v1/modules/example/models.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy import Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Item(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
"""Modelo de ejemplo — renombra y ajusta a tu entidad de negocio."""
|
||||||
|
|
||||||
|
__tablename__ = "example_items"
|
||||||
|
__table_args__ = {"schema": "public"}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
65
backend/api/v1/modules/example/routes.py
Normal file
65
backend/api/v1/modules/example/routes.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
|
||||||
|
from .dto import ItemCreate, ItemResponse, ItemUpdate
|
||||||
|
from . import service
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[ItemResponse])
|
||||||
|
def list_items(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = current_user["tenant_id"]
|
||||||
|
return service.get_items(db, tenant_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=ItemResponse)
|
||||||
|
def get_item(
|
||||||
|
item_id: int,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = current_user["tenant_id"]
|
||||||
|
return service.get_item(db, item_id, tenant_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items", response_model=ItemResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_item(
|
||||||
|
payload: ItemCreate,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = current_user["tenant_id"]
|
||||||
|
return service.create_item(db, payload, tenant_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_model=ItemResponse)
|
||||||
|
def update_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: ItemUpdate,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = current_user["tenant_id"]
|
||||||
|
return service.update_item(db, item_id, payload, tenant_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_item(
|
||||||
|
item_id: int,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id = current_user["tenant_id"]
|
||||||
|
service.delete_item(db, item_id, tenant_id, company_id)
|
||||||
48
backend/api/v1/modules/example/service.py
Normal file
48
backend/api/v1/modules/example/service.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import ItemCreate, ItemUpdate
|
||||||
|
from .models import Item
|
||||||
|
|
||||||
|
|
||||||
|
def get_items(db: Session, tenant_id: int, company_id: int) -> list[Item]:
|
||||||
|
return (
|
||||||
|
db.query(Item)
|
||||||
|
.filter(Item.tenant_id == tenant_id, Item.company_id == company_id, Item.deleted_at.is_(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> Item:
|
||||||
|
item = (
|
||||||
|
db.query(Item)
|
||||||
|
.filter(Item.id == item_id, Item.tenant_id == tenant_id, Item.company_id == company_id, Item.deleted_at.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item no encontrado")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def create_item(db: Session, payload: ItemCreate, tenant_id: int, company_id: int) -> Item:
|
||||||
|
item = Item(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(db: Session, item_id: int, payload: ItemUpdate, tenant_id: int, company_id: int) -> Item:
|
||||||
|
item = get_item(db, item_id, tenant_id, company_id)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(item, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
|
||||||
|
item = get_item(db, item_id, tenant_id, company_id)
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
item.deleted_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
20
backend/api/v1/router.py
Normal file
20
backend/api/v1/router.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Router principal de API v1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .modules.core.router import router as core_router
|
||||||
|
from .modules.example.routes import router as example_router
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(core_router)
|
||||||
|
router.include_router(example_router, prefix="/example", tags=["example"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def status():
|
||||||
|
"""Health check de la API"""
|
||||||
|
return {"status": "ok", "version": "1.0.0", "api": "v1"}
|
||||||
41
backend/core/__init__.py
Normal file
41
backend/core/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Core module - Configuración y utilidades centrales de la aplicación
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .database import (
|
||||||
|
Base,
|
||||||
|
get_async_core_db,
|
||||||
|
get_core_db,
|
||||||
|
get_tenant_db,
|
||||||
|
init_async_db,
|
||||||
|
init_db,
|
||||||
|
scoped_async_core_db,
|
||||||
|
scoped_core_db,
|
||||||
|
set_rls_context,
|
||||||
|
)
|
||||||
|
from .security import (
|
||||||
|
get_current_active_user,
|
||||||
|
get_current_user,
|
||||||
|
get_tenant_from_token,
|
||||||
|
has_role,
|
||||||
|
verify_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"settings",
|
||||||
|
"Base",
|
||||||
|
"get_core_db",
|
||||||
|
"get_async_core_db",
|
||||||
|
"get_tenant_db",
|
||||||
|
"scoped_core_db",
|
||||||
|
"scoped_async_core_db",
|
||||||
|
"set_rls_context",
|
||||||
|
"init_db",
|
||||||
|
"init_async_db",
|
||||||
|
"verify_token",
|
||||||
|
"get_current_user",
|
||||||
|
"get_current_active_user",
|
||||||
|
"has_role",
|
||||||
|
"get_tenant_from_token",
|
||||||
|
]
|
||||||
126
backend/core/celery_app.py
Normal file
126
backend/core/celery_app.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from celery import Celery
|
||||||
|
from celery.signals import task_postrun, task_prerun
|
||||||
|
|
||||||
|
from core.database import reset_rls_context_tokens, rls_company_var, rls_tenant_var
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
valkey_url = settings.VALKEY_URL
|
||||||
|
print(f"DEBUG: Celery Broker URL: {valkey_url}")
|
||||||
|
logger.info(
|
||||||
|
"Initializing Celery app app_version=%s environment=%s broker=%s",
|
||||||
|
settings.APP_VERSION,
|
||||||
|
settings.ENVIRONMENT,
|
||||||
|
valkey_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configurar broker y backend explícitamente en el constructor
|
||||||
|
celery_app = Celery(
|
||||||
|
"app_tasks",
|
||||||
|
broker=valkey_url,
|
||||||
|
backend=valkey_url,
|
||||||
|
)
|
||||||
|
celery_app.set_default()
|
||||||
|
|
||||||
|
|
||||||
|
_RLS_TOKENS_ATTR = "_rls_context_tokens"
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@task_prerun.connect
|
||||||
|
def _set_rls_context_from_task(task_id=None, task=None, args=None, kwargs=None, **_):
|
||||||
|
"""Fija las ContextVars de RLS para la ejecución de la tarea.
|
||||||
|
|
||||||
|
Las rutas propagan ``tenant_id`` / ``company_id`` vía Celery headers en
|
||||||
|
:func:`track_and_dispatch`. Aquí los materializamos en ContextVars para
|
||||||
|
que cualquier sesión que se abra durante la tarea (incluidos los helpers
|
||||||
|
``scoped_core_db`` y llamadas directas a ``CoreSessionLocal()``) aplique
|
||||||
|
``SET LOCAL`` automáticamente.
|
||||||
|
"""
|
||||||
|
headers = {}
|
||||||
|
request = getattr(task, "request", None) if task is not None else None
|
||||||
|
if request is not None:
|
||||||
|
headers = getattr(request, "headers", None) or {}
|
||||||
|
|
||||||
|
tenant_id = _coerce_int(headers.get("rls_tenant_id"))
|
||||||
|
company_id = _coerce_int(headers.get("rls_company_id"))
|
||||||
|
if tenant_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Celery task_prerun missing rls_tenant_id task=%s task_id=%s headers=%s",
|
||||||
|
getattr(task, "name", "<unknown>"),
|
||||||
|
task_id,
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Celery task_prerun RLS context task=%s task_id=%s tenant_id=%s company_id=%s",
|
||||||
|
getattr(task, "name", "<unknown>"),
|
||||||
|
task_id,
|
||||||
|
tenant_id,
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
token_t = rls_tenant_var.set(tenant_id)
|
||||||
|
token_c = rls_company_var.set(company_id)
|
||||||
|
setattr(task, _RLS_TOKENS_ATTR, (token_t, token_c))
|
||||||
|
|
||||||
|
|
||||||
|
@task_postrun.connect
|
||||||
|
def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
||||||
|
"""Restaura las ContextVars al terminar la tarea (evita fuga entre tareas
|
||||||
|
cuando un worker reutiliza el mismo hilo)."""
|
||||||
|
tokens = getattr(task, _RLS_TOKENS_ATTR, None) if task is not None else None
|
||||||
|
if tokens is None:
|
||||||
|
return
|
||||||
|
token_t, token_c = tokens
|
||||||
|
logger.info(
|
||||||
|
"Celery task_postrun clearing RLS context task=%s task_id=%s tenant_id=%s company_id=%s",
|
||||||
|
getattr(task, "name", "<unknown>"),
|
||||||
|
task_id,
|
||||||
|
rls_tenant_var.get(),
|
||||||
|
rls_company_var.get(),
|
||||||
|
)
|
||||||
|
reset_rls_context_tokens(token_t, token_c)
|
||||||
|
delattr(task, _RLS_TOKENS_ATTR)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
include=[
|
||||||
|
"api.v1.modules.core.help_center.tasks",
|
||||||
|
# Agrega aquí las tareas de tu proyecto:
|
||||||
|
# "api.v1.modules.example.tasks",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuraciones adicionales
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_track_started=True,
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="America/Mexico_City",
|
||||||
|
enable_utc=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"sync-from-hub-every-minute": {
|
||||||
|
"task": "sync_from_hub_task",
|
||||||
|
"schedule": 60.0, # Run every 60 seconds
|
||||||
|
},
|
||||||
|
"cleanup-orphan-layout-imports-hourly": {
|
||||||
|
"task": "cleanup_orphan_layout_imports",
|
||||||
|
"schedule": 3600.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
celery_app.start()
|
||||||
149
backend/core/config.py
Normal file
149
backend/core/config.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Configuración centralizada de la aplicación usando Pydantic Settings
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Literal
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Configuración de la aplicación"""
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME: str = "Mi Aplicación"
|
||||||
|
# Sobreescribible con APP_VERSION (Dockerfile/Jenkins: build-arg + ENV) o entorno en runtime
|
||||||
|
APP_VERSION: str = "dev-local"
|
||||||
|
DEBUG: bool = True
|
||||||
|
ENVIRONMENT: str = "development"
|
||||||
|
|
||||||
|
# Auth local para desarrollo (sin Keycloak/Hub)
|
||||||
|
# Nunca activar en producción.
|
||||||
|
DEV_LOCAL_AUTH: bool = False
|
||||||
|
DEV_LOCAL_AUTH_EMAIL: str = "dev@local.test"
|
||||||
|
DEV_LOCAL_AUTH_NAME: str = "Dev User"
|
||||||
|
DEV_LOCAL_AUTH_TENANT_ID: int = 1
|
||||||
|
DEV_LOCAL_AUTH_COMPANY_ID: int = 1
|
||||||
|
|
||||||
|
# Database - Core (Shared)
|
||||||
|
CORE_DB_HOST: str = "postgres"
|
||||||
|
CORE_DB_PORT: int = 5432
|
||||||
|
CORE_DB_NAME: str = "app_core"
|
||||||
|
CORE_DB_USER: str = "postgres"
|
||||||
|
CORE_DB_PASSWORD: str = "postgres"
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY: str = "change-this-secret-key-in-production"
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
|
# Valkey / Redis
|
||||||
|
VALKEY_URL: str = "redis://valkey:6379/0"
|
||||||
|
PERMISSION_CACHE_ENABLED: bool = True
|
||||||
|
PERMISSION_CACHE_TTL_SECONDS: int = 300
|
||||||
|
|
||||||
|
# Synchronization
|
||||||
|
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||||
|
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||||
|
SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||||
|
HUB_URL: str = "http://localhost:8001"
|
||||||
|
# Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil)
|
||||||
|
HUB_API_BASE_URL: str = ""
|
||||||
|
HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000
|
||||||
|
# Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak)
|
||||||
|
HUB_ADMIN_EMAIL: str = ""
|
||||||
|
HUB_ADMIN_PASSWORD: str = ""
|
||||||
|
|
||||||
|
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
||||||
|
APP_PUBLIC_URL: str = "http://localhost:3000"
|
||||||
|
|
||||||
|
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_quotes(cls, v: str) -> str:
|
||||||
|
if v and isinstance(v, str):
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
# Evitar que solo espacios en .env se conviertan en "/" (rompe httpx: falta protocolo).
|
||||||
|
if not v:
|
||||||
|
return ""
|
||||||
|
if not v.endswith("/"):
|
||||||
|
v += "/"
|
||||||
|
return v
|
||||||
|
return v
|
||||||
|
|
||||||
|
# External APIs
|
||||||
|
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
|
||||||
|
COVE_API_URL: str = "https://api.vu.aduanasoft.com"
|
||||||
|
COVE_API_VERIFY_SSL: bool = False
|
||||||
|
COVE_FIEL_HASH_KEY: str = ""
|
||||||
|
COVE_FIEL_HASH_IV: str = ""
|
||||||
|
SITAR_API_USER: str = ""
|
||||||
|
SITAR_API_PASSWORD: str = ""
|
||||||
|
# SMTP Email Configuration
|
||||||
|
SMTP_HOST: str = "smtp.gmail.com"
|
||||||
|
SMTP_PORT: int = 587
|
||||||
|
SMTP_USER: str = ""
|
||||||
|
SMTP_PASSWORD: str = ""
|
||||||
|
SMTP_FROM_NAME: str = "Mi Aplicación"
|
||||||
|
SMTP_USE_TLS: bool = True
|
||||||
|
|
||||||
|
# CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis
|
||||||
|
CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio"
|
||||||
|
S3_ENDPOINT_URL: str = "http://minio:9000"
|
||||||
|
S3_ACCESS_KEY: str = ""
|
||||||
|
S3_SECRET_KEY: str = ""
|
||||||
|
S3_BUCKET: str = "app"
|
||||||
|
S3_REGION: str = "us-east-1"
|
||||||
|
S3_USE_SSL: bool = False
|
||||||
|
# Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis)
|
||||||
|
S3_FILE_STORAGE: bool = True
|
||||||
|
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=[".env", "../.env"],
|
||||||
|
case_sensitive=True,
|
||||||
|
extra="ignore",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def core_database_url(self) -> str:
|
||||||
|
"""URL de conexión a la base de datos core"""
|
||||||
|
return f"postgresql://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def async_core_database_url(self) -> str:
|
||||||
|
"""URL de conexión asíncrona a la base de datos core"""
|
||||||
|
return f"postgresql+asyncpg://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins_list(self) -> List[str]:
|
||||||
|
"""Lista de orígenes CORS permitidos"""
|
||||||
|
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def use_s3_object_storage(self) -> bool:
|
||||||
|
"""
|
||||||
|
Usar MinIO para logos, certificados y Help (mismo bucket que CSV).
|
||||||
|
True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo.
|
||||||
|
"""
|
||||||
|
return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hub_api_base_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Base URL para endpoints /v1 del Workspace/Hub.
|
||||||
|
Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api.
|
||||||
|
"""
|
||||||
|
custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/")
|
||||||
|
if custom:
|
||||||
|
return custom
|
||||||
|
return f"{self.HUB_URL.rstrip('/')}/api"
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia global de configuración
|
||||||
|
settings = Settings()
|
||||||
10
backend/core/context.py
Normal file
10
backend/core/context.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None)
|
||||||
|
|
||||||
|
def get_user_context() -> Optional[Dict[str, Any]]:
|
||||||
|
return _user_context.get()
|
||||||
|
|
||||||
|
def set_user_context(user: Dict[str, Any]) -> None:
|
||||||
|
_user_context.set(user)
|
||||||
278
backend/core/database.py
Normal file
278
backend/core/database.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""
|
||||||
|
Configuración de base de datos con soporte multi-tenant
|
||||||
|
- Base de datos compartida (core_db) para tenants pequeños/medianos
|
||||||
|
- Bases de datos dedicadas para clientes enterprise
|
||||||
|
|
||||||
|
Row-Level Security (RLS):
|
||||||
|
Para respetar el aislamiento por tenant/company definido en PostgreSQL,
|
||||||
|
cada sesión fija las GUCs ``app.tenant_id`` y ``app.company_id`` vía
|
||||||
|
``SET LOCAL`` al inicio de cada transacción. El listener
|
||||||
|
``after_begin`` aplica el contexto guardado en ``Session.info``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import AsyncGenerator, Dict, Generator, Optional
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy import create_engine, event, text
|
||||||
|
from sqlalchemy.exc import ProgrammingError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
core_engine = create_engine(
|
||||||
|
settings.core_database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
CoreSessionLocal = sessionmaker(
|
||||||
|
autocommit=False, autoflush=False, bind=core_engine)
|
||||||
|
|
||||||
|
async_core_engine = create_async_engine(
|
||||||
|
settings.async_core_database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
echo=settings.DEBUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncCoreSessionLocal = async_sessionmaker(
|
||||||
|
async_core_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
_tenant_engines: Dict[str, any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
RLS_TENANT_KEY = "rls_tenant_id"
|
||||||
|
RLS_COMPANY_KEY = "rls_company_id"
|
||||||
|
|
||||||
|
rls_tenant_var: ContextVar[Optional[int]] = ContextVar("rls_tenant_id", default=None)
|
||||||
|
rls_company_var: ContextVar[Optional[int]] = ContextVar("rls_company_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_rls_context(connection, tenant_id: Optional[int], company_id: Optional[int]) -> None:
|
||||||
|
"""Ejecuta ``SET LOCAL`` en la transacción activa para fijar el contexto RLS."""
|
||||||
|
tenant_value = "" if tenant_id is None else str(int(tenant_id))
|
||||||
|
company_value = "" if company_id is None else str(int(company_id))
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT set_config('app.tenant_id', :t, true), "
|
||||||
|
"set_config('app.company_id', :c, true)"
|
||||||
|
),
|
||||||
|
{"t": tenant_value, "c": company_value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_context(session) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
"""Selecciona tenant_id/company_id desde ``session.info`` y, si faltan,
|
||||||
|
desde las ContextVars (usadas por tareas Celery vía task_prerun)."""
|
||||||
|
tenant_id = session.info.get(RLS_TENANT_KEY)
|
||||||
|
company_id = session.info.get(RLS_COMPANY_KEY)
|
||||||
|
if tenant_id is None:
|
||||||
|
tenant_id = rls_tenant_var.get()
|
||||||
|
if company_id is None:
|
||||||
|
company_id = rls_company_var.get()
|
||||||
|
return tenant_id, company_id
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Session, "after_begin")
|
||||||
|
def _after_begin(session: Session, transaction, connection) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Aplica ``SET LOCAL`` en cada transacción nueva.
|
||||||
|
|
||||||
|
Cubre sesiones síncronas y asíncronas porque ``AsyncSession`` envuelve
|
||||||
|
internamente una ``Session`` que hereda de esta clase.
|
||||||
|
"""
|
||||||
|
tenant_id, company_id = _resolve_context(session)
|
||||||
|
if tenant_id is None and company_id is None:
|
||||||
|
return
|
||||||
|
_apply_rls_context(connection, tenant_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
def set_rls_context(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: Optional[int] = None,
|
||||||
|
company_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Guarda el contexto RLS en la sesión y, si hay transacción abierta, lo aplica.
|
||||||
|
|
||||||
|
Útil para endpoints que validan acceso a una compañía específica después de
|
||||||
|
crear la sesión (por ejemplo rutas que reciben ``company_id`` en el path).
|
||||||
|
"""
|
||||||
|
session.info[RLS_TENANT_KEY] = tenant_id
|
||||||
|
session.info[RLS_COMPANY_KEY] = company_id
|
||||||
|
if session.in_transaction():
|
||||||
|
_apply_rls_context(session.connection(), tenant_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_rls_context_tokens(token_t, token_c) -> None:
|
||||||
|
"""Restaura ContextVars de RLS de forma segura entre hilos/tareas asyncio.
|
||||||
|
|
||||||
|
``ContextVar.reset`` exige que el token se cree y restaure en el mismo contexto
|
||||||
|
lógico; en rutas FastAPI async + dependencias síncronas con ``yield`` (thread
|
||||||
|
pool) el ``finally`` puede ejecutarse en otro contexto y lanzar ``ValueError``
|
||||||
|
(mensaje: "was created in a different Context"). En ese caso degradamos a
|
||||||
|
``set(None)``, igual que ``task_postrun`` en ``core/celery_app.py``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rls_tenant_var.reset(token_t)
|
||||||
|
rls_company_var.reset(token_c)
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
rls_tenant_var.set(None)
|
||||||
|
rls_company_var.set(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_rls_context(request: Optional[Request]) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
"""Recupera ``tenant_id`` / ``company_id`` del estado del request (o de cookies)."""
|
||||||
|
if request is None:
|
||||||
|
return None, None
|
||||||
|
tenant_id = getattr(request.state, "tenant_id", None)
|
||||||
|
company_id = getattr(request.state, "company_id", None)
|
||||||
|
if company_id is None:
|
||||||
|
cookie_value = request.cookies.get("active_company_id")
|
||||||
|
if cookie_value:
|
||||||
|
try:
|
||||||
|
company_id = int(cookie_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
company_id = None
|
||||||
|
return tenant_id, company_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_core_db(request: Request = None) -> Generator[Session, None, None]:
|
||||||
|
"""Dependency para obtener sesión síncrona con contexto RLS.
|
||||||
|
|
||||||
|
FastAPI inyecta ``Request`` automáticamente; los llamadores existentes que
|
||||||
|
escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin
|
||||||
|
cambios porque ``Request`` se resuelve en la capa de dependencia.
|
||||||
|
|
||||||
|
No se escriben las ContextVars de RLS aquí: las dependencias síncronas con
|
||||||
|
``yield`` se ejecutan vía ``contextmanager_in_threadpool`` (hilo worker) y
|
||||||
|
mezclar ``ContextVar.set`` / ``reset`` entre ese hilo y el bucle asyncio
|
||||||
|
provoca ``ValueError: ... was created in a different Context``. El aislamiento
|
||||||
|
RLS se aplica con ``session.info`` (véase ``after_begin`` y audit listeners).
|
||||||
|
"""
|
||||||
|
tenant_id, company_id = _extract_rls_context(request)
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
db.info[RLS_TENANT_KEY] = tenant_id
|
||||||
|
db.info[RLS_COMPANY_KEY] = company_id
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Dependency async para obtener sesión con contexto RLS."""
|
||||||
|
tenant_id, company_id = _extract_rls_context(request)
|
||||||
|
prev_tenant = rls_tenant_var.get()
|
||||||
|
prev_company = rls_company_var.get()
|
||||||
|
rls_tenant_var.set(tenant_id)
|
||||||
|
rls_company_var.set(company_id)
|
||||||
|
try:
|
||||||
|
async with AsyncCoreSessionLocal() as session:
|
||||||
|
session.info[RLS_TENANT_KEY] = tenant_id
|
||||||
|
session.info[RLS_COMPANY_KEY] = company_id
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
finally:
|
||||||
|
rls_tenant_var.set(prev_tenant)
|
||||||
|
rls_company_var.set(prev_company)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def scoped_core_db(
|
||||||
|
tenant_id: Optional[int] = None,
|
||||||
|
company_id: Optional[int] = None,
|
||||||
|
) -> Generator[Session, None, None]:
|
||||||
|
"""Abre una sesión síncrona con contexto RLS explícito.
|
||||||
|
|
||||||
|
Pensado para tareas Celery, comandos de mantenimiento o cualquier camino
|
||||||
|
fuera del ciclo de request HTTP. El contexto se aplica con ``SET LOCAL``
|
||||||
|
en cada transacción.
|
||||||
|
"""
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
db.info[RLS_TENANT_KEY] = tenant_id
|
||||||
|
db.info[RLS_COMPANY_KEY] = company_id
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def scoped_async_core_db(
|
||||||
|
tenant_id: Optional[int] = None,
|
||||||
|
company_id: Optional[int] = None,
|
||||||
|
) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Variante async de :func:`scoped_core_db`."""
|
||||||
|
async with AsyncCoreSessionLocal() as session:
|
||||||
|
session.info[RLS_TENANT_KEY] = tenant_id
|
||||||
|
session.info[RLS_COMPANY_KEY] = company_id
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_engine(tenant_id: int, db_config: dict):
|
||||||
|
"""Obtiene o crea un engine para un tenant con BD dedicada."""
|
||||||
|
if tenant_id not in _tenant_engines:
|
||||||
|
db_url = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['name']}"
|
||||||
|
_tenant_engines[tenant_id] = create_engine(
|
||||||
|
db_url, pool_pre_ping=True, pool_size=5, max_overflow=10
|
||||||
|
)
|
||||||
|
return _tenant_engines[tenant_id]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_tenant_db(
|
||||||
|
tenant_id: int, db_config: Optional[dict] = None
|
||||||
|
) -> Generator[Session, None, None]:
|
||||||
|
"""Context manager para obtener sesión de BD de un tenant específico.
|
||||||
|
|
||||||
|
Si ``db_config`` es ``None`` usa la BD core compartida y aplica RLS con
|
||||||
|
el ``tenant_id`` recibido. Si el tenant tiene BD dedicada, el aislamiento
|
||||||
|
es físico y no se fija contexto RLS (no hay columna ``tenant_id``).
|
||||||
|
"""
|
||||||
|
if db_config is None:
|
||||||
|
db = CoreSessionLocal()
|
||||||
|
db.info[RLS_TENANT_KEY] = tenant_id
|
||||||
|
else:
|
||||||
|
engine = get_tenant_engine(tenant_id, db_config)
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
autocommit=False, autoflush=False, bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Inicializa las tablas de la base de datos core."""
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=core_engine, checkfirst=True)
|
||||||
|
except ProgrammingError as e:
|
||||||
|
if "already exists" in str(e):
|
||||||
|
logger.warning(
|
||||||
|
f"Algunas tablas ya existen en la base de datos: {e}")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def init_async_db():
|
||||||
|
"""Inicializa las tablas de la base de datos core (async)."""
|
||||||
|
async with async_core_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
118
backend/core/email.py
Normal file
118
backend/core/email.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Email service for sending reports via SMTP.
|
||||||
|
"""
|
||||||
|
import aiosmtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email import encoders
|
||||||
|
from typing import List
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
"""Service for sending emails with attachments."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def send_report_email(
|
||||||
|
recipient_email: str,
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
csv_content: str,
|
||||||
|
filename: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send a report email with CSV attachment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipient_email: Email address of recipient
|
||||||
|
subject: Email subject line
|
||||||
|
body_text: Plain text email body
|
||||||
|
csv_content: CSV file content as string
|
||||||
|
filename: Name for the CSV attachment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if email sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create message
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||||
|
msg['To'] = recipient_email
|
||||||
|
msg['Subject'] = subject
|
||||||
|
|
||||||
|
# Email body
|
||||||
|
html_body = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">
|
||||||
|
Reporte de Facturas
|
||||||
|
</h2>
|
||||||
|
<p>{body_text}</p>
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
El reporte se encuentra adjunto en formato CSV.
|
||||||
|
</p>
|
||||||
|
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="font-size: 12px; color: #6b7280;">
|
||||||
|
Este es un correo generado automáticamente. Por favor no responder.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 12px; color: #6b7280;">
|
||||||
|
Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
msg.attach(MIMEText(html_body, 'html'))
|
||||||
|
|
||||||
|
# CSV attachment with UTF-8 BOM for Excel compatibility
|
||||||
|
attachment = MIMEBase('text', 'csv')
|
||||||
|
csv_bytes = b'\xef\xbb\xbf' + csv_content.encode('utf-8')
|
||||||
|
attachment.set_payload(csv_bytes)
|
||||||
|
encoders.encode_base64(attachment)
|
||||||
|
attachment.add_header(
|
||||||
|
'Content-Disposition',
|
||||||
|
f'attachment; filename="{filename}"'
|
||||||
|
)
|
||||||
|
msg.attach(attachment)
|
||||||
|
|
||||||
|
# Create SSL context that ignores certificate errors
|
||||||
|
import ssl
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
if settings.SMTP_PORT == 465:
|
||||||
|
# Port 465 uses implicit SSL
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.SMTP_HOST,
|
||||||
|
port=settings.SMTP_PORT,
|
||||||
|
use_tls=True, # Implicit SSL
|
||||||
|
tls_context=context
|
||||||
|
) as smtp:
|
||||||
|
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
await smtp.send_message(msg)
|
||||||
|
else:
|
||||||
|
# Port 587 uses STARTTLS
|
||||||
|
async with aiosmtplib.SMTP(
|
||||||
|
hostname=settings.SMTP_HOST,
|
||||||
|
port=settings.SMTP_PORT,
|
||||||
|
tls_context=context
|
||||||
|
) as smtp:
|
||||||
|
await smtp.starttls(tls_context=context)
|
||||||
|
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
await smtp.send_message(msg)
|
||||||
|
|
||||||
|
logger.info(f"Email sent successfully to {recipient_email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email to {recipient_email}: {str(e)}")
|
||||||
|
return False
|
||||||
357
backend/core/error_handlers.py
Normal file
357
backend/core/error_handlers.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"""
|
||||||
|
Manejadores globales de excepciones para FastAPI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import Request, status, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .exceptions import BaseAPIException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _cors_headers(request: Request) -> Dict[str, str]:
|
||||||
|
"""CORS headers for error responses so browser does not block on 4xx/5xx."""
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if not origin or origin not in settings.cors_origins_list:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": origin,
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def base_exception_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: BaseAPIException,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para todas las excepciones personalizadas de la API
|
||||||
|
"""
|
||||||
|
logger.warning(
|
||||||
|
f"API Exception: {exc.error_code} - {exc.message}",
|
||||||
|
extra={
|
||||||
|
"path": request.url.path,
|
||||||
|
"method": request.method,
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log detailed errors if they exist
|
||||||
|
if hasattr(exc, "errors") and exc.errors:
|
||||||
|
logger.warning(f"Validation errors details: {exc.errors}")
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=jsonable_encoder(exc.to_dict()),
|
||||||
|
)
|
||||||
|
for k, v in _cors_headers(request).items():
|
||||||
|
response.headers[k] = v
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Mapa de campos técnicos a nombres legibles en español
|
||||||
|
_FIELD_LABELS: Dict[str, str] = {
|
||||||
|
"broker_key": "Clave del Agente",
|
||||||
|
"license": "Patente",
|
||||||
|
"tax_id": "RFC",
|
||||||
|
"personal_id": "CURP",
|
||||||
|
"email": "Correo Electrónico",
|
||||||
|
"phone": "Teléfono",
|
||||||
|
"fax": "Fax",
|
||||||
|
"contact": "Nombre de Contacto",
|
||||||
|
"name": "Nombre / Razón Social",
|
||||||
|
"address": "Dirección",
|
||||||
|
"postal_code": "Código Postal",
|
||||||
|
"city": "Ciudad",
|
||||||
|
"state": "Estado",
|
||||||
|
"country": "País",
|
||||||
|
# Partes (A76)
|
||||||
|
"unit_cost": "Costo Unitario",
|
||||||
|
"unit_weight": "Peso Unitario",
|
||||||
|
"sector": "Sector",
|
||||||
|
"fraction_type": "Tipo de tarifa",
|
||||||
|
}
|
||||||
|
|
||||||
|
_FIELD_PATTERN_MESSAGES: Dict[str, str] = {
|
||||||
|
"broker_key": "La Clave del Agente solo puede contener letras y números (máx. 5 caracteres).",
|
||||||
|
"license": "La Patente debe ser un número entre 1 y 9999 (no puede ser 0 ni contener letras).",
|
||||||
|
"tax_id": "El RFC no tiene el formato correcto. Ejemplo válido: XAXX010101000.",
|
||||||
|
"personal_id": "La CURP no tiene el formato correcto. Debe tener 18 caracteres alfanuméricos.",
|
||||||
|
"email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.",
|
||||||
|
"phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).",
|
||||||
|
"contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.",
|
||||||
|
# Partes (A76)
|
||||||
|
"sector": "El Sector solo puede contener números, máximo 8 dígitos (sin espacios ni caracteres especiales).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _friendly_message(field_key: str, error_type: str) -> str:
|
||||||
|
"""Devuelve un mensaje de error legible en español según el campo y tipo de error."""
|
||||||
|
if error_type in ("greater_than_equal",):
|
||||||
|
if field_key in ("unit_cost", "unit_weight"):
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' no puede ser negativo."
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser mayor o igual a 0."
|
||||||
|
if error_type in ("string_pattern_mismatch", "value_error"):
|
||||||
|
return _FIELD_PATTERN_MESSAGES.get(
|
||||||
|
field_key,
|
||||||
|
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.",
|
||||||
|
)
|
||||||
|
if error_type in ("decimal_parsing", "decimal_type", "float_parsing", "float_type", "int_parsing", "int_type"):
|
||||||
|
return (
|
||||||
|
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser numérico. "
|
||||||
|
"Si no aplica, déjelo vacío."
|
||||||
|
)
|
||||||
|
if error_type in ("literal_error",):
|
||||||
|
if field_key == "fraction_type":
|
||||||
|
return "El campo 'Tipo de tarifa' es inválido. Seleccione una opción predefinida."
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene una opción inválida."
|
||||||
|
if error_type == "string_too_long":
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida."
|
||||||
|
if error_type == "string_too_short":
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es demasiado corto."
|
||||||
|
if error_type in ("missing", "value_error.missing"):
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es obligatorio."
|
||||||
|
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor inválido."
|
||||||
|
|
||||||
|
|
||||||
|
async def validation_exception_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: RequestValidationError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para errores de validación de Pydantic/FastAPI.
|
||||||
|
Devuelve mensajes legibles en español.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
|
||||||
|
field = ".".join(loc_parts)
|
||||||
|
field_key = loc_parts[-1] if loc_parts else ""
|
||||||
|
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"field": field,
|
||||||
|
"message": _friendly_message(field_key, error["type"]),
|
||||||
|
"type": error["type"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"DEBUG REQUEST VALIDATION ERRORS: {errors}")
|
||||||
|
logger.warning(
|
||||||
|
f"Validation Error en {request.url.path}",
|
||||||
|
extra={"errors": errors},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
errors[0]["message"]
|
||||||
|
if len(errors) == 1
|
||||||
|
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
content={
|
||||||
|
"error": "VALIDATION_ERROR",
|
||||||
|
"message": summary,
|
||||||
|
"status_code": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
"errors": errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for k, v in _cors_headers(request).items():
|
||||||
|
response.headers[k] = v
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
async def inner_validation_exception_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: ValidationError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes).
|
||||||
|
"""
|
||||||
|
traceback.print_exc()
|
||||||
|
errors = []
|
||||||
|
for error in exc.errors():
|
||||||
|
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
|
||||||
|
field = ".".join(loc_parts)
|
||||||
|
field_key = loc_parts[-1] if loc_parts else ""
|
||||||
|
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"field": field,
|
||||||
|
"message": _friendly_message(field_key, error["type"]),
|
||||||
|
"type": error["type"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"DEBUG VALIDATION ERRORS: {errors}")
|
||||||
|
logger.warning(
|
||||||
|
f"Inner Validation Error en {request.url.path}",
|
||||||
|
extra={"errors": errors},
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
errors[0]["message"]
|
||||||
|
if len(errors) == 1
|
||||||
|
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
content={
|
||||||
|
"error": "VALIDATION_ERROR",
|
||||||
|
"message": summary,
|
||||||
|
"status_code": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
"errors": errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for k, v in _cors_headers(request).items():
|
||||||
|
response.headers[k] = v
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def integrity_error_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: IntegrityError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para errores de integridad de la base de datos
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
f"Database Integrity Error: {str(exc.orig)}",
|
||||||
|
extra={
|
||||||
|
"path": request.url.path,
|
||||||
|
"method": request.method,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
orig_msg = str(exc.orig).lower()
|
||||||
|
|
||||||
|
# Check for unique/duplicate key violations (English and Spanish)
|
||||||
|
if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]):
|
||||||
|
error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)."
|
||||||
|
# Check for foreign key violations (English and Spanish)
|
||||||
|
elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]):
|
||||||
|
error_message = "Referencia inválida a otro registro. Verifica las categorías y catálogos seleccionados."
|
||||||
|
# Check for not null violations (English and Spanish)
|
||||||
|
elif any(kw in orig_msg for kw in ["not null", "no nulo", "valor nulo"]):
|
||||||
|
error_message = "Falta un campo requerido. Asegúrate de llenar todos los datos obligatorios."
|
||||||
|
else:
|
||||||
|
error_message = "Error de integridad en la base de datos"
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
content={
|
||||||
|
"error": "DATABASE_INTEGRITY_ERROR",
|
||||||
|
"message": error_message,
|
||||||
|
"status_code": status.HTTP_409_CONFLICT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for k, v in _cors_headers(request).items():
|
||||||
|
response.headers[k] = v
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def sqlalchemy_error_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: SQLAlchemyError,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para errores generales de SQLAlchemy
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
f"Database Error: {str(exc)}",
|
||||||
|
extra={
|
||||||
|
"path": request.url.path,
|
||||||
|
"method": request.method,
|
||||||
|
},
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"error": "DATABASE_ERROR",
|
||||||
|
"message": f"Error en la operación de base de datos: {str(exc)}",
|
||||||
|
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for k, v in _cors_headers(request).items():
|
||||||
|
response.headers[k] = v
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def http_exception_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: HTTPException,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para HTTPException de FastAPI
|
||||||
|
"""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"error": "HTTP_ERROR",
|
||||||
|
"message": exc.detail,
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def general_exception_handler(
|
||||||
|
request: Request,
|
||||||
|
exc: Exception,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Manejador para excepciones no capturadas
|
||||||
|
"""
|
||||||
|
logger.error(
|
||||||
|
f"Unhandled Exception: {str(exc)}",
|
||||||
|
extra={
|
||||||
|
"path": request.url.path,
|
||||||
|
"method": request.method,
|
||||||
|
},
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"error": "INTERNAL_SERVER_ERROR",
|
||||||
|
"message": f"Error interno del servidor: {str(exc)}",
|
||||||
|
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for k, v in _cors_headers(request).items():
|
||||||
|
response.headers[k] = v
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app) -> None:
|
||||||
|
"""
|
||||||
|
Registra todos los manejadores de excepciones en la aplicación FastAPI
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: Instancia de FastAPI
|
||||||
|
"""
|
||||||
|
app.add_exception_handler(BaseAPIException, base_exception_handler)
|
||||||
|
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||||
|
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||||
|
app.add_exception_handler(ValidationError, inner_validation_exception_handler)
|
||||||
|
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
||||||
|
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
|
||||||
|
app.add_exception_handler(Exception, general_exception_handler)
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user