From b4bb28639688fc5ccc03e47934778d0ba6263022 Mon Sep 17 00:00:00 2001 From: ARCHITECT Date: Wed, 24 Dec 2025 10:25:10 +0000 Subject: [PATCH] Initial JARED implementation - Predefined flows manager for CORP - Flask API with full CRUD for flows - Execute flow with OK->FELDMAN / incidencia->MASON routing - PostgreSQL integration with host DB - Docker deployment on port 5052 - S-CONTRACT v2.1 compliant --- .env.example | 7 + Dockerfile | 12 ++ README.md | 57 ++++++++ app.py | 347 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 25 ++++ init.sql | 37 +++++ requirements.txt | 3 + 7 files changed, 488 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 init.sql create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8143a56 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +H_INSTANCIA=your_h_instancia_here +DB_HOST=172.17.0.1 +DB_PORT=5432 +DB_NAME=corp +DB_USER=corp +DB_PASSWORD=your_password_here +PORT=5052 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa78720 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5052 + +CMD ["gunicorn", "--bind", "0.0.0.0:5052", "--workers", "2", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ed729c --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# JARED - Predefined Flows Manager (CORP) + +Microservicio para gestionar flujos de trabajo predefinidos en el servidor CORP del ecosistema TZZR. + +## Descripcion + +JARED maneja la creacion, listado y ejecucion de flujos predefinidos. Cada ejecucion se enruta a: +- **FELDMAN** si el flujo termina OK +- **MASON** si hay incidencia + +## Endpoints + +| Endpoint | Metodo | Auth | Descripcion | +|----------|--------|------|-------------| +| `/health` | GET | No | Health check | +| `/s-contract` | GET | No | Contrato del servicio | +| `/flujos` | GET | Si | Listar flujos | +| `/flujos` | POST | Si | Crear flujo | +| `/flujos/` | GET | Si | Obtener flujo | +| `/flujos/` | PUT | Si | Actualizar flujo | +| `/flujos/` | DELETE | Si | Eliminar flujo | +| `/ejecutar/` | POST | Si | Ejecutar flujo | +| `/ejecuciones` | GET | Si | Listar ejecuciones | +| `/stats` | GET | Si | Estadisticas | + +## Autenticacion + +Header `X-Auth-Key` con el h_instancia de CORP. + +## Despliegue + +```bash +cd /opt/jared +docker compose up -d --build +``` + +## Configuracion (.env) + +``` +H_INSTANCIA= +DB_HOST=172.17.0.1 +DB_PORT=5432 +DB_NAME=corp +DB_USER=corp +DB_PASSWORD= +PORT=5052 +``` + +## Base de Datos + +Tablas en PostgreSQL (corp): +- `flujos_predefinidos` - Definiciones de flujos +- `flujo_ejecuciones` - Historial de ejecuciones + +## Puerto + +5052 diff --git a/app.py b/app.py new file mode 100644 index 0000000..7cf3e3f --- /dev/null +++ b/app.py @@ -0,0 +1,347 @@ +from flask import Flask, request, jsonify +from functools import wraps +import psycopg2 +from psycopg2.extras import RealDictCursor +import os +import hashlib +import json +from datetime import datetime + +app = Flask(__name__) + +# Configuration +H_INSTANCIA = os.environ.get('H_INSTANCIA') +DB_HOST = os.environ.get('DB_HOST', 'localhost') +DB_PORT = os.environ.get('DB_PORT', '5432') +DB_NAME = os.environ.get('DB_NAME', 'corp') +DB_USER = os.environ.get('DB_USER', 'corp') +DB_PASSWORD = os.environ.get('DB_PASSWORD', '') +PORT = int(os.environ.get('PORT', 5052)) + +def get_db(): + return psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + cursor_factory=RealDictCursor + ) + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth_key = request.headers.get('X-Auth-Key') + if not auth_key or auth_key != H_INSTANCIA: + return jsonify({'error': 'Unauthorized', 'code': 401}), 401 + return f(*args, **kwargs) + return decorated + +def generate_hash(data): + return hashlib.sha256(f"{data}{datetime.now().isoformat()}".encode()).hexdigest() + +# Health check +@app.route('/health', methods=['GET']) +def health(): + try: + conn = get_db() + cur = conn.cursor() + cur.execute('SELECT 1') + cur.close() + conn.close() + return jsonify({ + 'status': 'healthy', + 'service': 'jared', + 'version': '1.0.0', + 'timestamp': datetime.now().isoformat() + }) + except Exception as e: + return jsonify({ + 'status': 'unhealthy', + 'error': str(e) + }), 500 + +# S-CONTRACT endpoint +@app.route('/s-contract', methods=['GET']) +def s_contract(): + return jsonify({ + 'service': 'jared', + 'version': '1.0.0', + 'contract_version': 'S-CONTRACT v2.1', + 'endpoints': { + '/health': {'method': 'GET', 'auth': False, 'desc': 'Health check'}, + '/flujos': {'method': 'GET', 'auth': True, 'desc': 'List predefined flows'}, + '/flujos': {'method': 'POST', 'auth': True, 'desc': 'Create flow'}, + '/flujos/': {'method': 'GET', 'auth': True, 'desc': 'Get flow'}, + '/flujos/': {'method': 'PUT', 'auth': True, 'desc': 'Update flow'}, + '/flujos/': {'method': 'DELETE', 'auth': True, 'desc': 'Delete flow'}, + '/ejecutar/': {'method': 'POST', 'auth': True, 'desc': 'Execute flow'}, + '/ejecuciones': {'method': 'GET', 'auth': True, 'desc': 'List executions'}, + '/stats': {'method': 'GET', 'auth': True, 'desc': 'Statistics'} + }, + 'auth': 'X-Auth-Key header with h_instancia' + }) + +# List flows +@app.route('/flujos', methods=['GET']) +@require_auth +def list_flujos(): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT id, nombre, descripcion, pasos, campos_fijos, campos_variables, activo, created_at + FROM flujos_predefinidos + WHERE h_instancia = %s + ORDER BY created_at DESC + ''', (H_INSTANCIA,)) + flujos = cur.fetchall() + cur.close() + conn.close() + return jsonify({'flujos': [dict(f) for f in flujos], 'count': len(flujos)}) + +# Create flow +@app.route('/flujos', methods=['POST']) +@require_auth +def create_flujo(): + data = request.get_json() + if not data or 'nombre' not in data or 'pasos' not in data: + return jsonify({'error': 'nombre and pasos required'}), 400 + + flujo_id = generate_hash(data['nombre'])[:64] + + conn = get_db() + cur = conn.cursor() + try: + cur.execute(''' + INSERT INTO flujos_predefinidos + (id, h_instancia, nombre, descripcion, pasos, campos_fijos, campos_variables) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, nombre, created_at + ''', ( + flujo_id, + H_INSTANCIA, + data['nombre'], + data.get('descripcion', ''), + json.dumps(data['pasos']), + json.dumps(data.get('campos_fijos', {})), + json.dumps(data.get('campos_variables', [])) + )) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + return jsonify({'success': True, 'flujo': dict(result)}), 201 + except psycopg2.IntegrityError: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': 'Flow already exists'}), 409 + +# Get flow +@app.route('/flujos/', methods=['GET']) +@require_auth +def get_flujo(flujo_id): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT * FROM flujos_predefinidos + WHERE id = %s AND h_instancia = %s + ''', (flujo_id, H_INSTANCIA)) + flujo = cur.fetchone() + cur.close() + conn.close() + if not flujo: + return jsonify({'error': 'Flow not found'}), 404 + return jsonify({'flujo': dict(flujo)}) + +# Update flow +@app.route('/flujos/', methods=['PUT']) +@require_auth +def update_flujo(flujo_id): + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + conn = get_db() + cur = conn.cursor() + + updates = [] + values = [] + if 'nombre' in data: + updates.append('nombre = %s') + values.append(data['nombre']) + if 'descripcion' in data: + updates.append('descripcion = %s') + values.append(data['descripcion']) + if 'pasos' in data: + updates.append('pasos = %s') + values.append(json.dumps(data['pasos'])) + if 'campos_fijos' in data: + updates.append('campos_fijos = %s') + values.append(json.dumps(data['campos_fijos'])) + if 'campos_variables' in data: + updates.append('campos_variables = %s') + values.append(json.dumps(data['campos_variables'])) + if 'activo' in data: + updates.append('activo = %s') + values.append(data['activo']) + + updates.append('updated_at = NOW()') + values.extend([flujo_id, H_INSTANCIA]) + + cur.execute(f''' + UPDATE flujos_predefinidos + SET {', '.join(updates)} + WHERE id = %s AND h_instancia = %s + RETURNING id, nombre, updated_at + ''', values) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + + if not result: + return jsonify({'error': 'Flow not found'}), 404 + return jsonify({'success': True, 'flujo': dict(result)}) + +# Delete flow +@app.route('/flujos/', methods=['DELETE']) +@require_auth +def delete_flujo(flujo_id): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + DELETE FROM flujos_predefinidos + WHERE id = %s AND h_instancia = %s + RETURNING id + ''', (flujo_id, H_INSTANCIA)) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + if not result: + return jsonify({'error': 'Flow not found'}), 404 + return jsonify({'success': True, 'deleted': flujo_id}) + +# Execute flow +@app.route('/ejecutar/', methods=['POST']) +@require_auth +def ejecutar_flujo(flujo_id): + data = request.get_json() or {} + + conn = get_db() + cur = conn.cursor() + + # Get flow + cur.execute(''' + SELECT * FROM flujos_predefinidos + WHERE id = %s AND h_instancia = %s AND activo = true + ''', (flujo_id, H_INSTANCIA)) + flujo = cur.fetchone() + + if not flujo: + cur.close() + conn.close() + return jsonify({'error': 'Flow not found or inactive'}), 404 + + # Determine estado and destino + hay_incidencia = data.get('incidencia', False) + estado = 'incidencia' if hay_incidencia else 'ok' + destino = 'mason' if hay_incidencia else 'feldman' + + h_ejecucion = generate_hash(f"{flujo_id}{json.dumps(data)}")[:64] + + # Merge campos_fijos with provided data + datos_completos = {**flujo['campos_fijos'], **data} + + cur.execute(''' + INSERT INTO flujo_ejecuciones + (h_flujo, h_instancia, h_ejecucion, datos, estado, destino, notas) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, h_ejecucion, estado, destino, created_at + ''', ( + flujo_id, + H_INSTANCIA, + h_ejecucion, + json.dumps(datos_completos), + estado, + destino, + data.get('notas', '') + )) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'ejecucion': dict(result), + 'flujo_nombre': flujo['nombre'], + 'routing': { + 'estado': estado, + 'destino': destino, + 'mensaje': f"Enviando a {destino.upper()}" + (" por incidencia" if hay_incidencia else " (OK)") + } + }) + +# List executions +@app.route('/ejecuciones', methods=['GET']) +@require_auth +def list_ejecuciones(): + limit = request.args.get('limit', 50, type=int) + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT e.*, f.nombre as flujo_nombre + FROM flujo_ejecuciones e + LEFT JOIN flujos_predefinidos f ON e.h_flujo = f.id + WHERE e.h_instancia = %s + ORDER BY e.created_at DESC + LIMIT %s + ''', (H_INSTANCIA, limit)) + ejecuciones = cur.fetchall() + cur.close() + conn.close() + return jsonify({'ejecuciones': [dict(e) for e in ejecuciones], 'count': len(ejecuciones)}) + +# Stats +@app.route('/stats', methods=['GET']) +@require_auth +def stats(): + conn = get_db() + cur = conn.cursor() + + cur.execute('SELECT COUNT(*) as total FROM flujos_predefinidos WHERE h_instancia = %s', (H_INSTANCIA,)) + total_flujos = cur.fetchone()['total'] + + cur.execute('SELECT COUNT(*) as total FROM flujo_ejecuciones WHERE h_instancia = %s', (H_INSTANCIA,)) + total_ejecuciones = cur.fetchone()['total'] + + cur.execute(''' + SELECT estado, COUNT(*) as count + FROM flujo_ejecuciones + WHERE h_instancia = %s + GROUP BY estado + ''', (H_INSTANCIA,)) + por_estado = {r['estado']: r['count'] for r in cur.fetchall()} + + cur.execute(''' + SELECT destino, COUNT(*) as count + FROM flujo_ejecuciones + WHERE h_instancia = %s + GROUP BY destino + ''', (H_INSTANCIA,)) + por_destino = {r['destino']: r['count'] for r in cur.fetchall()} + + cur.close() + conn.close() + + return jsonify({ + 'flujos_totales': total_flujos, + 'ejecuciones_totales': total_ejecuciones, + 'por_estado': por_estado, + 'por_destino': por_destino + }) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=PORT, debug=False) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5eb6cb4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: "3.8" + +services: + jared: + build: . + container_name: jared-service + restart: unless-stopped + ports: + - "5052:5052" + environment: + - H_INSTANCIA=${H_INSTANCIA} + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT} + - DB_NAME=${DB_NAME} + - DB_USER=${DB_USER} + - DB_PASSWORD=${DB_PASSWORD} + - PORT=5052 + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - tzzr-network + +networks: + tzzr-network: + external: true diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..853bba8 --- /dev/null +++ b/init.sql @@ -0,0 +1,37 @@ +-- JARED database tables for CORP + +CREATE TABLE IF NOT EXISTS flujos_predefinidos ( + id VARCHAR(64) PRIMARY KEY, + h_instancia VARCHAR(64) NOT NULL, + nombre VARCHAR(100) NOT NULL, + descripcion TEXT, + pasos JSONB NOT NULL, + campos_fijos JSONB DEFAULT '{}', + campos_variables JSONB DEFAULT '[]', + activo BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS flujo_ejecuciones ( + id BIGSERIAL PRIMARY KEY, + h_flujo VARCHAR(64) REFERENCES flujos_predefinidos(id), + h_instancia VARCHAR(64) NOT NULL, + h_ejecucion VARCHAR(64) NOT NULL UNIQUE, + datos JSONB NOT NULL, + estado VARCHAR(20) DEFAULT 'ok', + destino VARCHAR(20) DEFAULT 'feldman', + notas TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_flujos_h_instancia ON flujos_predefinidos(h_instancia); +CREATE INDEX IF NOT EXISTS idx_ejecuciones_h_instancia ON flujo_ejecuciones(h_instancia); +CREATE INDEX IF NOT EXISTS idx_ejecuciones_estado ON flujo_ejecuciones(estado); +CREATE INDEX IF NOT EXISTS idx_ejecuciones_destino ON flujo_ejecuciones(destino); + +-- Permissions (run as postgres superuser) +-- GRANT ALL PRIVILEGES ON TABLE flujos_predefinidos TO corp; +-- GRANT ALL PRIVILEGES ON TABLE flujo_ejecuciones TO corp; +-- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO corp; diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..efddf1d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.0 +psycopg2-binary==2.9.9 +gunicorn==21.2.0