Commit c0b5a635 by Lucía Elizo Gómez

Plugin comparar

parent 9c0758dd
"""
Kadi4Mat plugin: Comparar sesiones
------------------------------------
Permite comparar métricas de varios records de sesión de impresión 3D
mediante gráficas interactivas.
Hooks:
- kadi_get_blueprints → página /comparar y API
- kadi_get_resource_overview_templates → botón en cada record
- kadi_post_app_initialization → log de arranque
Endpoints:
GET /comparar/ → página principal con gráficas
GET /api/comparar/record/<id> → devuelve métricas de un record
GET /api/comparar/buscar?q=texto → busca records por título
"""
from flask import request, render_template
from kadi.lib.api.blueprint import bp as api_bp
from kadi.lib.api.core import json_response, json_error_response
from kadi.lib.plugins.core import PluginBlueprint
from kadi.plugins import hookimpl
PLUGIN_NAME = "comparar"
# ── Blueprint ─────────────────────────────────────────────────────────────────
plugin_bp = PluginBlueprint(
PLUGIN_NAME,
__name__,
url_prefix="/comparar",
template_folder="templates",
)
# ── Métricas a comparar ───────────────────────────────────────────────────────
METRICAS_COMPARAR = [
("hotend_temp_max_c", "Hotend temp. máx (°C)"),
("hotend_temp_media_c", "Hotend temp. media (°C)"),
("cama_temp_max_c", "Cama temp. máx (°C)"),
("duracion_impresion_s", "Duración impresión (s)"),
("filamento_usado_mm", "Filamento usado (mm)"),
("cpu_media_pct", "CPU media (%)"),
("pi_temp_max_c", "Temp. Pi máx (°C)"),
]
# ── Helpers ───────────────────────────────────────────────────────────────────
def _extraer_metricas(record) -> dict:
"""Extrae las métricas relevantes de los extras de un record de Kadi4Mat."""
resultado = {
"id": record.id,
"title": record.title,
}
extras = {e.key: e.value for e in (record.extras or [])}
for key, _ in METRICAS_COMPARAR:
val = extras.get(key)
try:
resultado[key] = float(val) if val is not None else None
except (ValueError, TypeError):
resultado[key] = None
return resultado
# ── Endpoints ─────────────────────────────────────────────────────────────────
@plugin_bp.get("/")
def pagina_comparar():
"""Página principal con las gráficas comparativas."""
return render_template("comparar/comparar.html", metricas=METRICAS_COMPARAR)
@api_bp.get("/comparar/record/<int:record_id>")
def get_record_metricas(record_id):
"""Devuelve las métricas de un record para usarlas en las gráficas."""
from kadi.modules.records.models import Record
record = Record.query.get(record_id)
if record is None:
return json_error_response(404, description="Record no encontrado.")
return json_response(200, _extraer_metricas(record))
@api_bp.get("/comparar/buscar")
def buscar_records():
"""Busca records por título para el autocompletado. ?q=texto"""
from kadi.modules.records.models import Record
q = request.args.get("q", "").strip()
if not q:
return json_response(200, {"items": []})
records = (
Record.query
.filter(Record.title.ilike(f"%{q}%"))
.limit(10)
.all()
)
items = [{"id": r.id, "title": r.title, "identifier": r.identifier}
for r in records]
return json_response(200, {"items": items})
# ── Botón en la página de cada record ────────────────────────────────────────
BOTON_TEMPLATE = """
<div style="margin-top: 1rem;">
<a href="/comparar/?add={{ resource.id }}"
style="
display: inline-block;
padding: 0.4rem 1rem;
background: #1976d2;
color: white;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
">
📊 Añadir a comparación
</a>
</div>
"""
# ── Hooks ─────────────────────────────────────────────────────────────────────
@hookimpl
def kadi_get_blueprints():
return plugin_bp
@hookimpl
def kadi_get_resource_overview_templates(resource):
"""Añade el botón en la página de cada record."""
from flask import render_template_string
from kadi.modules.records.models import Record
if not isinstance(resource, Record):
return None
return render_template_string(BOTON_TEMPLATE, resource=resource)
@hookimpl
def kadi_post_app_initialization(app):
app.logger.info(
"comparar: plugin activo. "
"Página: GET /comparar/ | "
"API: GET /api/comparar/record/<id>"
)
Metadata-Version: 2.4
Name: comparar
Version: 0.1.0
Summary: Plugin de comparación de sesiones de impresión 3D para Kadi4Mat
Requires-Python: >=3.10
Requires-Dist: kadi
pyproject.toml
setup.py
comparar.egg-info/PKG-INFO
comparar.egg-info/SOURCES.txt
comparar.egg-info/dependency_links.txt
comparar.egg-info/entry_points.txt
comparar.egg-info/requires.txt
comparar.egg-info/top_level.txt
kadi_plugin_comparar/__init__.py
kadi_plugin_comparar/plugin.py
kadi_plugin_comparar/templates/comparar/comparar.html
\ No newline at end of file
[kadi_plugins]
comparar = kadi_plugin_comparar.plugin
"""
Kadi4Mat plugin: Comparar sesiones
------------------------------------
Permite comparar metricas de varios records de sesion de impresión 3D
mediante gráficas interactivas.
Hooks:
- kadi_get_blueprints → página /comparar y API
- kadi_get_resource_overview_templates → botón en cada record
- kadi_post_app_initialization → log de arranque
Endpoints:
GET /comparar/ → página principal con gráficas
GET /api/comparar/record/<id> → devuelve métricas de un record
GET /api/comparar/buscar?q=texto → busca records por título
"""
from flask import request, render_template
from kadi.lib.api.blueprint import bp as api_bp
from kadi.lib.api.core import json_response, json_error_response
from kadi.lib.plugins.core import PluginBlueprint
from kadi.plugins import hookimpl
PLUGIN_NAME = "comparar"
# ── Blueprint ─────────────────────────────────────────────────────────────────
plugin_bp = PluginBlueprint(
PLUGIN_NAME,
__name__,
url_prefix="/comparar",
template_folder="templates",
)
# ── Métricas a comparar ───────────────────────────────────────────────────────
METRICAS_COMPARAR = [
("hotend_temp_max_c", "Hotend temp. máx (°C)"),
("hotend_temp_media_c", "Hotend temp. media (°C)"),
("cama_temp_max_c", "Cama temp. máx (°C)"),
("duracion_impresion_s", "Duración impresión (s)"),
("filamento_usado_mm", "Filamento usado (mm)"),
("cpu_media_pct", "CPU media (%)"),
("pi_temp_max_c", "Temp. Pi máx (°C)"),
]
# ── Helpers ───────────────────────────────────────────────────────────────────
def _extraer_metricas(record) -> dict:
"""Extrae las métricas relevantes de los extras de un record de Kadi4Mat."""
resultado = {
"id": record.id,
"title": record.title,
}
extras = {e["key"]: e["value"] for e in (record.extras or [])}
for key, _ in METRICAS_COMPARAR:
val = extras.get(key)
try:
resultado[key] = float(val) if val is not None else None
except (ValueError, TypeError):
resultado[key] = None
return resultado
# ── Endpoints ─────────────────────────────────────────────────────────────────
@plugin_bp.get("/")
def pagina_comparar():
"""Página principal con las gráficas comparativas."""
return render_template("comparar/comparar.html", metricas=METRICAS_COMPARAR)
@api_bp.get("/comparar/record/<int:record_id>")
def get_record_metricas(record_id):
"""Devuelve las métricas de un record para usarlas en las gráficas."""
from kadi.modules.records.models import Record
record = Record.query.get(record_id)
if record is None:
return json_error_response(404, description="Record no encontrado.")
return json_response(200, _extraer_metricas(record))
@api_bp.get("/comparar/buscar")
def buscar_records():
"""Busca records por título para el autocompletado. ?q=texto"""
from kadi.modules.records.models import Record
q = request.args.get("q", "").strip()
if not q:
return json_response(200, {"items": []})
records = (
Record.query
.filter(Record.title.ilike(f"%{q}%"))
.limit(10)
.all()
)
items = [{"id": r.id, "title": r.title, "identifier": r.identifier}
for r in records]
return json_response(200, {"items": items})
# ── Botón en la página de cada record ────────────────────────────────────────
BOTON_TEMPLATE = """
<div style="margin-bottom: 1rem;">
<a href="/comparar/?add={{ resource.id }}"
style="
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 1rem;
background: #2c3e50;
color: white;
border-radius: 4px;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
">
📊 Añadir a comparación
</a>
</div>
"""
# ── Hooks ─────────────────────────────────────────────────────────────────────
@hookimpl
def kadi_get_content_security_policies():
"""Permite cargar Chart.js desde cdnjs y ejecutar scripts inline."""
return {
"script-src": "https://cdnjs.cloudflare.com",
}
@hookimpl
def kadi_get_blueprints():
return plugin_bp
@hookimpl
def kadi_get_resource_overview_templates(resource):
"""Añade el botón en la página de cada record."""
from flask import render_template_string
from kadi.modules.records.models import Record
if not isinstance(resource, Record):
return None
return render_template_string(BOTON_TEMPLATE, resource=resource)
@hookimpl
def kadi_post_app_initialization(app):
app.logger.info(
"comparar: plugin activo. "
"Página: GET /comparar/ | "
"API: GET /api/comparar/record/<id>"
)
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Comparar sesiones — Kadi4Mat</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" nonce="{{ csp_nonce() }}"></script>
<style>
body { font-family: sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #222; }
h1 { font-size: 1.6rem; margin-bottom: 0.5rem; }
.buscador { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.buscador input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; }
.buscador button { padding: 0.5rem 1.2rem; background: #1976d2; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.sugerencias { border: 1px solid #ccc; border-radius: 4px; max-height: 200px; overflow-y: auto; display: none; position: absolute; background: white; width: 100%; z-index: 10; }
.sugerencia { padding: 0.5rem 1rem; cursor: pointer; }
.sugerencia:hover { background: #f0f4ff; }
.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
.chip { background: #e3f0ff; border-radius: 20px; padding: 0.3rem 0.8rem; font-size: 0.9rem; display: flex; align-items: center; gap: 0.4rem; }
.chip button { background: none; border: none; cursor: pointer; font-size: 1rem; color: #555; }
.graficas { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.grafica { background: #fafafa; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; }
canvas { max-height: 250px; }
.vacio { color: #888; font-style: italic; }
@media (max-width: 700px) { .graficas { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>📊 Comparar sesiones de impresión</h1>
<button id="btn-volver"
style="
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 1rem;
background: #2c3e50;
color: white;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 1rem;
">
← Volver
</button>
<p class="vacio" id="ayuda">Busca y añade records para comparar sus métricas.</p>
<!-- Buscador -->
<div class="buscador">
<div style="position: relative; flex: 1;">
<input id="input-buscar" type="text"
placeholder="Buscar record por título..." autocomplete="off">
<div class="sugerencias" id="sugerencias"></div>
</div>
<button id="btn-añadir-id">Añadir por ID</button>
</div>
<!-- Chips: records seleccionados -->
<div class="chips" id="chips"></div>
<!-- Gráficas -->
<div class="graficas" id="graficas"></div>
<script nonce="{{ csp_nonce() }}">
// ── Estado ───────────────────────────────────────────────────────────────
const records = {};
const colores = ["#1976d2","#e53935","#43a047","#fb8c00","#8e24aa","#00897b"];
const metricas = {{ metricas | tojson }};
const charts = {};
// ── Al cargar: leer ?add=ID de la URL ────────────────────────────────────
const params = new URLSearchParams(window.location.search);
if (params.get("add")) cargarRecord(parseInt(params.get("add")));
// ── Buscador ──────────────────────────────────────────────────────────────
const inputBuscar = document.getElementById("input-buscar");
const divSugerencias = document.getElementById("sugerencias");
inputBuscar.addEventListener("input", async () => {
const q = inputBuscar.value.trim();
if (q.length < 2) { divSugerencias.style.display = "none"; return; }
const res = await fetch(`/api/comparar/buscar?q=${encodeURIComponent(q)}`);
const data = await res.json();
divSugerencias.innerHTML = "";
if (!data.items.length) { divSugerencias.style.display = "none"; return; }
data.items.forEach(r => {
const div = document.createElement("div");
div.className = "sugerencia";
div.textContent = `${r.title} (ID: ${r.id})`;
div.onclick = () => {
cargarRecord(r.id);
inputBuscar.value = "";
divSugerencias.style.display = "none";
};
divSugerencias.appendChild(div);
});
divSugerencias.style.display = "block";
});
document.getElementById("btn-volver").addEventListener("click", () => {
history.back();
});
document.getElementById("btn-añadir-id").addEventListener("click", añadirPorId);
document.addEventListener("click", e => {
if (!divSugerencias.contains(e.target) && e.target !== inputBuscar)
divSugerencias.style.display = "none";
});
function añadirPorId() {
const id = parseInt(prompt("Introduce el ID del record:"));
if (id) cargarRecord(id);
}
// ── Cargar record desde la API ────────────────────────────────────────────
async function cargarRecord(id) {
if (records[id]) return;
const res = await fetch(`/api/comparar/record/${id}`);
if (!res.ok) { alert("Record no encontrado."); return; }
records[id] = await res.json();
actualizarChips();
actualizarGraficas();
document.getElementById("ayuda").style.display = "none";
}
function eliminarRecord(id) {
delete records[id];
actualizarChips();
actualizarGraficas();
if (!Object.keys(records).length)
document.getElementById("ayuda").style.display = "";
}
// ── Chips ─────────────────────────────────────────────────────────────────
function actualizarChips() {
const div = document.getElementById("chips");
div.innerHTML = "";
Object.values(records).forEach((r, i) => {
const chip = document.createElement("div");
chip.className = "chip";
const circulo = document.createElement("span");
circulo.style.cssText = `width:12px;height:12px;border-radius:50%;background:${colores[i % colores.length]};display:inline-block;`;
const texto = document.createElement("span");
texto.textContent = r.title;
const boton = document.createElement("button");
boton.textContent = "✕";
boton.title = "Quitar";
boton.addEventListener("click", () => eliminarRecord(r.id));
chip.appendChild(circulo);
chip.appendChild(texto);
chip.appendChild(boton);
div.appendChild(chip);
});
}
// ── Gráficas ──────────────────────────────────────────────────────────────
function actualizarGraficas() {
const ids = Object.keys(records);
metricas.forEach(([key, label]) => {
const valores = ids.map(id => records[id][key]);
const tieneValores = valores.some(v => v !== null && v !== undefined);
if (!tieneValores) {
if (charts[key])
document.getElementById(`wrap-${key}`).style.display = "none";
return;
}
if (!charts[key]) {
const wrap = document.createElement("div");
wrap.className = "grafica";
wrap.id = `wrap-${key}`;
const canvas = document.createElement("canvas");
canvas.id = `chart-${key}`;
wrap.appendChild(canvas);
document.getElementById("graficas").appendChild(wrap);
charts[key] = new Chart(canvas, {
type: "bar",
data: { labels: [], datasets: [] },
options: {
responsive: true,
plugins: {
legend: { display: false },
title: { display: true, text: label, font: { size: 14 } },
},
scales: { y: { beginAtZero: true } },
},
});
}
const chart = charts[key];
chart.data.labels = ids.map(id => records[id].title);
chart.data.datasets = [{
data: valores.map(v => v ?? 0),
backgroundColor: ids.map((_, i) => colores[i % colores.length] + "cc"),
borderColor: ids.map((_, i) => colores[i % colores.length]),
borderWidth: 1,
}];
chart.update();
document.getElementById(`wrap-${key}`).style.display = "";
});
}
</script>
</body>
</html>
[project]
name = "comparar"
version = "0.1.0"
description = "Plugin de comparación de sesiones de impresión 3D para Kadi4Mat"
requires-python = ">=3.10"
dependencies = ["kadi"]
[project.entry-points."kadi_plugins"]
comparar = "kadi_plugin_comparar.plugin"
from setuptools import setup, find_packages
setup(
name="comparar",
version="0.1.0",
packages=find_packages(),
package_data={
"kadi_plugin_comparar": ["templates/**/*"],
},
entry_points={
"kadi_plugins": [
"comparar = kadi_plugin_comparar.plugin",
],
},
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment