diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py
index e754afd..2e1b6fe 100644
--- a/app/routers/planning_import.py
+++ b/app/routers/planning_import.py
@@ -308,6 +308,7 @@ async def import_sheet_json(request: Request, import_id: int, sheet_name: str,
r.mode_operatoire, r.impacts, r.commentaire, r.base_de_donnees,
r.duree_coupure, r.jour, r.jour_text, r.heure, r.heure_t,
r.pb_espace_disque, r.date_patch_realise,
+ r.is_eligible, r.reported_to_sheet, r.report_reason,
r.server_id, s.hostname as resolved_hostname,
(r.jour + COALESCE(r.heure_t, TIME '00:00:00')) AS start_at
FROM patch_planning_import_rows r
@@ -345,12 +346,101 @@ async def import_sheet_json(request: Request, import_id: int, sheet_name: str,
"start_iso": r.start_at.isoformat() if r.start_at else None,
"pb_espace_disque": r.pb_espace_disque,
"date_patch_realise": r.date_patch_realise.isoformat() if r.date_patch_realise else None,
+ "is_eligible": r.is_eligible,
+ "reported_to_sheet": r.reported_to_sheet,
+ "report_reason": r.report_reason,
"server_id": r.server_id,
"resolved_hostname": r.resolved_hostname,
})
return JSONResponse({"ok": True, "rows": out, "count": len(out)})
+# ────────────────────────────────────────────────────────────────────────
+# Action sur les rows : éligible / report
+# ────────────────────────────────────────────────────────────────────────
+
+@router.post("/patching/import/{import_id}/rows/action")
+async def import_rows_action(request: Request, import_id: int, db=Depends(get_db)):
+ """Pose une action sur N rows :
+ - action='eligible' : marque is_eligible=true (et clear report)
+ - action='unset_eligible' : remet is_eligible=false
+ - action='report' : reported_to_sheet=target_sheet, reason=... (clear is_eligible)
+ - action='unset_report' : clear report
+ """
+ user = get_current_user(request)
+ if not user:
+ return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
+ perms = get_user_perms(db, user)
+ if not _can_import(perms):
+ return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
+
+ body = await request.json()
+ row_ids = [int(x) for x in body.get("row_ids", []) if str(x).isdigit()]
+ action = (body.get("action") or "").strip()
+ target_sheet = (body.get("target_sheet") or "").strip()
+ reason = (body.get("reason") or "").strip()
+
+ if not row_ids:
+ return JSONResponse({"ok": False, "msg": "Aucune ligne sélectionnée"})
+ if action not in ("eligible", "unset_eligible", "report", "unset_report"):
+ return JSONResponse({"ok": False, "msg": f"Action inconnue: {action}"})
+ if action == "report" and not target_sheet:
+ return JSONResponse({"ok": False, "msg": "Semaine cible obligatoire pour reporter"})
+
+ placeholders = ",".join(str(i) for i in row_ids)
+ # Restreint aux rows de cet import (sécurité)
+ rows = db.execute(text(f"""
+ SELECT id FROM patch_planning_import_rows
+ WHERE id IN ({placeholders}) AND import_id=:imp
+ """), {"imp": import_id}).fetchall()
+ valid_ids = [r.id for r in rows]
+ if not valid_ids:
+ return JSONResponse({"ok": False, "msg": "Aucune ligne valide pour cet import"})
+ valid_ph = ",".join(str(i) for i in valid_ids)
+
+ uid = user.get("uid")
+ details = {}
+ if action == "eligible":
+ db.execute(text(f"""
+ UPDATE patch_planning_import_rows
+ SET is_eligible=true, reported_to_sheet=NULL, report_reason=NULL,
+ last_action_at=NOW(), last_action_by=:uid
+ WHERE id IN ({valid_ph})
+ """), {"uid": uid})
+ elif action == "unset_eligible":
+ db.execute(text(f"""
+ UPDATE patch_planning_import_rows
+ SET is_eligible=false, last_action_at=NOW(), last_action_by=:uid
+ WHERE id IN ({valid_ph})
+ """), {"uid": uid})
+ elif action == "report":
+ details = {"target_sheet": target_sheet, "reason": reason}
+ db.execute(text(f"""
+ UPDATE patch_planning_import_rows
+ SET is_eligible=false, reported_to_sheet=:ts, report_reason=:rs,
+ last_action_at=NOW(), last_action_by=:uid
+ WHERE id IN ({valid_ph})
+ """), {"ts": target_sheet, "rs": reason or None, "uid": uid})
+ elif action == "unset_report":
+ db.execute(text(f"""
+ UPDATE patch_planning_import_rows
+ SET reported_to_sheet=NULL, report_reason=NULL,
+ last_action_at=NOW(), last_action_by=:uid
+ WHERE id IN ({valid_ph})
+ """), {"uid": uid})
+
+ # Log
+ for rid in valid_ids:
+ db.execute(text("""
+ INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
+ VALUES (:rid, :ac, :de, :uid)
+ """), {"rid": rid, "ac": action,
+ "de": json.dumps(details, ensure_ascii=False) if details else None,
+ "uid": uid})
+ db.commit()
+ return JSONResponse({"ok": True, "updated": len(valid_ids), "action": action})
+
+
# ────────────────────────────────────────────────────────────────────────
# Upload
# ────────────────────────────────────────────────────────────────────────
@@ -484,6 +574,43 @@ async def import_upload(request: Request, db=Depends(get_db),
# Suppression
# ────────────────────────────────────────────────────────────────────────
+# ────────────────────────────────────────────────────────────────────────
+# Workflow iexec — placeholder étape B (à compléter)
+# ────────────────────────────────────────────────────────────────────────
+
+@router.get("/patching/iexec", response_class=HTMLResponse)
+async def iexec_page(request: Request, db=Depends(get_db),
+ row_ids: str = Query("")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
+ return RedirectResponse(url="/dashboard")
+
+ ids = [int(x) for x in row_ids.split(",") if x.strip().isdigit()]
+ rows = []
+ if ids:
+ placeholders = ",".join(str(i) for i in ids)
+ rows = db.execute(text(f"""
+ SELECT r.id, r.asset_name, r.environnement, r.os, r.os_version,
+ r.is_eligible, r.server_id,
+ s.hostname, vs.effective_excludes
+ FROM patch_planning_import_rows r
+ LEFT JOIN servers s ON s.id = r.server_id
+ LEFT JOIN v_servers_patching vs ON vs.id = r.server_id
+ WHERE r.id IN ({placeholders}) AND r.is_eligible = true
+ """)).fetchall()
+
+ ctx = base_context(request, db, user)
+ ctx.update({
+ "app_name": APP_NAME,
+ "rows": rows,
+ "row_ids": ids,
+ })
+ return templates.TemplateResponse("patching_iexec.html", ctx)
+
+
@router.post("/patching/import/{import_id}/delete")
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
user = get_current_user(request)
diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html
new file mode 100644
index 0000000..916151e
--- /dev/null
+++ b/app/templates/patching_iexec.html
@@ -0,0 +1,55 @@
+{% extends 'base.html' %}
+{% block title %}Pré-patching — iexec{% endblock %}
+{% block content %}
+
+
+
Pré-patching — workflow iexec
+
+ {{ rows|length }} serveur(s) éligible(s) sélectionné(s) sur {{ row_ids|length }} demandés.
+
+
+
← Retour
+
+
+
+
⚠ Étape B — workflow à implémenter
+
+ Les 3 steps planifiés :
+
+
+ - Step 1 — Pré-patching : vérif résolution DNS · vérif SSH · vérif Satellite (capsule)
+ - Step 2 — Snapshot : take snapshot vCenter (avant modif)
+ - Step 3 — Patch :
yum update -y --exclude=<effective_excludes>
+
+
+
+
+
Serveurs ciblés ({{ rows|length }})
+ {% if rows %}
+
+
+
+ | Asset |
+ Hostname BDD |
+ Env |
+ OS |
+ Excludes effectifs |
+
+
+
+ {% for r in rows %}
+
+ | {{ r.asset_name }} |
+ {{ r.hostname or '–' }} |
+ {{ r.environnement or '' }} |
+ {{ r.os or '' }} |
+ {{ r.effective_excludes or '(aucun)' }} |
+
+ {% endfor %}
+
+
+ {% else %}
+
Aucune ligne éligible parmi les IDs demandés.
+ {% endif %}
+
+{% endblock %}
diff --git a/app/templates/patching_import.html b/app/templates/patching_import.html
index 35af6d1..92f1376 100644
--- a/app/templates/patching_import.html
+++ b/app/templates/patching_import.html
@@ -123,17 +123,26 @@
0 sélectionné(s)
-