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 : +

+
    +
  1. Step 1 — Pré-patching : vérif résolution DNS · vérif SSH · vérif Satellite (capsule)
  2. +
  3. Step 2 — Snapshot : take snapshot vCenter (avant modif)
  4. +
  5. Step 3 — Patch : yum update -y --exclude=<effective_excludes>
  6. +
+
+ +
+

Serveurs ciblés ({{ rows|length }})

+ {% if rows %} + + + + + + + + + + + + {% for r in rows %} + + + + + + + + {% endfor %} + +
AssetHostname BDDEnvOSExcludes effectifs
{{ r.asset_name }}{{ r.hostname or '–' }}{{ r.environnement or '' }}{{ r.os or '' }}{{ r.effective_excludes or '(aucun)' }}
+ {% 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)
- - + + {% endif %} + + @@ -170,7 +179,9 @@ const selAllHead = document.getElementById('select-all-head'); const selCount = document.getElementById('selection-count'); const btnPre = document.getElementById('btn-prepatch'); - const btnPatch = document.getElementById('btn-patch'); + const btnAddElig = document.getElementById('btn-add-eligible'); + const btnReport = document.getElementById('btn-report'); + const btnUnset = document.getElementById('btn-unset'); const fInter = document.getElementById('filter-intervenant'); const fEnv = document.getElementById('filter-env'); const fReset = document.getElementById('filter-reset'); @@ -221,14 +232,37 @@ function refreshSelection(){ const visible = tbody.querySelectorAll('tr:not(.row-hidden)'); const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb'); - const checked = tbody.querySelectorAll('input.row-cb:checked').length; - selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)'; + const checkedCb = Array.from(tbody.querySelectorAll('input.row-cb:checked')); + const checked = checkedCb.length; + const eligibleSelected = checkedCb.filter(cb => cb.dataset.eligible === '1').length; + selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)' + (eligibleSelected ? ' · ' + eligibleSelected + ' éligible(s)' : ''); const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked); selAll.checked = allVisibleChecked; selAllHead.checked = allVisibleChecked; const hasSel = checked > 0; - btnPre.disabled = !hasSel; - btnPatch.disabled = !hasSel; + if (btnAddElig) btnAddElig.disabled = !hasSel; + if (btnReport) btnReport.disabled = !hasSel; + if (btnUnset) btnUnset.disabled = !hasSel; + // Pré-patching : actif uniquement si au moins 1 row éligible sélectionnée + btnPre.disabled = eligibleSelected === 0; + } + + function getSelectedRowIds(){ + return Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => parseInt(cb.dataset.id, 10)).filter(x => x); + } + + async function postAction(payload){ + const r = await fetch('/patching/import/' + importId + '/rows/action', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(payload), + }); + const j = await r.json(); + if (!j.ok) { alert('Erreur : ' + (j.msg || 'inconnue')); return false; } + return true; + } + + async function reloadCurrentSheet(){ + if (sel.value) await loadSheet(sel.value); } function applyFilters(){ @@ -315,11 +349,19 @@ const assetCell = r.server_id ? '' + display + '' : '' + escapeHTML(r.asset_name || '') + ' ⚠'; + let badge = ''; + if (r.is_eligible) { + badge = '✓ ÉLIG.'; + } else if (r.reported_to_sheet) { + const t = r.report_reason ? ('Reporté → ' + r.reported_to_sheet + ' : ' + r.report_reason) : ('Reporté → ' + r.reported_to_sheet); + badge = '⤳ ' + escapeHTML(r.reported_to_sheet) + ''; + } return '' - + '' + + '' + + '' + '' + '' + '' @@ -356,13 +398,37 @@ thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); }); thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); }); - btnPre.addEventListener('click', () => { - const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x); - alert('Pré-patching à brancher (étape 2) — ' + ids.length + ' serveur(s) résolu(s) en base.'); + if (btnAddElig) btnAddElig.addEventListener('click', async () => { + const ids = getSelectedRowIds(); + if (!ids.length) return; + if (!confirm('Marquer ' + ids.length + ' ligne(s) comme éligibles au patching ?')) return; + if (await postAction({row_ids: ids, action: 'eligible'})) await reloadCurrentSheet(); }); - btnPatch.addEventListener('click', () => { - const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x); - alert('Patching by-step à brancher (étape 3) — ' + ids.length + ' serveur(s) résolu(s) en base.'); + if (btnReport) btnReport.addEventListener('click', async () => { + const ids = getSelectedRowIds(); + if (!ids.length) return; + const target = (prompt('Reporter vers quelle semaine ? (ex: S23)') || '').trim(); + if (!target) return; + if (!/^S\d{1,2}$/i.test(target)) { alert('Format attendu : Sxx (ex S23)'); return; } + const reason = (prompt('Raison du report (optionnel) :') || '').trim(); + if (await postAction({row_ids: ids, action: 'report', target_sheet: target.toUpperCase(), reason})) await reloadCurrentSheet(); + }); + if (btnUnset) btnUnset.addEventListener('click', async () => { + const ids = getSelectedRowIds(); + if (!ids.length) return; + if (!confirm('Annuler éligibilité ET report sur ' + ids.length + ' ligne(s) ?')) return; + if (await postAction({row_ids: ids, action: 'unset_eligible'})) { + await postAction({row_ids: ids, action: 'unset_report'}); + await reloadCurrentSheet(); + } + }); + btnPre.addEventListener('click', () => { + const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')) + .filter(cb => cb.dataset.eligible === '1') + .map(cb => cb.dataset.id); + if (!ids.length) { alert('Aucune ligne éligible sélectionnée.'); return; } + // Étape B : workflow iexec à brancher + window.location.href = '/patching/iexec?row_ids=' + ids.join(','); }); })(); diff --git a/migrate_planning_imports_v4.sql b/migrate_planning_imports_v4.sql new file mode 100644 index 0000000..bd04337 --- /dev/null +++ b/migrate_planning_imports_v4.sql @@ -0,0 +1,33 @@ +-- Migration v4 : actions sur les rows du planning importé +-- - is_eligible : ligne marquée éligible au patching (passe au workflow iexec) +-- - reported_to_sheet : si reportée, semaine cible (ex 'S23') +-- - report_reason : raison du report +-- - last_action_* : tracking dernière action (qui/quand) +-- + table patch_planning_row_log : audit log de chaque action posée +-- Idempotent + +ALTER TABLE public.patch_planning_import_rows + ADD COLUMN IF NOT EXISTS is_eligible boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS reported_to_sheet text, + ADD COLUMN IF NOT EXISTS report_reason text, + ADD COLUMN IF NOT EXISTS last_action_at timestamptz, + ADD COLUMN IF NOT EXISTS last_action_by integer REFERENCES public.users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_pp_rows_eligible + ON public.patch_planning_import_rows(import_id, sheet_name) + WHERE is_eligible = true; + +CREATE TABLE IF NOT EXISTS public.patch_planning_row_log ( + id SERIAL PRIMARY KEY, + row_id integer NOT NULL REFERENCES public.patch_planning_import_rows(id) ON DELETE CASCADE, + action text NOT NULL, -- 'eligible', 'unset_eligible', 'report', 'unset_report' + details jsonb, -- {target_sheet, reason, ...} + performed_by integer REFERENCES public.users(id) ON DELETE SET NULL, + performed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_pp_row_log_row ON public.patch_planning_row_log(row_id); +CREATE INDEX IF NOT EXISTS idx_pp_row_log_at ON public.patch_planning_row_log(performed_at DESC); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.patch_planning_row_log TO patchcenter; +GRANT USAGE, SELECT ON SEQUENCE public.patch_planning_row_log_id_seq TO patchcenter;
État Asset
' + badge + '' + assetCell + '' + escapeHTML(r.environnement||'') + '' + escapeHTML(r.domaine||'') + '