Patching: exclusions + correspondance prod<->hors-prod + validations

- /patching/config-exclusions: exclusions iTop par serveur + bulk + push iTop
- /quickwin/config: liste globale reboot packages (au lieu de per-server)
- /patching/correspondance: builder mark PROD/NON-PROD + bulk change env/app
  + auto-detect par nomenclature + exclut stock/obsolete
- /patching/validations: workflow post-patching (en_attente/OK/KO/force)
  validator obligatoire depuis contacts iTop
- /patching/validations/history/{id}: historique par serveur
- Auto creation patch_validation apres status='patched' dans QuickWin
- check_prod_validations: banniere rouge sur quickwin detail si non-prod non valides
- Menu: Correspondance sous Serveurs, Config exclusions+Validations sous Patching
- Colonne Equivalent(s) sur /servers + section Correspondance sur detail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pierre & Lumière 2026-04-12 18:51:30 +02:00
parent ba0bff0f6e
commit a706e240ca
11 changed files with 2065 additions and 277 deletions

679
app/routers/patching.py Normal file
View File

@ -0,0 +1,679 @@
"""Router Patching — exclusions, correspondance prod↔hors-prod, validations."""
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services import correspondance_service as corr
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _can_edit_excludes(perms):
"""Peut éditer les exclusions : admin, coordinator, operator (pas viewer)."""
return can_edit(perms, "servers") or can_edit(perms, "campaigns") or can_edit(perms, "quickwin")
@router.get("/patching/config-exclusions", response_class=HTMLResponse)
async def config_exclusions_page(request: Request, db=Depends(get_db),
search: str = Query(""),
domain: str = Query(""),
env: str = Query(""),
zone: str = Query(""),
tier: str = Query(""),
os: str = Query(""),
application: str = Query(""),
has_excludes: str = Query(""),
page: int = Query(1),
per_page: int = Query(30)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return RedirectResponse(url="/dashboard")
# Requête principale
where = ["1=1"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if domain:
where.append("d.code = :d"); params["d"] = domain
if env:
where.append("e.code = :e"); params["e"] = env
if zone:
where.append("z.name = :z"); params["z"] = zone
if tier:
where.append("s.tier = :t"); params["t"] = tier
if os:
where.append("s.os_family = :o"); params["o"] = os
if application:
where.append("s.application_name = :app"); params["app"] = application
if has_excludes == "yes":
where.append("s.patch_excludes IS NOT NULL AND s.patch_excludes != ''")
elif has_excludes == "no":
where.append("(s.patch_excludes IS NULL OR s.patch_excludes = '')")
wc = " AND ".join(where)
# Count
total = db.execute(text(f"""
SELECT COUNT(*) FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
"""), params).scalar() or 0
per_page = max(10, min(per_page, 200))
total_pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
offset = (page - 1) * per_page
rows = db.execute(text(f"""
SELECT s.id, s.hostname, s.os_family, s.os_version, s.tier, s.etat,
s.patch_excludes, s.application_name,
d.name as domain_name, d.code as domain_code,
e.name as env_name, e.code as env_code,
z.name as zone_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
ORDER BY s.hostname
LIMIT :limit OFFSET :offset
"""), {**params, "limit": per_page, "offset": offset}).fetchall()
# Listes pour filtres
domains = db.execute(text("SELECT code, name FROM domains ORDER BY name")).fetchall()
envs = db.execute(text("SELECT code, name FROM environments ORDER BY name")).fetchall()
zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall()
applications = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
WHERE application_name IS NOT NULL AND application_name != ''
GROUP BY application_name ORDER BY application_name""")).fetchall()
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
# Stats globales
stats = {
"total_servers": db.execute(text("SELECT COUNT(*) FROM servers")).scalar(),
"with_excludes": db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_excludes IS NOT NULL AND patch_excludes != ''")).scalar(),
}
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"servers": rows, "total": total,
"page": page, "per_page": per_page, "total_pages": total_pages,
"filters": {"search": search, "domain": domain, "env": env,
"zone": zone, "tier": tier, "os": os,
"application": application, "has_excludes": has_excludes},
"domains": domains, "envs": envs, "zones": [z.name for z in zones],
"applications": applications, "all_apps": all_apps,
"stats": stats,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("patching_config_exclusions.html", ctx)
@router.post("/patching/config-exclusions/{server_id}/save")
async def save_server_excludes(request: Request, server_id: int, db=Depends(get_db),
patch_excludes: str = Form("")):
"""Enregistre les exclusions d'un serveur + push iTop."""
from fastapi.responses import JSONResponse
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_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
# Normalise : split sur espaces/newlines, dédoublonne, rejoint avec un espace
parts = [p.strip() for p in patch_excludes.replace("\n", " ").replace("\t", " ").split() if p.strip()]
seen = set()
cleaned = []
for p in parts:
if p not in seen:
seen.add(p)
cleaned.append(p)
new_val = " ".join(cleaned)
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
if not srv:
return JSONResponse({"ok": False, "msg": "Serveur introuvable"}, status_code=404)
# 1. Maj base locale
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
{"pe": new_val, "id": server_id})
db.commit()
# 2. Push iTop immédiat (best effort)
itop_result = {"pushed": False, "msg": ""}
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
r = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{srv.hostname}"', "output_fields": "name"})
if r.get("objects"):
vm_id = list(r["objects"].values())[0]["key"]
upd = client.update("VirtualMachine", vm_id, {"patch_excludes": new_val})
if upd.get("code") == 0:
itop_result = {"pushed": True, "msg": "Poussé vers iTop"}
else:
itop_result = {"pushed": False, "msg": f"iTop: {upd.get('message','')[:80]}"}
except Exception as e:
itop_result = {"pushed": False, "msg": f"iTop error: {str(e)[:80]}"}
return JSONResponse({"ok": True, "patch_excludes": new_val, "itop": itop_result})
@router.post("/patching/config-exclusions/bulk")
async def bulk_update_excludes(request: Request, db=Depends(get_db)):
"""Bulk add/remove pattern sur plusieurs serveurs."""
from fastapi.responses import JSONResponse
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_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = body.get("server_ids", [])
pattern = (body.get("pattern") or "").strip()
action = body.get("action", "add") # "add" | "remove" | "replace"
if not server_ids or not pattern and action != "replace":
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
ids = [int(x) for x in server_ids if str(x).isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur valide"})
placeholders = ",".join(str(i) for i in ids)
rows = db.execute(text(f"SELECT id, hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
updated = 0
for r in rows:
current = (r.patch_excludes or "").strip()
parts = current.split() if current else []
if action == "add":
if pattern not in parts:
parts.append(pattern)
elif action == "remove":
parts = [p for p in parts if p != pattern]
elif action == "replace":
parts = pattern.split()
new_val = " ".join(parts)
if new_val != current:
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
{"pe": new_val, "id": r.id})
updated += 1
db.commit()
# Push iTop en batch (best effort, async conceptually)
itop_pushed = 0
itop_errors = 0
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
# Refresh après update
rows2 = db.execute(text(f"SELECT hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
for r in rows2:
resp = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{r.hostname}"', "output_fields": "name"})
if resp.get("objects"):
vm_id = list(resp["objects"].values())[0]["key"]
up = client.update("VirtualMachine", vm_id, {"patch_excludes": r.patch_excludes or ""})
if up.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors})
@router.post("/patching/config-exclusions/bulk-application")
async def bulk_update_application(request: Request, db=Depends(get_db)):
"""Bulk changement de solution applicative sur plusieurs serveurs."""
from fastapi.responses import JSONResponse
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_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = body.get("server_ids", [])
application_id = body.get("application_id") # int ou None/"" pour désassocier
ids = [int(x) for x in server_ids if str(x).isdigit()]
if not ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
app_id_val = None
app_itop_id = None
app_name = None
if application_id and str(application_id).strip().isdigit():
app_id_val = int(application_id)
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id_val}).fetchone()
if row:
app_itop_id = row.itop_id
app_name = row.nom_court
else:
return JSONResponse({"ok": False, "msg": "Application introuvable"})
placeholders = ",".join(str(i) for i in ids)
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
db.commit()
updated = len(ids)
# Push iTop
itop_pushed = 0
itop_errors = 0
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
for h in hosts:
try:
rr = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"})
if rr.get("objects"):
vm_id = list(rr["objects"].values())[0]["key"]
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
if up.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors,
"app_name": app_name or "(aucune)"})
# ═══════════════════════════════════════════════════════
# Correspondance prod ↔ hors-prod
# ═══════════════════════════════════════════════════════
@router.get("/patching/correspondance", response_class=HTMLResponse)
async def correspondance_page(request: Request, db=Depends(get_db),
search: str = Query(""), application: str = Query(""),
domain: str = Query(""), env: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms) and not can_view(perms, "campaigns"):
return RedirectResponse(url="/dashboard")
servers = corr.get_servers_for_builder(db, search=search, app=application,
domain=domain, env=env)
applications = db.execute(text("""SELECT DISTINCT application_name FROM servers
WHERE application_name IS NOT NULL AND application_name != ''
ORDER BY application_name""")).fetchall()
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
domains = db.execute(text("SELECT DISTINCT name FROM domains ORDER BY name")).fetchall()
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
# Stats globales
stats = {
"total_links": db.execute(text("SELECT COUNT(*) FROM server_correspondance")).scalar() or 0,
"filtered": len(servers),
}
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats,
"applications": applications,
"envs": [e.name for e in envs],
"domains": [d.name for d in domains],
"all_apps": all_apps,
"search": search, "application": application,
"domain": domain, "env": env,
"can_edit": _can_edit_excludes(perms),
"msg": request.query_params.get("msg", "")})
return templates.TemplateResponse("patching_correspondance.html", ctx)
@router.post("/patching/correspondance/bulk-env")
async def correspondance_bulk_env(request: Request, db=Depends(get_db)):
"""Change l'environnement réel de N serveurs (PatchCenter + push iTop)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
env_name = (body.get("env_name") or "").strip()
if not server_ids or not env_name:
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
# Trouver env_id
env_row = db.execute(text("SELECT id FROM environments WHERE name=:n"), {"n": env_name}).fetchone()
if not env_row:
return JSONResponse({"ok": False, "msg": f"Env '{env_name}' introuvable"})
env_id = env_row.id
placeholders = ",".join(str(i) for i in server_ids)
# Pour chaque serveur : trouver/créer le domain_env correspondant et l'affecter
updated = 0
srvs = db.execute(text(f"""SELECT s.id, s.hostname, s.domain_env_id, de.domain_id
FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id
WHERE s.id IN ({placeholders})""")).fetchall()
for s in srvs:
if not s.domain_id:
continue # pas de domaine actuel, skip
# Trouver ou créer domain_environments(domain_id, env_id)
de = db.execute(text("""SELECT id FROM domain_environments
WHERE domain_id=:d AND environment_id=:e"""),
{"d": s.domain_id, "e": env_id}).fetchone()
if not de:
db.execute(text("""INSERT INTO domain_environments (domain_id, environment_id)
VALUES (:d, :e)"""), {"d": s.domain_id, "e": env_id})
db.commit()
de = db.execute(text("""SELECT id FROM domain_environments
WHERE domain_id=:d AND environment_id=:e"""),
{"d": s.domain_id, "e": env_id}).fetchone()
db.execute(text("UPDATE servers SET domain_env_id=:de_id, updated_at=NOW() WHERE id=:sid"),
{"de_id": de.id, "sid": s.id})
updated += 1
db.commit()
# Push iTop
itop_pushed = 0
itop_errors = 0
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
for s in srvs:
try:
rr = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{s.hostname}"', "output_fields": "name"})
if rr.get("objects"):
vm_id = list(rr["objects"].values())[0]["key"]
upd = client.update("VirtualMachine", vm_id, {
"environnement_id": f"SELECT Environnement WHERE name = '{env_name}'"
})
if upd.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": updated,
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
"env_name": env_name})
@router.post("/patching/correspondance/bulk-application")
async def correspondance_bulk_app(request: Request, db=Depends(get_db)):
"""Change la solution applicative de N serveurs (PatchCenter + push iTop)."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
application_id = body.get("application_id")
if not server_ids:
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
app_id_val = None
app_itop_id = None
app_name = None
if application_id and str(application_id).strip().isdigit():
app_id_val = int(application_id)
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id_val}).fetchone()
if row:
app_itop_id = row.itop_id
app_name = row.nom_court
else:
return JSONResponse({"ok": False, "msg": "Application introuvable"})
placeholders = ",".join(str(i) for i in server_ids)
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
db.commit()
itop_pushed = 0
itop_errors = 0
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
for h in hosts:
try:
rr = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"})
if rr.get("objects"):
vm_id = list(rr["objects"].values())[0]["key"]
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
if up.get("code") == 0:
itop_pushed += 1
else:
itop_errors += 1
except Exception:
itop_errors += 1
except Exception:
pass
return JSONResponse({"ok": True, "updated": len(server_ids),
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
"app_name": app_name or "(aucune)"})
@router.post("/patching/correspondance/bulk-create")
async def correspondance_bulk_create(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
prod_ids = [int(x) for x in body.get("prod_ids", []) if str(x).isdigit()]
nonprod_ids = [int(x) for x in body.get("nonprod_ids", []) if str(x).isdigit()]
env_labels = body.get("env_labels", {})
if not prod_ids or not nonprod_ids:
return JSONResponse({"ok": False, "msg": "Au moins 1 prod et 1 non-prod requis"})
r = corr.bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user.get("uid"))
return JSONResponse({"ok": True, **r})
@router.post("/patching/correspondance/auto-detect")
async def correspondance_auto_detect(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
stats = corr.detect_correspondances(db)
return JSONResponse({"ok": True, **{k: v for k, v in stats.items() if k != "plan"}})
@router.post("/patching/correspondance/link")
async def correspondance_link(request: Request, db=Depends(get_db),
prod_id: int = Form(...), nonprod_id: int = Form(...),
env_code: str = Form(""), note: str = Form("")):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
corr.create_manual_link(db, prod_id, nonprod_id, env_code, note, user.get("uid"))
return JSONResponse({"ok": True})
@router.post("/patching/correspondance/link-by-host")
async def correspondance_link_by_host(request: Request, db=Depends(get_db),
prod_id: int = Form(...),
nonprod_hostname: str = Form(...),
env_code: str = Form(""), note: str = Form("")):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"),
{"h": nonprod_hostname.strip()}).fetchone()
if not row:
return JSONResponse({"ok": False, "msg": f"Serveur '{nonprod_hostname}' introuvable"})
corr.create_manual_link(db, prod_id, row.id, env_code, note, user.get("uid"))
return JSONResponse({"ok": True})
@router.post("/patching/correspondance/{corr_id}/delete")
async def correspondance_delete(request: Request, corr_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
corr.delete_link(db, corr_id)
return JSONResponse({"ok": True})
# ═══════════════════════════════════════════════════════
# Validations post-patching
# ═══════════════════════════════════════════════════════
@router.get("/patching/validations", response_class=HTMLResponse)
async def validations_page(request: Request, db=Depends(get_db),
status: str = Query("en_attente"),
campaign_id: int = Query(None),
env: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return RedirectResponse(url="/dashboard")
validations = corr.get_pending_validations(db, env=env, campaign_id=campaign_id, status=status)
# Contacts validateurs (responsables + référents)
contacts = db.execute(text("""SELECT id, name, email, role, team
FROM contacts WHERE is_active=true
AND role IN ('responsable_applicatif','responsable_domaine','referent_technique','ra_prod','ra_test')
ORDER BY name""")).fetchall()
stats = {
"en_attente": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='en_attente'")).scalar() or 0,
"validated_ok": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ok'")).scalar() or 0,
"validated_ko": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ko'")).scalar() or 0,
"forced": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='forced'")).scalar() or 0,
}
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "validations": validations, "contacts": contacts,
"stats": stats, "status": status, "campaign_id": campaign_id, "env": env,
"envs": [e.name for e in envs],
"can_force": can_edit(perms, "campaigns") or user.get("role") == "admin",
"msg": request.query_params.get("msg", "")})
return templates.TemplateResponse("patching_validations.html", ctx)
@router.post("/patching/validations/mark")
async def validations_mark(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False}, status_code=401)
perms = get_user_perms(db, user)
if not _can_edit_excludes(perms):
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
body = await request.json()
ids = body.get("validation_ids", [])
status = body.get("status", "validated_ok")
contact_id = body.get("contact_id")
forced_reason = body.get("forced_reason", "")
notes = body.get("notes", "")
if status not in ("validated_ok", "validated_ko", "forced"):
return JSONResponse({"ok": False, "msg": "Status invalide"})
if status == "forced" and not forced_reason.strip():
return JSONResponse({"ok": False, "msg": "Raison obligatoire pour forcer"})
if status in ("validated_ok", "validated_ko") and not contact_id:
return JSONResponse({"ok": False, "msg": "Validateur obligatoire"})
validator_name = None
if contact_id:
row = db.execute(text("SELECT name FROM contacts WHERE id=:id"),
{"id": int(contact_id)}).fetchone()
if row:
validator_name = row.name
n = corr.mark_validation(db, ids, status, contact_id, validator_name,
forced_reason, notes, user.get("uid"))
return JSONResponse({"ok": True, "updated": n})
@router.get("/patching/validations/history/{server_id}", response_class=HTMLResponse)
async def validations_history(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
if not srv:
return HTMLResponse("Serveur introuvable", status_code=404)
history = corr.get_validation_history(db, server_id)
ctx = base_context(request, db, user)
ctx.update({"app_name": APP_NAME, "server": srv, "history": history})
return templates.TemplateResponse("patching_validations_history.html", ctx)

View File

@ -10,7 +10,7 @@ from ..services.quickwin_service import (
get_server_configs, upsert_server_config, delete_server_config,
get_eligible_servers, list_runs, get_run, get_run_entries,
create_run, delete_run, update_entry_field,
can_start_prod, get_run_stats, inject_yum_history,
can_start_prod, check_prod_validations, get_run_stats, inject_yum_history,
advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots,
build_yum_commands, get_available_servers, get_available_filters,
add_entries_to_run, remove_entries_from_run,
@ -55,53 +55,32 @@ async def quickwin_page(request: Request, db=Depends(get_db)):
# -- Config exclusions par serveur --
DEFAULT_REBOOT_PACKAGES = (
"kernel* glibc* systemd* dbus* polkit* linux-firmware* microcode_ctl* "
"tuned* dracut* grub2* kexec-tools* libselinux* selinux-policy* shim* "
"mokutil* net-snmp* NetworkManager* network-scripts* nss* openssl-libs*"
)
@router.get("/quickwin/config", response_class=HTMLResponse)
async def quickwin_config_page(request: Request, db=Depends(get_db),
page: int = Query(1),
per_page: int = Query(14),
search: str = Query(""),
env: str = Query(""),
domain: str = Query(""),
zone: str = Query("")):
async def quickwin_config_page(request: Request, db=Depends(get_db)):
"""Page d'édition de la liste globale des packages qui nécessitent un reboot.
Cette liste est utilisée par QuickWin (en plus des exclusions iTop par serveur)."""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
configs = get_server_configs(db)
# Filtres
filtered = configs
if search:
filtered = [s for s in filtered if search.lower() in s.hostname.lower()]
if env:
filtered = [s for s in filtered if s.environnement == env]
if domain:
filtered = [s for s in filtered if s.domaine == domain]
if zone:
filtered = [s for s in filtered if (s.zone or '') == zone]
# Pagination
per_page = max(5, min(per_page, 100))
total = len(filtered)
total_pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
page_servers = filtered[start:start + per_page]
from ..services.secrets_service import get_secret
current = get_secret(db, "patching_reboot_packages") or DEFAULT_REBOOT_PACKAGES
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"all_servers": page_servers,
"all_configs": configs,
"default_excludes": DEFAULT_GENERAL_EXCLUDES,
"total_count": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"filters": {"search": search, "env": env, "domain": domain, "zone": zone},
"reboot_packages": current,
"default_packages": DEFAULT_REBOOT_PACKAGES,
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_config.html", ctx)
@ -109,44 +88,20 @@ async def quickwin_config_page(request: Request, db=Depends(get_db),
@router.post("/quickwin/config/save")
async def quickwin_config_save(request: Request, db=Depends(get_db),
server_id: int = Form(0),
general_excludes: str = Form(""),
specific_excludes: str = Form(""),
notes: str = Form("")):
reboot_packages: str = Form("")):
"""Sauvegarde la liste globale des packages nécessitant un reboot."""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if server_id:
upsert_server_config(db, server_id, general_excludes.strip(),
specific_excludes.strip(), notes.strip())
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
from ..services.secrets_service import set_secret
set_secret(db, "patching_reboot_packages", reboot_packages.strip(),
"Packages nécessitant un reboot (QuickWin)")
return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303)
@router.post("/quickwin/config/delete")
async def quickwin_config_delete(request: Request, db=Depends(get_db),
config_id: int = Form(0)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if config_id:
delete_server_config(db, config_id)
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
@router.post("/quickwin/config/bulk-add")
async def quickwin_config_bulk_add(request: Request, db=Depends(get_db),
server_ids: str = Form(""),
general_excludes: str = Form("")):
"""Ajouter plusieurs serveurs d'un coup avec les memes exclusions generales"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
for sid in ids:
upsert_server_config(db, sid, general_excludes.strip(), "", "")
return RedirectResponse(url=f"/quickwin/config?msg=added_{len(ids)}", status_code=303)
# -- Runs QuickWin --
@router.post("/quickwin/create")
@ -168,7 +123,7 @@ async def quickwin_create(request: Request, db=Depends(get_db),
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
if not ids:
# Prendre tous les serveurs eligibles (linux, en_production, secops)
# Prendre tous les serveurs eligibles (linux, production, secops)
eligible = get_eligible_servers(db)
ids = [s.id for s in eligible]
@ -189,6 +144,9 @@ async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db))
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
runs = list_runs(db)
if not runs:
return RedirectResponse(url="/quickwin?msg=no_run")
@ -221,6 +179,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
entries = get_run_entries(db, run_id)
stats = get_run_stats(db, run_id)
prod_ok = can_start_prod(db, run_id)
validations_ok, validations_blockers = check_prod_validations(db, run_id)
step_stats_hp = get_step_stats(db, run_id, "hprod")
step_stats_pr = get_step_stats(db, run_id, "prod")
@ -279,6 +238,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
"p_page": p_page, "p_total_pages": p_total_pages,
"per_page": per_page,
"prod_ok": prod_ok,
"validations_ok": validations_ok, "validations_blockers": validations_blockers,
"step_hp": step_stats_hp, "step_pr": step_stats_pr,
"scope": scope,
"filters": {"search": search, "status": status, "domain": domain,

View File

@ -0,0 +1,442 @@
"""Service de correspondance prod ↔ hors-prod + validations post-patching.
Détection automatique par signature de hostname :
- 2ème caractère = environnement SANEF (p=prod, r=recette, t=test, i=preprod, v=validation, d=dev, o=preprod, s=prod)
- Signature = hostname avec le 2ème char remplacé par "_"
- Tous les hostnames avec la même signature sont candidats correspondants.
Exceptions (ls-*, sp-*, etc.) : ne sont pas traitées automatiquement.
"""
import logging
from sqlalchemy import text
from collections import defaultdict
log = logging.getLogger(__name__)
# Lettres prod (un prod pour une signature)
PROD_CHARS = {"p", "s"} # p=Production, s=Production secours (à valider)
# Lettres hors-prod avec label
NONPROD_CHARS = {
"r": "Recette", "t": "Test", "i": "Pre-production",
"v": "Validation", "d": "Developpement", "o": "Pre-production",
}
# Préfixes qui ne suivent PAS la nomenclature
EXCEPTION_PREFIXES = ("ls-", "sp")
def _signature(hostname):
"""Retourne (signature, env_char) ou (None, None) si non analysable."""
hn = (hostname or "").lower().strip()
if not hn or len(hn) < 3:
return None, None
# Préfixes d'exception
for pref in EXCEPTION_PREFIXES:
if hn.startswith(pref):
return None, None
# Format standard : X{env_char}YYYYYY
env_char = hn[1]
if env_char not in PROD_CHARS and env_char not in NONPROD_CHARS:
return None, None
signature = hn[0] + "_" + hn[2:]
return signature, env_char
def detect_correspondances(db, dry_run=False):
"""Parcourt tous les serveurs, groupe par signature, crée les liens auto.
Ne touche pas aux liens 'manual' ou 'exception' existants.
Retourne un dict de stats."""
stats = {"signatures": 0, "prod_found": 0, "nonprod_found": 0,
"links_created": 0, "links_kept_manual": 0, "orphan_nonprod": 0,
"ambiguous": 0, "exceptions": 0}
# Tous les serveurs actifs (exclut stock/obsolete)
rows = db.execute(text("""SELECT id, hostname FROM servers
WHERE etat NOT IN ('stock','obsolete') ORDER BY hostname""")).fetchall()
by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)]
for r in rows:
sig, env = _signature(r.hostname)
if sig is None:
stats["exceptions"] += 1
continue
by_signature[sig].append((r.id, env, r.hostname))
stats["signatures"] = len(by_signature)
if dry_run:
# Préparer plan
plan = []
for sig, members in by_signature.items():
prods = [m for m in members if m[1] in PROD_CHARS]
nps = [m for m in members if m[1] in NONPROD_CHARS]
if len(prods) == 1 and nps:
plan.append({"signature": sig, "prod": prods[0][2],
"nonprods": [(n[2], NONPROD_CHARS[n[1]]) for n in nps]})
elif len(prods) > 1 and nps:
stats["ambiguous"] += 1
elif not prods and nps:
stats["orphan_nonprod"] += len(nps)
stats["plan"] = plan[:50]
return stats
for sig, members in by_signature.items():
prods = [m for m in members if m[1] in PROD_CHARS]
nonprods = [m for m in members if m[1] in NONPROD_CHARS]
stats["prod_found"] += len(prods)
stats["nonprod_found"] += len(nonprods)
if not prods and nonprods:
stats["orphan_nonprod"] += len(nonprods)
continue
if len(prods) > 1:
stats["ambiguous"] += 1
# On n'auto-détecte pas quand plusieurs prods (ambigu)
continue
if len(prods) == 1 and nonprods:
prod_id = prods[0][0]
for np_id, np_env, np_host in nonprods:
env_label = NONPROD_CHARS.get(np_env, "Autre")
# Insert si pas déjà présent + pas 'manual' ou 'exception'
existing = db.execute(text("""SELECT id, source FROM server_correspondance
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
{"p": prod_id, "n": np_id}).fetchone()
if existing:
if existing.source in ("manual", "exception"):
stats["links_kept_manual"] += 1
# sinon déjà auto, on skip
continue
try:
db.execute(text("""INSERT INTO server_correspondance
(prod_server_id, nonprod_server_id, environment_code, source)
VALUES (:p, :n, :env, 'auto')"""),
{"p": prod_id, "n": np_id, "env": env_label})
stats["links_created"] += 1
except Exception as e:
db.rollback()
db.commit()
return stats
def get_servers_for_builder(db, search="", app="", domain="", env=""):
"""Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes.
Exclut les serveurs en stock / obsolete (décommissionnés, EOL)."""
where = ["s.etat NOT IN ('stock','obsolete')"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if app:
where.append("s.application_name = :app"); params["app"] = app
if domain:
where.append("d.name = :dom"); params["dom"] = domain
if env:
where.append("e.name = :env"); params["env"] = env
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT s.id, s.hostname, s.application_name,
e.name as env_name, d.name as domain_name, z.name as zone_name,
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.prod_server_id = s.id) as n_as_prod,
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) as n_as_nonprod
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc}
ORDER BY e.name, s.hostname
LIMIT 500
"""), params).fetchall()
def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id):
"""Crée toutes les correspondances prod × non-prod.
env_labels est un dict {nonprod_id: env_label} optionnel."""
created = 0
skipped = 0
for pid in prod_ids:
for npid in nonprod_ids:
if pid == npid:
continue
existing = db.execute(text("""SELECT id FROM server_correspondance
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
{"p": pid, "n": npid}).fetchone()
if existing:
skipped += 1
continue
env = (env_labels or {}).get(str(npid)) or (env_labels or {}).get(npid) or ""
db.execute(text("""INSERT INTO server_correspondance
(prod_server_id, nonprod_server_id, environment_code, source, created_by)
VALUES (:p, :n, :env, 'manual', :uid)"""),
{"p": pid, "n": npid, "env": env, "uid": user_id})
created += 1
db.commit()
return {"created": created, "skipped": skipped}
def get_correspondance_view(db, search="", app="", env=""):
"""Vue hiérarchique des correspondances groupées par application.
Exclut les serveurs en stock/obsolete."""
where = ["s.etat NOT IN ('stock','obsolete')"]
params = {}
if search:
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
if app:
where.append("s.application_name = :app"); params["app"] = app
if env:
where.append("e.name = :env"); params["env"] = env
else:
# Par défaut : tout ce qui ressemble à prod (Production ou code prod)
where.append("(e.name ILIKE '%production%' OR e.code ILIKE '%prod%')")
wc = " AND ".join(where)
prods = db.execute(text(f"""
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
d.name as domain_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE {wc}
ORDER BY s.application_name, s.hostname
"""), params).fetchall()
results = []
for p in prods:
corrs = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, sc.note,
ns.id as np_id, ns.hostname as np_hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_validation_status,
(SELECT pv.validated_at FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_validated_at,
(SELECT pv.patch_date FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_patch_date
FROM server_correspondance sc
JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid
ORDER BY sc.environment_code, ns.hostname"""), {"pid": p.id}).fetchall()
# Validation status agrégé du prod
# Compter statuts des hors-prod liés
n_total = len(corrs)
n_ok = sum(1 for c in corrs if c.last_validation_status in ("validated_ok", "forced"))
n_pending = sum(1 for c in corrs if c.last_validation_status == "en_attente")
n_ko = sum(1 for c in corrs if c.last_validation_status == "validated_ko")
if n_total == 0:
global_status = "no_nonprod" # gris
elif n_ko > 0:
global_status = "ko"
elif n_pending > 0:
global_status = "pending"
elif n_ok == n_total:
global_status = "all_ok"
else:
global_status = "partial"
results.append({
"prod_id": p.id, "prod_hostname": p.hostname,
"application": p.application_name, "domain": p.domain_name,
"env": p.env_name,
"correspondants": [dict(c._mapping) for c in corrs],
"n_total": n_total, "n_ok": n_ok, "n_pending": n_pending, "n_ko": n_ko,
"global_status": global_status,
})
return results
def get_server_links(db, server_id):
"""Pour un serveur donné, retourne ses liens :
- as_prod : liste des hors-prod qui lui sont liés (si ce serveur est prod)
- as_nonprod : liste des prod auxquels il est lié (si ce serveur est non-prod)
Chaque item : {hostname, env_name, environment_code, source, corr_id}
"""
as_prod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
ns.id, ns.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ns ON sc.nonprod_server_id = ns.id
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.prod_server_id = :id ORDER BY e.name, ns.hostname"""),
{"id": server_id}).fetchall()
as_nonprod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
ps.id, ps.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ps ON sc.prod_server_id = ps.id
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.nonprod_server_id = :id ORDER BY ps.hostname"""),
{"id": server_id}).fetchall()
return {
"as_prod": [dict(r._mapping) for r in as_prod],
"as_nonprod": [dict(r._mapping) for r in as_nonprod],
}
def get_links_bulk(db, server_ids):
"""Pour une liste d'IDs, retourne un dict {server_id: {as_prod: [...], as_nonprod: [...]}}.
Optimisé pour affichage en liste (/servers)."""
if not server_ids:
return {}
placeholders = ",".join(str(i) for i in server_ids if str(i).isdigit())
if not placeholders:
return {}
result = {sid: {"as_prod": [], "as_nonprod": []} for sid in server_ids}
# Prod → non-prods
rows = db.execute(text(f"""SELECT sc.prod_server_id as sid, sc.environment_code,
ns.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ns ON sc.nonprod_server_id = ns.id
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.prod_server_id IN ({placeholders})
ORDER BY ns.hostname""")).fetchall()
for r in rows:
if r.sid in result:
result[r.sid]["as_prod"].append({"hostname": r.hostname,
"env_name": r.env_name, "environment_code": r.environment_code})
# Non-prod → prods
rows = db.execute(text(f"""SELECT sc.nonprod_server_id as sid,
ps.hostname, e.name as env_name
FROM server_correspondance sc
JOIN servers ps ON sc.prod_server_id = ps.id
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
WHERE sc.nonprod_server_id IN ({placeholders})
ORDER BY ps.hostname""")).fetchall()
for r in rows:
if r.sid in result:
result[r.sid]["as_nonprod"].append({"hostname": r.hostname, "env_name": r.env_name})
return result
def get_orphan_nonprod(db):
"""Retourne les hors-prod sans prod associée (exclut stock/obsolete)."""
rows = db.execute(text("""
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
d.name as domain_name
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%'
AND s.etat NOT IN ('stock','obsolete')
AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id)
ORDER BY s.application_name, s.hostname
LIMIT 500
""")).fetchall()
return rows
def create_manual_link(db, prod_id, nonprod_id, env_code, note, user_id):
"""Crée un lien manuel."""
existing = db.execute(text("""SELECT id FROM server_correspondance
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
{"p": prod_id, "n": nonprod_id}).fetchone()
if existing:
db.execute(text("""UPDATE server_correspondance SET source='manual',
environment_code=:env, note=:note, updated_at=NOW() WHERE id=:id"""),
{"env": env_code, "note": note, "id": existing.id})
else:
db.execute(text("""INSERT INTO server_correspondance (prod_server_id,
nonprod_server_id, environment_code, source, note, created_by)
VALUES (:p, :n, :env, 'manual', :note, :uid)"""),
{"p": prod_id, "n": nonprod_id, "env": env_code, "note": note, "uid": user_id})
db.commit()
def delete_link(db, corr_id):
db.execute(text("DELETE FROM server_correspondance WHERE id=:id"), {"id": corr_id})
db.commit()
# ─── Patch validation ───
def create_validation_entry(db, server_id, campaign_id=None, campaign_type="manual"):
"""Crée une entrée 'en_attente' après patching."""
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id, campaign_type,
patch_date, status) VALUES (:sid, :cid, :ct, NOW(), 'en_attente')"""),
{"sid": server_id, "cid": campaign_id, "ct": campaign_type})
db.commit()
def mark_validation(db, validation_ids, status, validator_contact_id, validator_name,
forced_reason, notes, user_id):
"""Marque N validations. status dans (validated_ok, validated_ko, forced)."""
placeholders = ",".join(str(i) for i in validation_ids if str(i).isdigit())
if not placeholders:
return 0
db.execute(text(f"""UPDATE patch_validation SET
status=:s, validated_by_contact_id=:cid, validated_by_name=:n,
validated_at=NOW(), marked_by_user_id=:uid,
forced_reason=:fr, notes=:nt, updated_at=NOW()
WHERE id IN ({placeholders})"""),
{"s": status, "cid": validator_contact_id, "n": validator_name,
"uid": user_id, "fr": forced_reason, "nt": notes})
db.commit()
return len(placeholders.split(","))
def get_pending_validations(db, env="", campaign_id=None, status="en_attente", limit=500):
"""Liste les validations filtrées."""
where = ["1=1"]
params = {}
if status:
where.append("pv.status = :st"); params["st"] = status
if campaign_id:
where.append("pv.campaign_id = :cid"); params["cid"] = campaign_id
if env:
where.append("e.name = :env"); params["env"] = env
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT pv.id, pv.server_id, s.hostname, s.application_name,
e.name as env_name, d.name as domain_name,
pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
pv.validated_by_name, pv.validated_at,
pv.forced_reason, pv.notes,
EXTRACT(day FROM NOW() - pv.patch_date) as days_pending
FROM patch_validation pv
JOIN servers s ON pv.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN domains d ON de.domain_id = d.id
WHERE {wc}
ORDER BY pv.patch_date DESC
LIMIT {int(limit)}
"""), params).fetchall()
def get_validation_history(db, server_id):
return db.execute(text("""
SELECT pv.id, pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
pv.validated_by_name, pv.validated_at, pv.forced_reason, pv.notes,
u.display_name as marked_by
FROM patch_validation pv
LEFT JOIN users u ON pv.marked_by_user_id = u.id
WHERE pv.server_id = :sid
ORDER BY pv.patch_date DESC
"""), {"sid": server_id}).fetchall()
def can_patch_prod(db, prod_server_id):
"""Retourne (bool, list_of_pending_hostnames) : peut-on patcher le prod ?
OK si tous les hors-prod liés ont validated_ok ou forced sur leur dernier patching."""
corrs = db.execute(text("""SELECT ns.id, ns.hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid"""), {"pid": prod_server_id}).fetchall()
if not corrs:
return True, [] # pas de hors-prod = OK (ou selon règle, à ajuster)
blockers = [c.hostname for c in corrs if c.last_status not in ("validated_ok", "forced")]
return (len(blockers) == 0), blockers

