import json
import os
from datetime import datetime
import psycopg2
import psycopg2.extras
from flask import Flask, Response, redirect, render_template_string, request, url_for
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
PG_CONFIG = {
"host": getenv("PGHOST", required=True),
"port": int(getenv("PGPORT", "5432")),
"dbname": getenv("PGDATABASE", required=True),
"user": getenv("PGUSER", required=True),
"password": getenv("PGPASSWORD", required=True),
"connect_timeout": 5,
}
APP_TITLE = getenv("APP_TITLE", "MESAVAULT Edge-OEE - Consola de paros UCEPSA")
DEFAULT_MACHINE_ID = getenv("DEFAULT_MACHINE_ID", "CORT-01")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
AUTO_REFRESH_SECONDS = int(getenv("AUTO_REFRESH_SECONDS", "5"))
ALLOWED_MACHINES = [
item.strip()
for item in getenv("ALLOWED_MACHINES", DEFAULT_MACHINE_ID).split(",")
if item.strip()
]
def resolve_machine_id(raw_value):
machine_id = (raw_value or DEFAULT_MACHINE_ID).strip()
if machine_id not in ALLOWED_MACHINES:
return DEFAULT_MACHINE_ID
return machine_id
app = Flask(__name__)
def get_conn():
return psycopg2.connect(**PG_CONFIG)
def fetch_pending_stops(machine_id):
sql = """
SELECT
id,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
started_at,
ended_at,
status,
classification_status,
cause_code,
cause_name,
operator_note,
duration_min,
cost_eur
FROM mv_hot.edge_oee_stops_recent
WHERE classification_status = 'PENDING'
AND machine_id = %(machine_id)s
ORDER BY started_at DESC
LIMIT 50;
"""
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql, {"machine_id": machine_id})
return cur.fetchall()
def fetch_causes():
sql = """
SELECT
cause_code,
cause_name,
description,
is_planned
FROM mv_hot.edge_oee_stop_cause_options
ORDER BY sort_order, cause_name;
"""
with get_conn() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(sql)
return cur.fetchall()
def classify_stop(stop_id, machine_id, cause_code, operator_note):
sql = """
UPDATE mv_hot.edge_oee_machine_stops
SET
cause_code = %(cause_code)s,
operator_note = %(operator_note)s,
classification_status = 'CLASSIFIED',
updated_at = now()
WHERE id = %(stop_id)s
AND machine_id = %(machine_id)s
AND classification_status = 'PENDING';
"""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
sql,
{
"stop_id": stop_id,
"machine_id": machine_id,
"cause_code": cause_code,
"operator_note": operator_note,
},
)
return cur.rowcount
def fmt_dt(value):
if not value:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value)
def fmt_num(value, decimals=2):
if value is None:
return "0"
try:
return f"{float(value):.{decimals}f}"
except Exception:
return str(value)
HTML = """
{{ app_title }}
Paros pendientes
{{ pending_count }}
Máquina monitorizada
{{ selected_machine }}
{{ default_asset }}
{% if stops %}
{% else %}
No hay paros pendientes para {{ selected_machine }}.
{% endif %}
"""
@app.route("/", methods=["GET"])
def index():
selected_machine = resolve_machine_id(request.args.get("machine_id"))
stops = fetch_pending_stops(selected_machine)
causes = fetch_causes()
return render_template_string(
HTML,
app_title=APP_TITLE,
stops=stops,
causes=causes,
pending_count=len(stops),
selected_machine=selected_machine,
allowed_machines=ALLOWED_MACHINES,
default_machine=selected_machine,
default_asset=DEFAULT_ASSET,
auto_refresh_seconds=AUTO_REFRESH_SECONDS,
loaded_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
fmt_dt=fmt_dt,
fmt_num=fmt_num,
)
@app.route("/classify/", methods=["POST"])
def classify(stop_id):
selected_machine = resolve_machine_id(
request.form.get("machine_id") or request.args.get("machine_id")
)
cause_code = (request.form.get("cause_code") or "").strip()
operator_note = (request.form.get("operator_note") or "").strip()
if cause_code:
classify_stop(
stop_id=stop_id,
machine_id=selected_machine,
cause_code=cause_code,
operator_note=operator_note,
)
return redirect(url_for("index", machine_id=selected_machine))
@app.route("/health", methods=["GET"])
def health():
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1;")
cur.fetchone()
return {"status": "ok"}, 200
except Exception as exc:
return {"status": "error", "error": str(exc)}, 500
@app.route("/manifest.webmanifest", methods=["GET"])
def manifest():
selected_machine = resolve_machine_id(request.args.get("machine_id"))
payload = {
"name": f"MESAVAULT Edge-OEE {selected_machine}",
"short_name": selected_machine,
"description": "Consola de paros MESAVAULT Edge-OEE para UCEPSA",
"start_url": url_for("index", machine_id=selected_machine),
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"icons": [
{
"src": url_for("pwa_icon"),
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable",
}
],
}
return Response(
json.dumps(payload, ensure_ascii=False),
mimetype="application/manifest+json",
)
@app.route("/sw.js", methods=["GET"])
def service_worker():
js = """
self.addEventListener("install", function (event) {
self.skipWaiting();
});
self.addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", function (event) {
event.respondWith(fetch(event.request));
});
"""
return Response(
js,
mimetype="application/javascript",
headers={"Cache-Control": "no-cache"},
)
@app.route("/pwa-icon.svg", methods=["GET"])
def pwa_icon():
svg = """
"""
return Response(svg, mimetype="image/svg+xml")
if __name__ == "__main__":
port = int(getenv("APP_PORT", "8080"))
app.run(host="0.0.0.0", port=port)