excel-planner/services/planner_service.py
juan.pelaez 27d759ace8 Agregar integracion Excel-Planner con tablero RTC Sapian.
Puente Power Automate, servidor local del tablero HTML v7 y exportacion a Excel/TSV para monitoreo de proyectos en Microsoft Planner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:44:38 -05:00

187 lines
7.1 KiB
Python

from datetime import datetime, timezone
from typing import Any
from auth.graph_client import GraphClient, create_graph_client
from config import AUTH_MODE
def _parse_date(value: str | None) -> datetime | None:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _task_status(percent: int) -> str:
if percent == 100:
return "Completada"
if percent > 0:
return "En progreso"
return "Por hacer"
def _priority_label(priority: int) -> str:
labels = {0: "Urgente", 1: "Importante", 3: "Media", 5: "Baja", 9: "Sin prioridad"}
return labels.get(priority, "Sin prioridad")
class PlannerService:
"""Extrae y normaliza datos de Microsoft Planner."""
def __init__(self, client: GraphClient | None = None) -> None:
self.client = client or create_graph_client()
self._user_cache: dict[str, str] = {}
self._team_cache: dict[str, str] = {}
def _resolve_user(self, user_id: str) -> str:
if user_id in self._user_cache:
return self._user_cache[user_id]
try:
user = self.client.get(f"/users/{user_id}")
name = user.get("displayName") or user.get("mail") or user_id
except Exception:
name = user_id
self._user_cache[user_id] = name
return name
def get_my_plans(self) -> list[dict]:
"""Planes a los que el usuario tiene acceso (modo delegado)."""
return self.client.get_all_pages("/me/planner/plans")
def get_my_teams(self) -> dict[str, str]:
"""Mapea ID de equipo -> nombre (modo delegado, sin admin)."""
if self._team_cache:
return self._team_cache
teams = self.client.get_all_pages("/me/joinedTeams")
self._team_cache = {t["id"]: t.get("displayName", t["id"]) for t in teams}
return self._team_cache
def get_groups_with_planner(self) -> list[dict]:
"""Obtiene grupos con Planner. En modo delegado usa los equipos del usuario."""
if AUTH_MODE == "delegated":
teams = self.get_my_teams()
return [{"id": tid, "displayName": name} for tid, name in teams.items()]
return self.client.get_all_pages(
"/groups",
params={
"$filter": "resourceProvisioningOptions/Any(x:x eq 'Team')",
"$select": "id,displayName,description",
},
)
def get_plans_for_group(self, group_id: str) -> list[dict]:
return self.client.get_all_pages(f"/groups/{group_id}/planner/plans")
def get_buckets_for_plan(self, plan_id: str) -> dict[str, str]:
buckets = self.client.get_all_pages(f"/planner/plans/{plan_id}/buckets")
return {b["id"]: b.get("name", "Sin bucket") for b in buckets}
def get_tasks_for_plan(self, plan_id: str) -> list[dict]:
return self.client.get_all_pages(f"/planner/plans/{plan_id}/tasks")
def get_all_projects(self, group_ids: list[str] | None = None) -> list[dict[str, Any]]:
"""
Extrae todos los planes y sus tareas.
Modo delegado: solo planes a los que el usuario tiene acceso.
Modo aplicacion: todos los grupos de la organizacion.
"""
projects: list[dict[str, Any]] = []
teams = self.get_my_teams() if AUTH_MODE == "delegated" else {}
if AUTH_MODE == "delegated" and not group_ids:
plans = self.get_my_plans()
plan_groups = [{
"plan": plan,
"group_id": plan.get("owner", ""),
"group_name": teams.get(plan.get("owner", ""), "Mi equipo"),
} for plan in plans]
else:
if group_ids:
groups = [{"id": gid, "displayName": gid} for gid in group_ids]
else:
groups = self.get_groups_with_planner()
plan_groups = []
for group in groups:
group_id = group["id"]
group_name = group.get("displayName", group_id)
try:
plans = self.get_plans_for_group(group_id)
except Exception:
continue
for plan in plans:
plan_groups.append({
"plan": plan,
"group_id": group_id,
"group_name": group_name,
})
for item in plan_groups:
plan = item["plan"]
group_name = item["group_name"]
plan_id = plan["id"]
plan_title = plan.get("title", "Sin título")
buckets = self.get_buckets_for_plan(plan_id)
try:
tasks = self.get_tasks_for_plan(plan_id)
except Exception:
tasks = []
normalized_tasks = []
for task in tasks:
percent = task.get("percentComplete", 0)
due = _parse_date(task.get("dueDateTime"))
now = datetime.now(timezone.utc)
assignees = []
for user_id in task.get("assignments", {}):
assignees.append(self._resolve_user(user_id))
normalized_tasks.append({
"grupo": group_name,
"proyecto": plan_title,
"plan_id": plan_id,
"tarea": task.get("title", "Sin título"),
"tarea_id": task["id"],
"bucket": buckets.get(task.get("bucketId", ""), "Sin bucket"),
"estado": _task_status(percent),
"porcentaje": percent,
"prioridad": _priority_label(task.get("priority", 9)),
"fecha_inicio": task.get("startDateTime", ""),
"fecha_vencimiento": task.get("dueDateTime", ""),
"asignados": ", ".join(assignees) if assignees else "Sin asignar",
"vencida": bool(due and due < now and percent < 100),
"creada": task.get("createdDateTime", ""),
"actualizada": task.get("lastModifiedDateTime", ""),
})
completed = sum(1 for t in normalized_tasks if t["porcentaje"] == 100)
overdue = sum(1 for t in normalized_tasks if t["vencida"])
total = len(normalized_tasks)
projects.append({
"grupo": group_name,
"proyecto": plan_title,
"plan_id": plan_id,
"total_tareas": total,
"completadas": completed,
"en_progreso": sum(1 for t in normalized_tasks if 0 < t["porcentaje"] < 100),
"por_hacer": sum(1 for t in normalized_tasks if t["porcentaje"] == 0),
"vencidas": overdue,
"porcentaje_avance": round((completed / total) * 100, 1) if total else 0,
"tareas": normalized_tasks,
})
return projects
def get_flat_tasks(self, projects: list[dict]) -> list[dict]:
"""Aplana todas las tareas de todos los proyectos en una sola lista."""
tasks = []
for project in projects:
tasks.extend(project["tareas"])
return tasks