commit edf37b7d97a1731ac3e3c99c5485ab8d564c53bd Author: ARCHITECT Date: Wed Dec 24 10:32:39 2025 +0000 Initial MASON implementation - Data editing workspace - Flask API for data review and completion - Edit items before forwarding to FELDMAN - Receives data from CLARA and incidencias from ALFRED/JARED - Docker deployment on port 5053 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c607d20 --- /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=5053 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e992f19 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . +EXPOSE 5053 +CMD ["gunicorn", "--bind", "0.0.0.0:5053", "--workers", "2", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a426bdf --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# MASON - Data Editing Workspace + +Espacio de edicion para completar y corregir informacion que entra al sistema TZZR. + +## Descripcion + +MASON recibe datos que necesitan revision o edicion: +- Informacion desde CLARA que necesita completarse +- Flujos con incidencias desde ALFRED/JARED + +Una vez editados, los datos se envian a FELDMAN como completados. + +## Endpoints + +| Endpoint | Metodo | Auth | Descripcion | +|----------|--------|------|-------------| +| `/health` | GET | No | Health check | +| `/s-contract` | GET | No | Contrato del servicio | +| `/recibir` | POST | Si | Recibir datos para revision | +| `/pendientes` | GET | Si | Listar items pendientes | +| `/item/` | GET | Si | Ver detalle de item | +| `/item/` | PUT | Si | Editar datos del item | +| `/item//resolver` | POST | Si | Resolver y enviar a FELDMAN | +| `/item//descartar` | POST | Si | Descartar item | +| `/historial` | GET | Si | Items resueltos/descartados | +| `/stats` | GET | Si | Estadisticas | + +## Flujo de Trabajo + +1. Datos llegan via `/recibir` (desde CLARA o routing de ALFRED/JARED) +2. Aparecen en `/pendientes` +3. Se editan con PUT `/item/` +4. Se resuelven con POST `/item//resolver` -> van a FELDMAN +5. O se descartan con POST `/item//descartar` + +## Puerto + +5053 diff --git a/app.py b/app.py new file mode 100644 index 0000000..ce8b42e --- /dev/null +++ b/app.py @@ -0,0 +1,280 @@ +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__) + +H_INSTANCIA = os.environ.get('H_INSTANCIA') +DB_HOST = os.environ.get('DB_HOST', '172.17.0.1') +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', 'corp') +PORT = int(os.environ.get('PORT', 5053)) + +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'}), 401 + return f(*args, **kwargs) + return decorated + +def generate_hash(data): + return hashlib.sha256(f"{data}{datetime.now().isoformat()}".encode()).hexdigest()[:64] + +@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': 'mason', 'version': '1.0.0'}) + except Exception as e: + return jsonify({'status': 'unhealthy', 'error': str(e)}), 500 + +@app.route('/s-contract', methods=['GET']) +def s_contract(): + return jsonify({ + 'service': 'mason', + 'version': '1.0.0', + 'contract_version': 'S-CONTRACT v2.1', + 'description': 'Editing workspace for data with incidencias - review and correct before forwarding', + 'endpoints': { + '/health': {'method': 'GET', 'auth': False}, + '/recibir': {'method': 'POST', 'auth': True, 'desc': 'Receive data with incidencia'}, + '/pendientes': {'method': 'GET', 'auth': True, 'desc': 'List pending items to review'}, + '/item/': {'method': 'GET', 'auth': True, 'desc': 'Get item details'}, + '/item/': {'method': 'PUT', 'auth': True, 'desc': 'Edit item data'}, + '/item//resolver': {'method': 'POST', 'auth': True, 'desc': 'Mark as resolved and forward to FELDMAN'}, + '/item//descartar': {'method': 'POST', 'auth': True, 'desc': 'Discard item'}, + '/historial': {'method': 'GET', 'auth': True, 'desc': 'Resolved/discarded items'}, + '/stats': {'method': 'GET', 'auth': True, 'desc': 'Statistics'} + } + }) + +# Receive data with incidencia (from ALFRED/JARED routing) +@app.route('/recibir', methods=['POST']) +@require_auth +def recibir(): + data = request.get_json() or {} + h_registro = generate_hash(str(data)) + + conn = get_db() + cur = conn.cursor() + cur.execute(''' + INSERT INTO incidencias + (h_incidencia, h_instancia_origen, h_ejecucion, tipo, descripcion, datos, estado) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id, h_incidencia, created_at + ''', ( + h_registro, + data.get('h_instancia_origen', 'unknown'), + data.get('h_ejecucion', ''), + data.get('tipo', 'revision'), + data.get('descripcion', 'Pendiente de revision'), + psycopg2.extras.Json(data), + 'pendiente' + )) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'mensaje': 'Recibido para revision', + 'registro': dict(result) + }) + +# List pending items (need review/editing) +@app.route('/pendientes', methods=['GET']) +@require_auth +def pendientes(): + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT id, h_incidencia, h_instancia_origen, tipo, descripcion, datos, created_at + FROM incidencias + WHERE estado = 'pendiente' + ORDER BY created_at ASC + ''') + items = cur.fetchall() + cur.close() + conn.close() + return jsonify({'pendientes': [dict(i) for i in items], 'count': len(items)}) + +# Get item details +@app.route('/item/', methods=['GET']) +@require_auth +def get_item(item_id): + conn = get_db() + cur = conn.cursor() + cur.execute('SELECT * FROM incidencias WHERE id = %s', (item_id,)) + item = cur.fetchone() + cur.close() + conn.close() + if not item: + return jsonify({'error': 'Item not found'}), 404 + return jsonify({'item': dict(item)}) + +# Edit item data +@app.route('/item/', methods=['PUT']) +@require_auth +def edit_item(item_id): + data = request.get_json() or {} + + conn = get_db() + cur = conn.cursor() + + # Get current item + cur.execute('SELECT * FROM incidencias WHERE id = %s AND estado = %s', (item_id, 'pendiente')) + item = cur.fetchone() + if not item: + cur.close() + conn.close() + return jsonify({'error': 'Item not found or already processed'}), 404 + + # Merge edited data + current_datos = item['datos'] or {} + if 'datos' in data: + current_datos.update(data['datos']) + + cur.execute(''' + UPDATE incidencias + SET datos = %s, descripcion = %s, updated_at = NOW() + WHERE id = %s + RETURNING id, datos, updated_at + ''', ( + psycopg2.extras.Json(current_datos), + data.get('descripcion', item['descripcion']), + item_id + )) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + + return jsonify({'success': True, 'mensaje': 'Item actualizado', 'item': dict(result)}) + +# Resolve and forward to FELDMAN +@app.route('/item//resolver', methods=['POST']) +@require_auth +def resolver_item(item_id): + data = request.get_json() or {} + + conn = get_db() + cur = conn.cursor() + + cur.execute('SELECT * FROM incidencias WHERE id = %s AND estado = %s', (item_id, 'pendiente')) + item = cur.fetchone() + if not item: + cur.close() + conn.close() + return jsonify({'error': 'Item not found or already processed'}), 404 + + # Mark as resolved + cur.execute(''' + UPDATE incidencias + SET estado = 'resuelto', resolucion = %s, resolved_at = NOW(), updated_at = NOW() + WHERE id = %s + RETURNING id, h_incidencia, estado, resolved_at + ''', (data.get('resolucion', 'Corregido manualmente'), item_id)) + result = cur.fetchone() + + # Insert into completados (FELDMAN table) with corrected data + h_completado = generate_hash(str(item['datos'])) + cur.execute(''' + INSERT INTO completados + (h_completado, h_instancia_origen, h_ejecucion, flujo_nombre, datos, notas) + VALUES (%s, %s, %s, %s, %s, %s) + ''', ( + h_completado, + item['h_instancia_origen'], + item['h_ejecucion'], + item['datos'].get('flujo_nombre', ''), + psycopg2.extras.Json(item['datos']), + f"Corregido en MASON: {data.get('resolucion', '')}" + )) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'mensaje': 'Item resuelto y enviado a FELDMAN', + 'item': dict(result) + }) + +# Discard item +@app.route('/item//descartar', methods=['POST']) +@require_auth +def descartar_item(item_id): + data = request.get_json() or {} + + conn = get_db() + cur = conn.cursor() + cur.execute(''' + UPDATE incidencias + SET estado = 'descartado', resolucion = %s, resolved_at = NOW(), updated_at = NOW() + WHERE id = %s AND estado = 'pendiente' + RETURNING id, h_incidencia, estado + ''', (data.get('motivo', 'Descartado'), item_id)) + result = cur.fetchone() + conn.commit() + cur.close() + conn.close() + + if not result: + return jsonify({'error': 'Item not found or already processed'}), 404 + return jsonify({'success': True, 'mensaje': 'Item descartado', 'item': dict(result)}) + +# History of resolved/discarded +@app.route('/historial', methods=['GET']) +@require_auth +def historial(): + limit = request.args.get('limit', 50, type=int) + conn = get_db() + cur = conn.cursor() + cur.execute(''' + SELECT * FROM incidencias + WHERE estado IN ('resuelto', 'descartado') + ORDER BY resolved_at DESC LIMIT %s + ''', (limit,)) + items = cur.fetchall() + cur.close() + conn.close() + return jsonify({'historial': [dict(i) for i in items], 'count': len(items)}) + +# Stats +@app.route('/stats', methods=['GET']) +@require_auth +def stats(): + conn = get_db() + cur = conn.cursor() + cur.execute('SELECT estado, COUNT(*) as count FROM incidencias GROUP BY estado') + por_estado = {r['estado']: r['count'] for r in cur.fetchall()} + cur.execute('SELECT COUNT(*) as total FROM incidencias WHERE estado = %s', ('pendiente',)) + pendientes = cur.fetchone()['total'] + cur.close() + conn.close() + return jsonify({'pendientes': pendientes, 'por_estado': por_estado}) + +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..75283b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3.8" +services: + mason: + build: . + container_name: mason-service + restart: unless-stopped + ports: + - "5053:5053" + 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=5053 + 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..f4adf36 --- /dev/null +++ b/init.sql @@ -0,0 +1,21 @@ +-- MASON tables + +CREATE TABLE IF NOT EXISTS incidencias ( + id BIGSERIAL PRIMARY KEY, + h_incidencia VARCHAR(64) NOT NULL UNIQUE, + h_instancia_origen VARCHAR(64) NOT NULL, + h_ejecucion VARCHAR(64), + tipo VARCHAR(50) DEFAULT 'general', + severidad VARCHAR(20) DEFAULT 'media', + descripcion TEXT, + datos JSONB DEFAULT '{}', + estado VARCHAR(20) DEFAULT 'pendiente', + asignado_a VARCHAR(100), + resolucion TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + resolved_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_incidencias_estado ON incidencias(estado); +CREATE INDEX IF NOT EXISTS idx_incidencias_origen ON incidencias(h_instancia_origen); 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