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
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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
|
||||
7
Dockerfile
Normal file
7
Dockerfile
Normal file
@@ -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"]
|
||||
38
README.md
Normal file
38
README.md
Normal file
@@ -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/<id>` | GET | Si | Ver detalle de item |
|
||||
| `/item/<id>` | PUT | Si | Editar datos del item |
|
||||
| `/item/<id>/resolver` | POST | Si | Resolver y enviar a FELDMAN |
|
||||
| `/item/<id>/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/<id>`
|
||||
4. Se resuelven con POST `/item/<id>/resolver` -> van a FELDMAN
|
||||
5. O se descartan con POST `/item/<id>/descartar`
|
||||
|
||||
## Puerto
|
||||
|
||||
5053
|
||||
280
app.py
Normal file
280
app.py
Normal file
@@ -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/<id>': {'method': 'GET', 'auth': True, 'desc': 'Get item details'},
|
||||
'/item/<id>': {'method': 'PUT', 'auth': True, 'desc': 'Edit item data'},
|
||||
'/item/<id>/resolver': {'method': 'POST', 'auth': True, 'desc': 'Mark as resolved and forward to FELDMAN'},
|
||||
'/item/<id>/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/<int:item_id>', 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/<int:item_id>', 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/<int:item_id>/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/<int:item_id>/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)
|
||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -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
|
||||
21
init.sql
Normal file
21
init.sql
Normal file
@@ -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);
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask==3.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
gunicorn==21.2.0
|
||||
Reference in New Issue
Block a user