Agregar selector de equipos Teams y filtro Graph por group_id.

Incluye API /api/groups, cache por equipo en el servidor y correccion del volumen config en docker-compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
juan.pelaez 2026-06-18 13:21:06 -05:00
parent 7fb305ab2b
commit a5a2559c6d
3 changed files with 308 additions and 56 deletions

View file

@ -56,6 +56,7 @@ DEFAULT_ACTA_CONFIG: dict = {
}
_CLIENT_GONE_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)
_ALL_TEAMS_KEY = "__all__"
def load_acta_config() -> dict:
@ -154,6 +155,30 @@ def schedule_open_browser(port: int) -> None:
threading.Thread(target=_open, daemon=True).start()
def _team_cache_key(group_id: str | None) -> str:
gid = (group_id or "").strip()
return gid if gid else _ALL_TEAMS_KEY
def fetch_teams_list() -> list[dict[str, str]]:
"""Lista equipos de Teams con Planner (Graph). Vacio si la fuente no lo soporta."""
service = create_data_service()
list_fn = getattr(service, "get_groups_with_planner", None)
if not callable(list_fn):
return []
groups = list_fn()
teams = [
{
"id": str(g.get("id", "")).strip(),
"name": str(g.get("displayName") or g.get("id") or "").strip(),
}
for g in groups
if g.get("id")
]
teams.sort(key=lambda t: t["name"].casefold())
return teams
class DashboardCache:
def __init__(
self,
@ -167,9 +192,11 @@ class DashboardCache:
self._on_updated = on_updated
self._data_lock = threading.Lock()
self._refresh_lock = threading.Lock()
self._refreshing = False
self._cached_at = 0.0
self._cached_tsv = ""
self._refreshing_keys: set[str] = set()
self._tsv_entries: dict[str, tuple[str, float]] = {}
self._teams: list[dict[str, str]] = []
self._teams_cached_at = 0.0
self._teams_ttl = max(ttl_seconds * 6, 60)
self._last_error = ""
self._last_background_at = 0.0
@ -180,88 +207,127 @@ class DashboardCache:
except Exception:
pass
def _fetch_fresh(self) -> str:
def _fetch_fresh_tsv(self, group_id: str | None = None) -> str:
service = create_data_service()
projects = service.get_all_projects()
gid = (group_id or "").strip()
group_ids = [gid] if gid else None
projects = service.get_all_projects(group_ids=group_ids)
return projects_to_tsv(projects)
def _cache_age(self) -> float:
if not self._cached_tsv:
def _entry_age(self, key: str) -> float:
entry = self._tsv_entries.get(key)
if not entry:
return float("inf")
return time.time() - self._cached_at
return time.time() - entry[1]
def _is_stale(self) -> bool:
if not self._cached_tsv:
return True
return self._cache_age() >= self.ttl_seconds
def _is_stale(self, key: str) -> bool:
return key not in self._tsv_entries or self._entry_age(key) >= self.ttl_seconds
def _should_background_refresh(self) -> bool:
if self._refreshing:
def _get_cached_tsv(self, key: str) -> str:
entry = self._tsv_entries.get(key)
return entry[0] if entry else ""
def _should_background_refresh(self, key: str) -> bool:
if key in self._refreshing_keys:
return False
since_bg = time.time() - self._last_background_at
if since_bg < self.min_background_seconds:
return False
return self._is_stale()
return self._is_stale(key)
def _start_background_refresh(self) -> None:
def _start_background_refresh(self, group_id: str | None = None) -> None:
key = _team_cache_key(group_id)
with self._refresh_lock:
if self._refreshing:
if key in self._refreshing_keys:
return
self._refreshing = True
self._refreshing_keys.add(key)
self._last_background_at = time.time()
def _run() -> None:
try:
self._refresh()
self._refresh(group_id)
finally:
with self._refresh_lock:
self._refreshing = False
self._refreshing_keys.discard(key)
threading.Thread(target=_run, daemon=True).start()
def _refresh(self) -> tuple[str, bool]:
def _refresh_stale_entries(self) -> None:
with self._data_lock:
keys = list(self._tsv_entries.keys())
for key in keys:
if self._is_stale(key):
gid = None if key == _ALL_TEAMS_KEY else key
self._start_background_refresh(gid)
def _refresh(self, group_id: str | None = None) -> tuple[str, bool]:
key = _team_cache_key(group_id)
try:
fresh = self._fetch_fresh()
fresh = self._fetch_fresh_tsv(group_id)
except Exception as exc:
self._last_error = str(exc)
with self._data_lock:
if self._cached_tsv:
return self._cached_tsv, True
cached = self._get_cached_tsv(key)
if cached:
return cached, True
raise
with self._data_lock:
self._cached_tsv = fresh
self._cached_at = time.time()
self._tsv_entries[key] = (fresh, time.time())
self._last_error = ""
self._notify_updated()
return self._cached_tsv, False
return fresh, False
def get_tsv(self, *, force: bool = False, group_id: str | None = None) -> tuple[str, bool]:
"""Devuelve (tsv, from_cache) para un equipo o todos (__all__)."""
key = _team_cache_key(group_id)
if force:
with self._refresh_lock:
if key in self._refreshing_keys:
cached = self._get_cached_tsv(key)
if cached:
return cached, True
refreshed, _ = self._refresh(group_id)
return refreshed, False
def get_tsv(self, *, force: bool = False) -> tuple[str, bool]:
"""Devuelve (tsv, from_cache). Sirve cache mientras Power Automate responde."""
with self._data_lock:
cached = self._cached_tsv
stale = self._is_stale()
cached = self._get_cached_tsv(key)
stale = self._is_stale(key)
if force or stale:
if self._should_background_refresh():
self._start_background_refresh()
if stale and cached:
if self._should_background_refresh(key):
self._start_background_refresh(group_id)
return cached, True
if cached and not stale:
return cached, True
if cached:
return cached, True
if self._should_background_refresh(key):
self._start_background_refresh(group_id)
with self._refresh_lock:
already_refreshing = self._refreshing
if already_refreshing:
refreshing = key in self._refreshing_keys
if refreshing:
deadline = time.time() + 5
while time.time() < deadline:
with self._data_lock:
if self._cached_tsv:
return self._cached_tsv, True
cached = self._get_cached_tsv(key)
if cached:
return cached, True
time.sleep(0.2)
return self._refresh()
return self._refresh(group_id)
def get_teams(self, *, force: bool = False) -> list[dict[str, str]]:
with self._data_lock:
teams = list(self._teams)
age = time.time() - self._teams_cached_at
if teams and not force and age < self._teams_ttl:
return teams
fresh = fetch_teams_list()
with self._data_lock:
self._teams = fresh
self._teams_cached_at = time.time()
return list(fresh)
@property
def last_error(self) -> str:
@ -280,7 +346,8 @@ def create_handler(
def refresh_and_notify() -> None:
try:
cache._last_background_at = 0.0
cache._start_background_refresh()
cache._start_background_refresh(None)
cache._refresh_stale_entries()
print("[WEBHOOK] Actualizacion desde Planner programada.", flush=True)
except Exception as exc:
event_hub.broadcast("planner-error", json.dumps({"error": str(exc)}))
@ -316,6 +383,10 @@ def create_handler(
self._serve_dashboard_tsv(parsed)
return
if route == "/api/groups":
self._serve_groups(parsed)
return
if route == "/api/health":
self._serve_health()
return
@ -392,12 +463,14 @@ def create_handler(
self._write_body(body)
def _serve_dashboard_tsv(self, parsed) -> None:
force = "refresh" in parse_qs(parsed.query)
query = parse_qs(parsed.query)
force = "refresh" in query
group_id = query.get("group_id", [""])[0].strip() or None
try:
tsv, from_cache = cache.get_tsv(force=force)
tsv, from_cache = cache.get_tsv(force=force, group_id=group_id)
if not tsv.strip():
raise RuntimeError(
"Sin datos en cache. Power Automate aun no respondio; reintenta en unos segundos."
"Sin datos en cache. Planner aun no respondio; reintenta en unos segundos."
)
body = tsv.encode("utf-8")
cache_status = "HIT" if from_cache else "MISS"
@ -406,6 +479,8 @@ def create_handler(
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Cache", cache_status)
if group_id:
self.send_header("X-Group-Id", group_id)
self.end_headers()
self._write_body(body)
except _CLIENT_GONE_ERRORS:
@ -422,6 +497,35 @@ def create_handler(
except _CLIENT_GONE_ERRORS:
pass
def _serve_groups(self, parsed) -> None:
query = parse_qs(parsed.query)
force = "refresh" in query
try:
teams = cache.get_teams(force=force)
payload = {"teams": teams}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self._write_body(body)
except _CLIENT_GONE_ERRORS:
pass
except Exception as exc:
body = json.dumps(
{"teams": [], "error": str(exc)},
ensure_ascii=False,
).encode("utf-8")
try:
self.send_response(502)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self._write_body(body)
except _CLIENT_GONE_ERRORS:
pass
def _serve_health(self) -> None:
payload = {
"ok": True,
@ -587,17 +691,19 @@ def run_server(
def _warm_cache() -> None:
print("[...] Cargando datos iniciales de Planner (puede tardar 1-3 min)...", flush=True)
key = _ALL_TEAMS_KEY
with cache._refresh_lock:
cache._refreshing = True
cache._refreshing_keys.add(key)
try:
cache._last_background_at = 0.0
cache._refresh()
cache._refresh(None)
cache.get_teams()
print("[OK] Cache inicial de Planner listo.", flush=True)
except Exception as exc:
print(f"[AVISO] Cache inicial no disponible: {exc}", flush=True)
finally:
with cache._refresh_lock:
cache._refreshing = False
cache._refreshing_keys.discard(key)
threading.Thread(target=_warm_cache, daemon=True).start()
@ -605,7 +711,7 @@ def run_server(
def _periodic_sync() -> None:
while True:
time.sleep(DASHBOARD_REFRESH_SECONDS)
cache._start_background_refresh()
cache._refresh_stale_entries()
threading.Thread(target=_periodic_sync, daemon=True).start()

View file

@ -11,6 +11,6 @@ services:
DASHBOARD_PORT: "8765"
DASHBOARD_OPEN_BROWSER: "false"
volumes:
- ./config/acta_config.json:/app/config/acta_config.json
- ./config:/app/config
- ./output:/app/output
restart: unless-stopped

View file

@ -471,6 +471,48 @@
.modal-status { font-size: 0.85rem; margin-top: 8px; min-height: 1.2em; }
.modal-status.ok { color: var(--success); }
.modal-status.err { color: var(--danger); }
.team-selector-wrap {
width: 100%;
margin-bottom: 14px;
padding: 0 4px;
}
.team-selector-label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgba(255,255,255,0.65);
margin-bottom: 6px;
font-weight: 600;
}
.team-selector {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
background: rgba(0,0,0,0.2);
color: white;
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
box-sizing: border-box;
}
.team-selector:focus {
outline: none;
border-color: var(--sapian-accent);
box-shadow: 0 0 0 2px rgba(17, 184, 154, 0.25);
}
.team-selector option {
color: #1e293b;
background: #fff;
}
.team-selector-hint {
font-size: 0.72rem;
color: rgba(255,255,255,0.5);
margin-top: 5px;
line-height: 1.3;
}
</style>
</head>
<body>
@ -480,6 +522,14 @@
<img src="logo_sapian_144.png" alt="Sapian Logo" class="sidebar-logo">
<h2>📊 Proyectos RTC</h2>
<div id="teamSelectorWrap" class="team-selector-wrap" style="display:none;">
<label class="team-selector-label" for="teamSelector">Equipo de Microsoft Teams</label>
<select id="teamSelector" class="team-selector" onchange="cambiarEquipo()" aria-label="Seleccionar equipo">
<option value="">Todos los equipos</option>
</select>
<div class="team-selector-hint" id="teamSelectorHint">Filtra planes y tareas de Planner por equipo.</div>
</div>
<button class="btn-action btn-pdf" onclick="generarPDFComite()">
<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
@ -640,6 +690,8 @@
<script src="/config.js"></script>
<script>
const URL_GOOGLE_SHEET = "/api/dashboard.tsv";
const URL_TEAMS = "/api/groups";
const TEAM_STORAGE_KEY = "rtc_selected_team_id";
const USE_EVENTS = window.DASHBOARD_USE_EVENTS !== false;
const REFRESH_MS = window.DASHBOARD_REFRESH_MS || window.DASHBOARD_FALLBACK_MS || 60000;
@ -647,7 +699,9 @@
let fetchPendienteForce = false;
const currentSystemDate = new Date();
let proyectosGlobal = [];
let estadoFiltroActual = 'activos';
let equiposGlobal = [];
let equipoSeleccionadoId = "";
let estadoFiltroActual = 'activos';
let chartEstado = null;
let chartAvance = null;
@ -954,7 +1008,7 @@
}
fetchEnCurso = true;
try {
const url = forceRefresh ? `${URL_GOOGLE_SHEET}?refresh=1` : URL_GOOGLE_SHEET;
const url = construirUrlDashboard(forceRefresh);
const response = await fetch(url);
if (!response.ok) throw new Error("Fallo en la respuesta de red");
const data = await response.text();
@ -977,6 +1031,95 @@
}
}
function construirUrlDashboard(forceRefresh) {
const params = new URLSearchParams();
if (forceRefresh) params.set("refresh", "1");
if (equipoSeleccionadoId) params.set("group_id", equipoSeleccionadoId);
const qs = params.toString();
return qs ? `${URL_GOOGLE_SHEET}?${qs}` : URL_GOOGLE_SHEET;
}
function nombreEquipoActual() {
if (!equipoSeleccionadoId) return "Todos los equipos";
const equipo = equiposGlobal.find(e => e.id === equipoSeleccionadoId);
return equipo ? equipo.name : "Equipo seleccionado";
}
function actualizarEtiquetaEquipo() {
const hint = document.getElementById("teamSelectorHint");
if (!hint) return;
if (!equiposGlobal.length) {
hint.textContent = "";
return;
}
const total = equiposGlobal.length;
if (equipoSeleccionadoId) {
hint.textContent = `Mostrando Planner de: ${nombreEquipoActual()}`;
} else {
hint.textContent = `${total} equipo(s) disponibles · vista consolidada`;
}
}
function renderizarSelectorEquipos() {
const wrap = document.getElementById("teamSelectorWrap");
const select = document.getElementById("teamSelector");
if (!wrap || !select) return;
if (!equiposGlobal.length) {
wrap.style.display = "none";
return;
}
wrap.style.display = "block";
const valorPrevio = equipoSeleccionadoId || localStorage.getItem(TEAM_STORAGE_KEY) || "";
select.innerHTML = '<option value="">Todos los equipos</option>';
equiposGlobal.forEach(equipo => {
const opt = document.createElement("option");
opt.value = equipo.id;
opt.textContent = equipo.name;
select.appendChild(opt);
});
const existe = valorPrevio && equiposGlobal.some(e => e.id === valorPrevio);
equipoSeleccionadoId = existe ? valorPrevio : "";
select.value = equipoSeleccionadoId;
if (equipoSeleccionadoId) {
localStorage.setItem(TEAM_STORAGE_KEY, equipoSeleccionadoId);
} else {
localStorage.removeItem(TEAM_STORAGE_KEY);
}
actualizarEtiquetaEquipo();
}
async function cargarEquipos() {
try {
const res = await fetch(URL_TEAMS, { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.teams)) {
equiposGlobal = data.teams.filter(t => t && t.id);
renderizarSelectorEquipos();
}
} catch (e) {
console.warn("No se pudo cargar la lista de equipos.", e);
}
}
function cambiarEquipo() {
const select = document.getElementById("teamSelector");
if (!select) return;
equipoSeleccionadoId = select.value || "";
if (equipoSeleccionadoId) {
localStorage.setItem(TEAM_STORAGE_KEY, equipoSeleccionadoId);
} else {
localStorage.removeItem(TEAM_STORAGE_KEY);
}
actualizarEtiquetaEquipo();
mostrarVistaGeneral();
document.getElementById('loading').style.display = 'block';
fetchGoogleSheet(true);
}
function normalizarId(id) {
return String(id || "").trim();
}
@ -1022,7 +1165,9 @@
document.getElementById('loading').style.display = 'none';
const opcionesFecha = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute:'2-digit' };
document.getElementById('fechaActualizacion').innerText = "Última actualización: " + new Date().toLocaleDateString('es-ES', opcionesFecha);
const alcance = equiposGlobal.length ? ` · ${nombreEquipoActual()}` : "";
document.getElementById('fechaActualizacion').innerText =
"Última actualización: " + new Date().toLocaleDateString('es-ES', opcionesFecha) + alcance;
renderizarListaProyectos();
renderizarDashboardGeneral();
@ -1385,9 +1530,10 @@
};
}
window.onload = () => {
window.onload = async () => {
cargarConfigActa();
fetchGoogleSheet(false);
await cargarEquipos();
fetchGoogleSheet(equipoSeleccionadoId ? true : false);
conectarEventosPlanner();
if (REFRESH_MS > 0) {
setInterval(() => fetchGoogleSheet(true), REFRESH_MS);