View File

@ -72,7 +72,7 @@ def delete_server_config(db, config_id):
def get_eligible_servers(db):
"""Serveurs Linux en_production, patch_os_owner=secops"""
"""Serveurs Linux production, patch_os_owner=secops"""
return db.execute(text("""
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
@ -86,7 +86,7 @@ def get_eligible_servers(db):
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
ORDER BY e.display_order, d.display_order, s.hostname
""")).fetchall()
@ -140,25 +140,26 @@ def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
run_id = row.id
# Lire les reboot packages globaux (source: app_secrets)
from .secrets_service import get_secret
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
for sid in server_ids:
srv = db.execute(text("""
SELECT s.id, e.name as env_name,
COALESCE(qc.general_excludes, '') as ge,
COALESCE(qc.specific_excludes, '') as se
SELECT s.id, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
# QuickWin : reboot globaux + exclusions iTop du serveur
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
db.commit()
return run_id
@ -183,7 +184,7 @@ def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
ORDER BY d.name, e.name, s.hostname
@ -210,7 +211,7 @@ def get_available_filters(db, run_id):
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.os_family = 'linux'
AND s.etat = 'en_production'
AND s.etat = 'production'
AND s.patch_os_owner = 'secops'
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
"""), {"rid": run_id}).fetchall()
@ -241,29 +242,28 @@ def add_entries_to_run(db, run_id, server_ids, user=None):
), {"rid": run_id}).fetchall())
by = user.get("display_name", user.get("username", "")) if user else ""
from .secrets_service import get_secret
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
added = 0
hostnames = []
for sid in server_ids:
if sid in existing:
continue
srv = db.execute(text("""
SELECT s.id, s.hostname, e.name as env_name,
COALESCE(qc.general_excludes, '') as ge,
COALESCE(qc.specific_excludes, '') as se
SELECT s.id, s.hostname, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
WHERE s.id = :sid
"""), {"sid": sid}).fetchone()
if not srv:
continue
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
db.execute(text("""
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
VALUES (:rid, :sid, :br, :ge, :se)
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
added += 1
hostnames.append(srv.hostname)
if added:
@ -395,6 +395,23 @@ def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
"pp": packages, "rb": reboot_required, "n": notes})
db.commit()
# Création auto d'une entrée patch_validation (en_attente) pour les serveurs patchés
if status == "patched":
row = db.execute(text("SELECT server_id, run_id FROM quickwin_entries WHERE id=:id"),
{"id": entry_id}).fetchone()
if row:
# Éviter les doublons (même run + même server dans la dernière heure)
existing = db.execute(text("""SELECT id FROM patch_validation
WHERE server_id=:sid AND campaign_id=:cid AND campaign_type='quickwin'
AND patch_date >= NOW() - INTERVAL '1 hour'"""),
{"sid": row.server_id, "cid": row.run_id}).fetchone()
if not existing:
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id,
campaign_type, patch_date, status)
VALUES (:sid, :cid, 'quickwin', NOW(), 'en_attente')"""),
{"sid": row.server_id, "cid": row.run_id})
db.commit()
def update_entry_field(db, entry_id, field, value):
"""Mise a jour d'un champ unique (pour inline edit)"""
@ -417,6 +434,31 @@ def can_start_prod(db, run_id):
return pending.cnt == 0
def check_prod_validations(db, run_id):
"""Vérifie que chaque prod du run a ses non-prod liés validés (via server_correspondance + patch_validation).
Retourne (ok, blockers) blockers = liste [{prod_hostname, nonprod_hostname, status}].
Ignore les prods sans non-prod lié (OK par défaut)."""
rows = db.execute(text("""
SELECT qe.id as entry_id, ps.id as prod_id, ps.hostname as prod_host
FROM quickwin_entries qe JOIN servers ps ON qe.server_id = ps.id
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status NOT IN ('excluded','skipped','patched')
"""), {"rid": run_id}).fetchall()
blockers = []
for r in rows:
corrs = db.execute(text("""SELECT ns.hostname,
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
WHERE sc.prod_server_id = :pid"""), {"pid": r.prod_id}).fetchall()
for c in corrs:
if c.last_status not in ("validated_ok", "forced"):
blockers.append({"prod_hostname": r.prod_host,
"nonprod_hostname": c.hostname,
"status": c.last_status or "aucun_patching"})
return (len(blockers) == 0), blockers
def get_run_stats(db, run_id):
return db.execute(text("""
SELECT

View File

@ -0,0 +1,225 @@
{% extends 'base.html' %}
{% block title %}Patching — Config exclusions{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Config exclusions — par serveur</h2>
<p class="text-xs text-gray-500 mt-1">Exclusions de packages lors du <code>yum update</code>. Stockées dans iTop (champ <code>patch_excludes</code>) et poussées en temps réel.</p>
</div>
<div class="flex gap-2">
<a href="/quickwin/config" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Packages reboot (QuickWin)</a>
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">{{ msg }}</div>
{% endif %}
<!-- KPIs -->
<div class="flex gap-2 mb-4">
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.total_servers }}</div><div class="text-xs text-gray-500">Serveurs total</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">{{ stats.with_excludes }}</div><div class="text-xs text-gray-500">Avec exclusions</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.total_servers - stats.with_excludes }}</div><div class="text-xs text-gray-500">Sans exclusions</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ total }}</div><div class="text-xs text-gray-500">Filtrés</div></div>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="text" name="search" value="{{ filters.search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:200px">
<select name="domain" class="text-xs py-1 px-2" style="width:150px">
<option value="">Tous domaines</option>
{% for d in domains %}<option value="{{ d.code }}" {% if filters.domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</select>
<select name="env" class="text-xs py-1 px-2" style="width:130px">
<option value="">Tous envs</option>
{% for e in envs %}<option value="{{ e.code }}" {% if filters.env == e.code %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
</select>
<select name="zone" class="text-xs py-1 px-2" style="width:100px">
<option value="">Toutes zones</option>
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
<select name="tier" class="text-xs py-1 px-2" style="width:90px">
<option value="">Tier</option>
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<select name="os" class="text-xs py-1 px-2" style="width:100px">
<option value="">Tous OS</option>
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
</select>
<select name="application" class="text-xs py-1 px-2" style="width:220px">
<option value="">Toutes solutions applicatives</option>
{% for a in applications %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
</select>
<select name="has_excludes" class="text-xs py-1 px-2" style="width:140px">
<option value="">Tous</option>
<option value="yes" {% if filters.has_excludes == 'yes' %}selected{% endif %}>Avec exclusions</option>
<option value="no" {% if filters.has_excludes == 'no' %}selected{% endif %}>Sans exclusions</option>
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/config-exclusions" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
</form>
</div>
<!-- Bulk actions -->
<div id="bulk-bar" class="card p-3 mb-2" style="display:none">
<div class="flex gap-2 items-center flex-wrap mb-2">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
<span class="text-xs text-gray-500 font-bold ml-2">Exclusions :</span>
<input type="text" id="bulk-pattern" placeholder="pattern (ex: oracle*, nginx*)" class="text-xs py-1 px-2" style="width:220px">
<button onclick="bulkAction('add')" class="btn-sm bg-cyber-green text-black">Ajouter</button>
<button onclick="bulkAction('remove')" class="btn-sm bg-red-900/40 text-cyber-red">Retirer</button>
<button onclick="bulkAction('replace')" class="btn-sm bg-cyber-border text-cyber-accent">Remplacer tout</button>
</div>
<div class="flex gap-2 items-center flex-wrap">
<span class="text-xs text-gray-500 font-bold">Solution applicative :</span>
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">-- Aucune (désassocier) --</option>
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
</select>
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer à tous les sélectionnés</button>
</div>
<div id="bulk-result" class="text-xs text-gray-400 mt-2"></div>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
<th class="text-left p-2">Hostname</th>
<th class="text-left p-2">Solution applicative</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">Zone</th>
<th class="p-2">Tier</th>
<th class="p-2">OS</th>
<th class="text-left p-2" style="min-width:300px">Exclusions</th>
<th class="p-2">Action</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr id="row-{{ s.id }}" class="border-t border-cyber-border/30">
<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:40] }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
<td class="p-2 text-center">{{ s.env_name or '-' }}</td>
<td class="p-2 text-center">{{ s.zone_name or '-' }}</td>
<td class="p-2 text-center">{{ s.tier or '-' }}</td>
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
<td class="p-2">
<textarea id="excl-{{ s.id }}" rows="2" class="w-full font-mono text-xs" placeholder="ex: oracle* tomcat* (un ou plusieurs patterns séparés par espace ou retour ligne)" style="resize:vertical;min-height:36px">{{ s.patch_excludes or '' }}</textarea>
</td>
<td class="p-2 text-center">
<button onclick="saveExcl({{ s.id }})" class="btn-sm bg-cyber-border text-cyber-accent">Sauver</button>
<span id="status-{{ s.id }}" class="text-xs ml-1"></span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="flex justify-center gap-2 mt-4">
{% for p in range(1, total_pages + 1) %}
<a href="?page={{ p }}{% for k,v in filters.items() %}{% if v %}&{{ k }}={{ v }}{% endif %}{% endfor %}"
class="btn-sm {% if p == page %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-2 py-1">{{ p }}</a>
{% endfor %}
</div>
{% endif %}
<script>
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
const count = document.getElementById('bulk-count');
count.textContent = ids.length;
bar.style.display = ids.length > 0 ? 'flex' : 'none';
window._selectedIds = ids;
}
function saveExcl(id) {
const inp = document.getElementById('excl-' + id);
const status = document.getElementById('status-' + id);
status.textContent = '…'; status.className = 'text-xs ml-1 text-gray-400';
const fd = new FormData();
fd.append('patch_excludes', inp.value);
fetch('/patching/config-exclusions/' + id + '/save', {method: 'POST', credentials: 'same-origin', body: fd})
.then(r => r.json())
.then(d => {
if (d.ok) {
status.textContent = d.itop && d.itop.pushed ? '✓ iTop' : '⚠ local';
status.className = 'text-xs ml-1 ' + (d.itop && d.itop.pushed ? 'text-cyber-green' : 'text-cyber-yellow');
status.title = d.itop ? d.itop.msg : '';
} else {
status.textContent = '✗';
status.className = 'text-xs ml-1 text-cyber-red';
status.title = d.msg || '';
}
})
.catch(e => { status.textContent = '✗'; status.className = 'text-xs ml-1 text-cyber-red'; status.title = e.message; });
}
function bulkChangeApp() {
const ids = window._selectedIds || [];
const app = document.getElementById('bulk-app').value;
const appName = document.getElementById('bulk-app').selectedOptions[0].text;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!confirm('Changer solution applicative vers "' + appName + '" sur ' + ids.length + ' serveur(s) ?')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 mt-2';
fetch('/patching/config-exclusions/bulk-application', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, application_id: app})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' serveurs → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs mt-2';
setTimeout(() => location.reload(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red mt-2';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red mt-2'; });
}
function bulkAction(action) {
const ids = window._selectedIds || [];
const pattern = document.getElementById('bulk-pattern').value.trim();
if (!ids.length) return alert('Aucun serveur sélectionné');
if (action !== 'replace' && !pattern) return alert('Saisir un pattern');
const label = action === 'add' ? 'Ajouter' : action === 'remove' ? 'Retirer' : 'Remplacer tout par';
if (!confirm(label + ' "' + pattern + '" sur ' + ids.length + ' serveur(s) ?')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 ml-2';
fetch('/patching/config-exclusions/bulk', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, pattern: pattern, action: action})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' serveurs mis à jour — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs ml-2';
setTimeout(() => location.reload(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red ml-2';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red ml-2'; });
}
</script>
{% endblock %}

View File

@ -0,0 +1,302 @@
{% extends 'base.html' %}
{% block title %}Builder correspondance prod ↔ hors-prod{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Builder correspondance Prod ↔ Hors-Prod</h2>
<p class="text-xs text-gray-500 mt-1">Filtrer les serveurs, les désigner comme <b class="text-cyber-green">Prod</b> ou <b class="text-cyber-yellow">Non-Prod</b>, puis générer les liens en masse.</p>
</div>
<div class="flex gap-2">
<a href="/patching/validations" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Validations</a>
</div>
</div>
<!-- KPIs -->
<div class="flex gap-2 mb-4">
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ stats.total_links }}</div><div class="text-xs text-gray-500">Liens existants (toutes apps)</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.filtered }}</div><div class="text-xs text-gray-500">Serveurs filtrés</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green" id="selected-prod-count">0</div><div class="text-xs text-gray-500">Marqués PROD</div></div>
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow" id="selected-nonprod-count">0</div><div class="text-xs text-gray-500">Marqués NON-PROD</div></div>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<input type="text" name="search" value="{{ search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
<select name="application" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">Toutes applications</option>
{% for a in applications %}<option value="{{ a.application_name }}" {% if application == a.application_name %}selected{% endif %}>{{ a.application_name }}</option>{% endfor %}
</select>
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
<option value="">Tous domaines</option>
{% for d in domains %}<option value="{{ d }}" {% if domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="env" class="text-xs py-1 px-2" style="width:140px">
<option value="">Tous envs</option>
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/correspondance" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
</form>
</div>
{% if can_edit %}
<!-- Barre actions bulk -->
<div class="card p-3 mb-2" id="bulk-bar" style="display:none">
<div class="flex gap-2 items-center flex-wrap mb-2">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
</div>
<!-- Section 1: Normaliser iTop (env + app) -->
<div class="flex gap-2 items-center flex-wrap mb-2" style="border-left:3px solid #00d4ff;padding-left:8px">
<span class="text-xs text-cyber-accent font-bold">Normaliser iTop :</span>
<select id="bulk-env" class="text-xs py-1 px-2" style="width:180px">
<option value="">-- Changer env vers --</option>
{% for e in envs %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
</select>
<button onclick="bulkChangeEnv()" class="btn-sm bg-cyber-blue text-black">Appliquer env</button>
<span class="text-gray-600">|</span>
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
<option value="">-- Changer solution app. vers --</option>
<option value="__none__">Aucune (désassocier)</option>
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
</select>
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer app.</button>
</div>
<!-- Section 2: Correspondance -->
<div class="flex gap-2 items-center flex-wrap" style="border-left:3px solid #00ff88;padding-left:8px">
<span class="text-xs text-cyber-green font-bold">Marquer pour correspondance :</span>
<button onclick="markSelected('prod')" class="btn-sm bg-cyber-green text-black">Marquer PROD</button>
<button onclick="markSelected('nonprod')" class="btn-sm bg-cyber-yellow text-black">Marquer NON-PROD</button>
<button onclick="markSelected('none')" class="btn-sm bg-cyber-border text-gray-300">Démarquer</button>
</div>
<div id="bulk-result" class="text-xs mt-2"></div>
</div>
<!-- Générer correspondances -->
<div class="card p-4 mb-4" style="border-color:#00d4ff55">
<div class="flex gap-3 items-center">
<div style="flex:1">
<b class="text-cyber-accent">Générer correspondances</b>
<p class="text-xs text-gray-400 mt-1">
<span class="text-cyber-green" id="preview-prod">0 prod</span> ×
<span class="text-cyber-yellow" id="preview-nonprod">0 non-prod</span> =
<b class="text-cyber-accent" id="preview-links">0 liens</b>
</p>
</div>
<button onclick="generateCorrespondances()" class="btn-primary px-4 py-2 text-sm" id="btn-generate" disabled>Créer les correspondances</button>
</div>
<div id="gen-result" class="text-xs mt-2"></div>
</div>
{% endif %}
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
{% if can_edit %}<th class="p-2 w-8"><input type="checkbox" onchange="toggleAll(this)"></th>{% endif %}
<th class="p-2 text-left">Hostname</th>
<th class="p-2">Env</th>
<th class="p-2 text-left">Application</th>
<th class="p-2">Domaine</th>
<th class="p-2">Zone</th>
<th class="p-2">Liens existants</th>
{% if can_edit %}<th class="p-2">Rôle</th>{% endif %}
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="border-t border-cyber-border/30" id="row-{{ s.id }}" data-env="{{ s.env_name or '' }}">
{% if can_edit %}<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center">
{% if s.env_name == 'Production' %}<span class="badge badge-green">{{ s.env_name }}</span>
{% elif s.env_name %}<span class="badge badge-yellow">{{ s.env_name }}</span>
{% else %}<span class="text-gray-600">-</span>{% endif %}
</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ s.zone_name or '-' }}</td>
<td class="p-2 text-center">
{% if s.n_as_prod %}<span class="badge badge-green" style="font-size:9px" title="Liés comme prod">{{ s.n_as_prod }}P</span>{% endif %}
{% if s.n_as_nonprod %}<span class="badge badge-yellow" style="font-size:9px" title="Liés comme non-prod">{{ s.n_as_nonprod }}N</span>{% endif %}
{% if not s.n_as_prod and not s.n_as_nonprod %}<span class="text-gray-600">-</span>{% endif %}
</td>
{% if can_edit %}
<td class="p-2 text-center">
<span id="role-{{ s.id }}" class="badge badge-gray" style="font-size:9px"></span>
</td>
{% endif %}
</tr>
{% endfor %}
{% if not servers %}
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun serveur pour ces filtres</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if can_edit %}
<script>
const markedProd = new Set();
const markedNonProd = new Set();
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const checked = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
bar.style.display = checked.length > 0 ? 'block' : 'none';
document.getElementById('bulk-count').textContent = checked.length;
window._selectedIds = checked;
}
function markSelected(role) {
const ids = window._selectedIds || [];
if (!ids.length) return;
// Contrôle de cohérence
const warnings = [];
ids.forEach(id => {
const row = document.getElementById('row-' + id);
const env = row ? (row.dataset.env || '') : '';
if (role === 'prod' && env !== 'Production') {
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' (env: ' + (env || 'aucun') + ') n\'est pas en Production');
} else if (role === 'nonprod' && env === 'Production') {
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' est en Production, pas hors-prod');
}
});
if (warnings.length > 0) {
if (!confirm('Incohérence détectée :\n\n' + warnings.join('\n') +
'\n\nVoulez-vous continuer quand même ?\n(Recommandé : corriger d\'abord l\'environnement via "Normaliser iTop")')) return;
}
ids.forEach(id => {
const badge = document.getElementById('role-' + id);
const row = document.getElementById('row-' + id);
if (role === 'prod') {
markedProd.add(id); markedNonProd.delete(id);
badge.className = 'badge badge-green';
badge.textContent = 'PROD';
row.style.background = 'rgba(0, 255, 136, 0.05)';
} else if (role === 'nonprod') {
markedNonProd.add(id); markedProd.delete(id);
badge.className = 'badge badge-yellow';
badge.textContent = 'NON-PROD';
row.style.background = 'rgba(255, 204, 0, 0.05)';
} else {
markedProd.delete(id); markedNonProd.delete(id);
badge.className = 'badge badge-gray'; badge.style.fontSize = '9px';
badge.textContent = '—';
row.style.background = '';
}
});
updateCounters();
// Décocher
document.querySelectorAll('.srv-check').forEach(c => c.checked = false);
document.querySelector('thead input[type=checkbox]').checked = false;
updateBulk();
}
function updateCounters() {
const np = markedProd.size, nn = markedNonProd.size;
document.getElementById('selected-prod-count').textContent = np;
document.getElementById('selected-nonprod-count').textContent = nn;
document.getElementById('preview-prod').textContent = np + ' prod';
document.getElementById('preview-nonprod').textContent = nn + ' non-prod';
document.getElementById('preview-links').textContent = (np * nn) + ' liens';
document.getElementById('btn-generate').disabled = (np === 0 || nn === 0);
}
function bulkChangeEnv() {
const ids = window._selectedIds || [];
const env = document.getElementById('bulk-env').value;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!env) return alert('Choisir un environnement');
if (!confirm('Changer l\'environnement vers "' + env + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
const res = document.getElementById('bulk-result');
res.textContent = 'Changement env en cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-env', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, env_name: env})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' → ' + d.env_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs mt-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
}
});
}
function bulkChangeApp() {
const ids = window._selectedIds || [];
let app = document.getElementById('bulk-app').value;
if (!ids.length) return alert('Aucun serveur sélectionné');
if (!app) return alert('Choisir une application');
const appText = document.getElementById('bulk-app').selectedOptions[0].text;
if (!confirm('Changer solution applicative vers "' + appText + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
if (app === '__none__') app = '';
const res = document.getElementById('bulk-result');
res.textContent = 'Changement app en cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-application', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_ids: ids, application_id: app})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.updated + ' → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
res.className = 'text-xs mt-2';
setTimeout(() => location.reload(), 1500);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
}
});
}
function generateCorrespondances() {
if (markedProd.size === 0 || markedNonProd.size === 0) return;
const n = markedProd.size * markedNonProd.size;
if (!confirm('Créer ' + n + ' correspondances ?')) return;
// Récupérer les env labels des non-prod depuis le data-env de chaque row
const envLabels = {};
markedNonProd.forEach(id => {
const row = document.getElementById('row-' + id);
if (row) envLabels[id] = row.dataset.env || '';
});
const res = document.getElementById('gen-result');
res.textContent = 'En cours...'; res.className = 'text-xs mt-2 text-gray-400';
fetch('/patching/correspondance/bulk-create', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
prod_ids: Array.from(markedProd),
nonprod_ids: Array.from(markedNonProd),
env_labels: envLabels,
})
})
.then(r => r.json())
.then(d => {
if (d.ok) {
res.innerHTML = '✓ ' + d.created + ' liens créés, ' + d.skipped + ' déjà existants';
res.className = 'text-xs mt-2 text-cyber-green';
setTimeout(() => location.reload(), 2000);
} else {
res.textContent = '✗ ' + (d.msg || 'Erreur');
res.className = 'text-xs mt-2 text-cyber-red';
}
})
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs mt-2 text-cyber-red'; });
}
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,179 @@
{% extends 'base.html' %}
{% block title %}Validations post-patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Validations post-patching</h2>
<p class="text-xs text-gray-500 mt-1">Serveurs patchés en attente de validation par les responsables applicatifs.</p>
</div>
<a href="/patching/correspondance" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Correspondance</a>
</div>
<!-- KPIs -->
<div class="flex gap-2 mb-4">
<a href="?status=en_attente" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.en_attente }}</div>
<div class="text-xs text-gray-500">En attente</div>
</a>
<a href="?status=validated_ok" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-green">{{ stats.validated_ok }}</div>
<div class="text-xs text-gray-500">Validés OK</div>
</a>
<a href="?status=validated_ko" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-red">{{ stats.validated_ko }}</div>
<div class="text-xs text-gray-500">KO</div>
</a>
<a href="?status=forced" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
<div class="text-2xl font-bold text-cyber-blue">{{ stats.forced }}</div>
<div class="text-xs text-gray-500">Forcés</div>
</a>
</div>
<!-- Filtres -->
<div class="card p-3 mb-4">
<form method="GET" class="flex gap-2 items-center flex-wrap">
<select name="status" class="text-xs py-1 px-2">
<option value="en_attente" {% if status == 'en_attente' %}selected{% endif %}>En attente</option>
<option value="validated_ok" {% if status == 'validated_ok' %}selected{% endif %}>Validés OK</option>
<option value="validated_ko" {% if status == 'validated_ko' %}selected{% endif %}>Validés KO</option>
<option value="forced" {% if status == 'forced' %}selected{% endif %}>Forcés</option>
</select>
<select name="env" class="text-xs py-1 px-2">
<option value="">Tous environnements</option>
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
{% if campaign_id %}<input type="hidden" name="campaign_id" value="{{ campaign_id }}">{% endif %}
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
{% if campaign_id %}<span class="text-xs text-cyber-accent">Campagne #{{ campaign_id }}</span>{% endif %}
</form>
</div>
<!-- Bulk bar -->
<div id="bulk-bar" class="card p-3 mb-2 flex gap-2 items-center flex-wrap" style="display:none">
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
<button onclick="openValidateModal('validated_ok')" class="btn-sm bg-cyber-green text-black">Marquer OK</button>
<button onclick="openValidateModal('validated_ko')" class="btn-sm bg-red-900/40 text-cyber-red">Marquer KO</button>
{% if can_force %}<button onclick="openValidateModal('forced')" class="btn-sm bg-cyber-yellow text-black">Forcer</button>{% endif %}
</div>
<!-- Tableau -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
<th class="p-2 text-left">Hostname</th>
<th class="p-2 text-left">Application</th>
<th class="p-2">Env</th>
<th class="p-2">Domaine</th>
<th class="p-2">Patched</th>
<th class="p-2">Jours</th>
<th class="p-2">Statut</th>
<th class="p-2">Validé par</th>
<th class="p-2">Action</th>
</tr></thead>
<tbody>
{% for v in validations %}
<tr class="border-t border-cyber-border/30">
<td class="p-2 text-center"><input type="checkbox" class="val-check" value="{{ v.id }}" onchange="updateBulk()"></td>
<td class="p-2 font-mono text-cyber-accent">{{ v.hostname }}</td>
<td class="p-2 text-xs text-gray-300">{{ (v.application_name or '-')[:30] }}</td>
<td class="p-2 text-center">{{ v.env_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{{ v.domain_name or '-' }}</td>
<td class="p-2 text-center text-gray-400">{% if v.patch_date %}{{ v.patch_date.strftime('%Y-%m-%d %H:%M') }}{% endif %}</td>
<td class="p-2 text-center {% if v.days_pending and v.days_pending > 7 %}text-cyber-red font-bold{% endif %}">{{ v.days_pending|int if v.days_pending else '-' }}</td>
<td class="p-2 text-center">
{% if v.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
{% elif v.status == 'validated_ok' %}<span class="badge badge-green">✓ OK</span>
{% elif v.status == 'validated_ko' %}<span class="badge badge-red">✗ KO</span>
{% elif v.status == 'forced' %}<span class="badge badge-yellow" title="{{ v.forced_reason }}">Forcé</span>
{% endif %}
</td>
<td class="p-2 text-xs text-gray-300">
{% if v.validated_by_name %}{{ v.validated_by_name }}{% if v.validated_at %}<div class="text-gray-500" style="font-size:10px">{{ v.validated_at.strftime('%Y-%m-%d') }}</div>{% endif %}
{% else %}—{% endif %}
</td>
<td class="p-2 text-center">
<a href="/patching/validations/history/{{ v.server_id }}" class="text-xs text-cyber-accent hover:underline">Historique</a>
</td>
</tr>
{% endfor %}
{% if not validations %}
<tr><td colspan="10" class="p-6 text-center text-gray-500">Aucune validation dans ce filtre</td></tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Modal validation -->
<div id="validate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
<div class="card p-5" style="width:420px;max-width:95vw">
<h3 class="text-sm font-bold text-cyber-accent mb-3" id="validate-title">Valider</h3>
<div id="validator-zone">
<label class="text-xs text-gray-500 block mb-1">Validé par (contact iTop obligatoire)</label>
<select id="val-contact-id" class="w-full text-xs">
<option value="">-- Sélectionner --</option>
{% for c in contacts %}<option value="{{ c.id }}">{{ c.name }} ({{ c.team or c.role }})</option>{% endfor %}
</select>
</div>
<div id="forced-zone" style="display:none" class="mt-3">
<label class="text-xs text-gray-500 block mb-1">Raison du forçage (obligatoire)</label>
<textarea id="val-reason" rows="3" class="w-full text-xs"></textarea>
</div>
<div class="mt-3">
<label class="text-xs text-gray-500 block mb-1">Notes (optionnel)</label>
<textarea id="val-notes" rows="2" class="w-full text-xs"></textarea>
</div>
<div class="flex gap-2 mt-4 justify-end">
<button onclick="document.getElementById('validate-modal').style.display='none'" class="btn-sm bg-cyber-border text-gray-300">Annuler</button>
<button onclick="submitValidate()" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
</div>
</div>
</div>
<script>
let _valStatus = null;
function toggleAll(cb) {
document.querySelectorAll('.val-check').forEach(c => c.checked = cb.checked);
updateBulk();
}
function updateBulk() {
const ids = Array.from(document.querySelectorAll('.val-check:checked')).map(c => parseInt(c.value));
const bar = document.getElementById('bulk-bar');
bar.style.display = ids.length > 0 ? 'flex' : 'none';
document.getElementById('bulk-count').textContent = ids.length;
window._selectedValIds = ids;
}
function openValidateModal(status) {
_valStatus = status;
const titles = {'validated_ok':'Marquer OK', 'validated_ko':'Marquer KO', 'forced':'Forcer'};
document.getElementById('validate-title').textContent = titles[status] + ' — ' + (window._selectedValIds || []).length + ' serveur(s)';
document.getElementById('forced-zone').style.display = (status === 'forced') ? 'block' : 'none';
document.getElementById('validator-zone').style.display = (status === 'forced') ? 'none' : 'block';
document.getElementById('val-reason').value = '';
document.getElementById('val-notes').value = '';
document.getElementById('validate-modal').style.display = 'flex';
}
function submitValidate() {
const ids = window._selectedValIds || [];
if (!ids.length) return alert('Aucun sélectionné');
const payload = {
validation_ids: ids,
status: _valStatus,
contact_id: document.getElementById('val-contact-id').value || null,
forced_reason: document.getElementById('val-reason').value,
notes: document.getElementById('val-notes').value,
};
fetch('/patching/validations/mark', {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(d => {
if (d.ok) { alert(d.updated + ' marqué(s)'); location.reload(); }
else alert('Erreur: ' + (d.msg || ''));
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,48 @@
{% extends 'base.html' %}
{% block title %}Historique validations — {{ server.hostname }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-gray-300">&larr; Validations</a>
<h2 class="text-xl font-bold text-cyber-accent">Historique — <span class="font-mono">{{ server.hostname }}</span></h2>
</div>
</div>
<div class="card overflow-x-auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-2">Patched</th>
<th class="p-2">Campagne</th>
<th class="p-2">Statut</th>
<th class="p-2">Validé par</th>
<th class="p-2">Le</th>
<th class="p-2 text-left">Raison forcée</th>
<th class="p-2 text-left">Notes</th>
<th class="p-2">Marqué par</th>
</tr></thead>
<tbody>
{% for h in history %}
<tr class="border-t border-cyber-border/30">
<td class="p-2 text-center">{{ h.patch_date.strftime('%Y-%m-%d %H:%M') if h.patch_date }}</td>
<td class="p-2 text-center text-gray-400">{{ h.campaign_type or '-' }} #{{ h.campaign_id or '' }}</td>
<td class="p-2 text-center">
{% if h.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
{% elif h.status == 'validated_ok' %}<span class="badge badge-green">OK</span>
{% elif h.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
{% elif h.status == 'forced' %}<span class="badge badge-yellow">Forcé</span>
{% endif %}
</td>
<td class="p-2 text-center">{{ h.validated_by_name or '—' }}</td>
<td class="p-2 text-center text-gray-400">{{ h.validated_at.strftime('%Y-%m-%d %H:%M') if h.validated_at else '—' }}</td>
<td class="p-2 text-gray-300">{{ h.forced_reason or '' }}</td>
<td class="p-2 text-gray-300">{{ h.notes or '' }}</td>
<td class="p-2 text-center text-gray-400">{{ h.marked_by or '—' }}</td>
</tr>
{% endfor %}
{% if not history %}
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun historique</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,206 +1,48 @@
{% extends "base.html" %}
{% block title %}QuickWin Config{% endblock %}
{% macro qs(p) -%}
?page={{ p }}&per_page={{ per_page }}&search={{ filters.search or '' }}&env={{ filters.env or '' }}&domain={{ filters.domain or '' }}&zone={{ filters.zone or '' }}
{%- endmacro %}
{% extends 'base.html' %}
{% block title %}QuickWin — Packages avec reboot{% endblock %}
{% block content %}
<div class="flex items-center justify-between mb-6">
<div class="flex justify-between items-center mb-4">
<div>
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">&larr; Retour QuickWin</a>
<h1 class="text-xl font-bold" style="color:#00d4ff">Exclusions par serveur</h1>
<p class="text-xs text-gray-500">Tous les serveurs Linux en_production / secops &mdash; exclusions g&eacute;n&eacute;rales par d&eacute;faut pr&eacute;-remplies &mdash; pas de reboot n&eacute;cessaire</p>
</div>
<div class="flex gap-2 items-center">
<span class="text-sm text-gray-400">{{ total_count }} serveur(s)</span>
<button onclick="document.getElementById('bulkModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">Modifier en masse</button>
<h2 class="text-xl font-bold text-cyber-accent">Packages nécessitant un reboot</h2>
<p class="text-xs text-gray-500 mt-1">Liste globale utilisée par QuickWin pour exclure ces packages du <code>yum update</code>.</p>
</div>
<a href="/quickwin" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">&larr; Retour QuickWin</a>
</div>
{% if msg %}
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
{% if 'saved' in msg %}Configuration sauvegard&eacute;e{% elif 'deleted' in msg %}Exclusions sp&eacute;cifiques retir&eacute;es{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis &agrave; jour{% elif 'bulk' in msg %}Mise &agrave; jour group&eacute;e OK{% else %}{{ msg }}{% endif %}
</div>
{% if msg == 'saved' %}
<div class="mb-3 p-2 rounded bg-green-900/30 text-cyber-green text-sm">Liste des reboot packages sauvegardée.</div>
{% endif %}
<!-- Filtre -->
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
<select name="env" onchange="this.form.submit()" style="width:140px">
<option value="">Tous env.</option>
{% set envs = all_configs|map(attribute='environnement')|select('string')|unique|sort %}
{% for e in envs %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
<select name="domain" onchange="this.form.submit()" style="width:140px">
<option value="">Tous domaines</option>
{% set doms = all_configs|map(attribute='domaine')|select('string')|unique|sort %}
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="zone" onchange="this.form.submit()" style="width:100px">
<option value="">Zone</option>
{% set zones = all_configs|map(attribute='zone')|select('string')|unique|sort %}
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
<select name="per_page" onchange="this.form.submit()" style="width:140px">
<option value="">Affichage / page</option>
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
</select>
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
<a href="/quickwin/config" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
<span class="text-xs text-gray-500">{{ total_count }} serveur(s)</span>
</form>
<!-- Info -->
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
<b class="text-cyber-accent">Fonctionnement :</b>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>Les <b>campagnes QuickWin</b> excluent ces packages + les exclusions par serveur (iTop / <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>).</li>
<li>Les <b>campagnes standard avec reboot</b> n'utilisent PAS cette liste — uniquement les exclusions par serveur.</li>
<li>Format : patterns yum séparés par espace (ex: <code>kernel* glibc* systemd*</code>).</li>
</ul>
</div>
<!-- Cartouche detail serveur -->
<div id="srvDetail" class="card mb-4" style="display:none;border-left:3px solid #00d4ff;padding:12px 16px">
<div class="flex items-center justify-between mb-2">
<h3 style="color:#00d4ff;font-weight:bold;font-size:0.95rem" id="detailName"></h3>
<button onclick="document.getElementById('srvDetail').style.display='none'" class="text-gray-500 hover:text-gray-300" style="font-size:1.2rem">&times;</button>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Formulaire -->
<div class="card p-4">
<form method="POST" action="/quickwin/config/save" class="space-y-3">
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions g&eacute;n&eacute;rales (OS / reboot)</div>
<pre id="detailGeneral" style="font-size:0.7rem;color:#ffcc00;white-space:pre-wrap;margin:0"></pre>
<label class="text-xs text-gray-500 block mb-1">Patterns de packages à exclure (séparés par espace)</label>
<textarea name="reboot_packages" rows="6" class="w-full font-mono text-xs" style="min-height:120px">{{ reboot_packages }}</textarea>
</div>
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions sp&eacute;cifiques (applicatifs &mdash; hors p&eacute;rim&egrave;tre secops)</div>
<pre id="detailSpecific" style="font-size:0.7rem;color:#ff8800;white-space:pre-wrap;margin:0"></pre>
</div>
</div>
</div>
<!-- Tableau serveurs -->
<div class="card">
<div class="table-wrap">
<table class="table-cyber w-full" id="srvTable">
<thead><tr>
<th class="px-2 py-2" style="width:30px"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
<th class="px-2 py-2">Serveur</th>
<th class="px-2 py-2">Domaine</th>
<th class="px-2 py-2">Env</th>
<th class="px-2 py-2">Zone</th>
<th class="px-2 py-2">Tier</th>
<th class="px-2 py-2">Exclusions g&eacute;n&eacute;rales</th>
<th class="px-2 py-2">Exclusions sp&eacute;cifiques</th>
<th class="px-2 py-2">Notes</th>
<th class="px-2 py-2" style="width:60px">Save</th>
<th class="px-2 py-2" style="width:60px">Cmd</th>
</tr></thead>
<tbody>
{% for s in all_servers %}
<tr>
<td class="px-2 py-2"><input type="checkbox" class="srv-check" value="{{ s.server_id }}"></td>
<td class="px-2 py-2 font-bold" style="color:#00d4ff;cursor:pointer" onclick="showDetail('{{ s.hostname }}', this)">{{ s.hostname }}</td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.domaine or '?' }}</td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.environnement or '?' }}</td>
<td class="px-2 py-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.tier }}</td>
<td class="px-2 py-2">
<form method="post" action="/quickwin/config/save" class="inline-form" style="display:flex;gap:4px;align-items:center">
<input type="hidden" name="server_id" value="{{ s.server_id }}">
<input type="text" name="general_excludes" value="{{ s.general_excludes }}"
style="width:200px;font-size:0.7rem;padding:2px 6px" title="{{ s.general_excludes }}">
</td>
<td class="px-2 py-2">
<input type="text" name="specific_excludes" value="{{ s.specific_excludes }}"
style="width:150px;font-size:0.7rem;padding:2px 6px" placeholder="sdcss* custom*...">
</td>
<td class="px-2 py-2">
<input type="text" name="notes" value="{{ s.notes }}"
style="width:80px;font-size:0.7rem;padding:2px 6px" placeholder="...">
<button type="submit" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.65rem">OK</button>
</form>
</td>
<td class="px-2 py-2">
<button type="button" class="btn-sm" style="background:#1a3a1a;color:#00ff88;font-size:0.6rem;white-space:nowrap" onclick="showDryRun('{{ s.hostname }}', this)">Dry Run</button>
</td>
</tr>
{% endfor %}
{% if not all_servers %}<tr><td colspan="11" class="px-2 py-8 text-center text-gray-500">Aucun serveur trouv&eacute;</td></tr>{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
<span>Page {{ page }} / {{ total_pages }} &mdash; {{ total_count }} serveurs</span>
<div class="flex gap-2">
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %}
{% if page < total_pages %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
<!-- Bulk modal -->
<div id="bulkModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:550px;max-width:90vw;padding:24px">
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:12px">Modification group&eacute;e</h3>
<p class="text-xs text-gray-500 mb-3">Cochez les serveurs dans le tableau, puis appliquez les exclusions.</p>
<form method="post" action="/quickwin/config/bulk-add">
<input type="hidden" name="server_ids" id="bulkIds">
<div class="mb-3">
<label class="text-xs text-gray-400 block mb-1">Exclusions g&eacute;n&eacute;rales</label>
<textarea name="general_excludes" rows="3" style="width:100%;font-size:0.75rem">{{ default_excludes }}</textarea>
</div>
<div class="flex gap-2 justify-end">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('bulkModal').style.display='none'">Annuler</button>
<button type="submit" class="btn-primary" style="padding:6px 20px" onclick="collectIds()">Appliquer</button>
<div class="flex gap-2 items-center">
<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>
<button type="button" onclick="document.querySelector('textarea[name=reboot_packages]').value = {{ default_packages|tojson }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Réinitialiser aux valeurs par défaut</button>
</div>
</form>
</div>
</div>
<!-- Dry Run modal -->
<div id="dryRunModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
<div class="card" style="width:700px;max-width:90vw;padding:24px">
<div class="flex items-center justify-between mb-3">
<h3 style="color:#00ff88;font-weight:bold" id="dryRunTitle">Dry Run</h3>
<button id="copyBtn" onclick="copyDryRun()" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.75rem;padding:4px 12px">Copier</button>
</div>
<pre id="dryRunCmd" style="background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px;padding:12px;font-size:0.75rem;color:#00ff88;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto"></pre>
<div class="flex justify-end mt-3">
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('dryRunModal').style.display='none'">Fermer</button>
</div>
</div>
<!-- Aperçu de la commande QuickWin -->
<div class="card p-4 mt-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Aperçu commande QuickWin générée</h3>
<pre class="text-xs font-mono text-cyber-green bg-cyber-bg p-3 rounded overflow-x-auto" style="white-space:pre-wrap">yum update -y \
{% for pkg in reboot_packages.split() %} --exclude={{ pkg }} \
{% endfor %} &lt;exclusions iTop du serveur&gt;</pre>
<p class="text-xs text-gray-500 mt-2">Les <code>&lt;exclusions iTop du serveur&gt;</code> sont ajoutées depuis le champ <code>patch_excludes</code> de chaque serveur, gérable via <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>.</p>
</div>
<script>
function showDetail(hostname, td) {
const tr = td.closest('tr');
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
document.getElementById('detailName').textContent = hostname;
document.getElementById('detailGeneral').textContent = ge ? ge.split(/\s+/).join('\n') : '(aucune)';
document.getElementById('detailSpecific').textContent = se ? se.split(/\s+/).join('\n') : '(aucune)';
const panel = document.getElementById('srvDetail');
panel.style.display = 'block';
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
function showDryRun(hostname, btn) {
const tr = btn.closest('tr');
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
const all = (ge + ' ' + se).trim().split(/\s+/).filter(x => x);
const excludes = all.map(e => '--exclude=' + e).join(' \\\n ');
const cmd = 'yum update -y \\\n ' + excludes;
document.getElementById('dryRunTitle').textContent = 'Dry Run — ' + hostname;
document.getElementById('dryRunCmd').textContent = cmd;
document.getElementById('dryRunModal').style.display = 'flex';
}
function copyDryRun() {
const text = document.getElementById('dryRunCmd').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copyBtn');
btn.textContent = 'Copi\u00e9 !';
setTimeout(() => btn.textContent = 'Copier', 1500);
});
}
function toggleAll(cb) {
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
}
function collectIds() {
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => c.value);
document.getElementById('bulkIds').value = ids.join(',');
}
</script>
{% endblock %}

View File

@ -26,6 +26,7 @@
</div>
<div class="flex gap-2 items-center">
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
<a href="/patching/validations?campaign_id={{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#00ff88;padding:4px 14px;text-decoration:none">Validations</a>
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
@ -232,6 +233,33 @@
{% if run.status == 'patching' %}
{% if prod_ok and stats.prod_total > 0 %}
{# Alerte validations hors-prod non validées avant de patcher prod #}
{% if not validations_ok %}
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:16px;background:#5a1a1a22">
<h3 style="color:#ff3366;font-weight:bold;margin-bottom:8px">⚠ Validations hors-prod requises avant la production</h3>
<p class="text-xs text-gray-300 mb-2">Les non-prod suivants ne sont pas validés. Marquer leur validation dans <a href="/patching/validations?status=en_attente" class="text-cyber-accent hover:underline">/patching/validations</a> avant de patcher les prods correspondants.</p>
<div class="text-xs" style="max-height:180px;overflow-y:auto">
<table class="w-full table-cyber">
<thead><tr><th class="text-left p-1">Prod</th><th class="text-left p-1">Hors-prod bloquant</th><th class="p-1">Statut</th></tr></thead>
<tbody>
{% for b in validations_blockers %}
<tr class="border-t border-cyber-border/30">
<td class="p-1 font-mono text-cyber-green">{{ b.prod_hostname }}</td>
<td class="p-1 font-mono text-cyber-yellow">{{ b.nonprod_hostname }}</td>
<td class="p-1 text-center">
{% if b.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
{% elif b.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
{% elif b.status == 'aucun_patching' %}<span class="badge badge-gray">Pas de patching</span>
{% else %}<span class="badge badge-gray">{{ b.status }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="text-xs text-gray-500 mt-2">{{ validations_blockers|length }} bloquant(s) — vous pouvez continuer mais il est recommandé d'obtenir les validations d'abord.</p>
</div>
{% endif %}
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Pr&eacute;paration Production</h3>

View File

@ -0,0 +1,41 @@
-- Table de correspondance prod ↔ hors-prod
CREATE TABLE IF NOT EXISTS server_correspondance (
id SERIAL PRIMARY KEY,
prod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
nonprod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
environment_code VARCHAR(50),
source VARCHAR(20) NOT NULL DEFAULT 'auto',
note TEXT,
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT server_correspondance_source_check CHECK (source IN ('auto','manual','exception')),
CONSTRAINT server_correspondance_uniq UNIQUE(prod_server_id, nonprod_server_id)
);
CREATE INDEX IF NOT EXISTS idx_corr_prod ON server_correspondance(prod_server_id);
CREATE INDEX IF NOT EXISTS idx_corr_nonprod ON server_correspondance(nonprod_server_id);
-- Table des validations post-patching
CREATE TABLE IF NOT EXISTS patch_validation (
id SERIAL PRIMARY KEY,
server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
campaign_id INTEGER,
campaign_type VARCHAR(30),
patch_date TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'en_attente',
validated_by_contact_id INTEGER REFERENCES contacts(id),
validated_by_name VARCHAR(200),
validated_at TIMESTAMP,
marked_by_user_id INTEGER REFERENCES users(id),
forced_reason TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT patch_validation_status_check CHECK (status IN ('en_attente','validated_ok','validated_ko','forced'))
);
CREATE INDEX IF NOT EXISTS idx_pv_server ON patch_validation(server_id);
CREATE INDEX IF NOT EXISTS idx_pv_campaign ON patch_validation(campaign_id, campaign_type);
CREATE INDEX IF NOT EXISTS idx_pv_status ON patch_validation(status);
CREATE INDEX IF NOT EXISTS idx_pv_patch_date ON patch_validation(patch_date DESC);
SELECT 'schema créé' as msg;