diff --git a/app/main.py b/app/main.py
index b683e56..0853329 100644
--- a/app/main.py
+++ b/app/main.py
@@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal
-from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin
+from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, safe_patching, audit_full, quickwin, referentiel
class PermissionsMiddleware(BaseHTTPMiddleware):
@@ -44,6 +44,7 @@ app.include_router(qualys.router)
app.include_router(safe_patching.router)
app.include_router(audit_full.router)
app.include_router(quickwin.router)
+app.include_router(referentiel.router)
@app.get("/")
diff --git a/app/routers/quickwin.py b/app/routers/quickwin.py
index fa8ef75..18b0564 100644
--- a/app/routers/quickwin.py
+++ b/app/routers/quickwin.py
@@ -1,8 +1,9 @@
"""Router QuickWin — Campagnes patching rapide avec exclusions par serveur"""
import json
from datetime import datetime
+from sqlalchemy import text
from fastapi import APIRouter, Request, Depends, Query, Form
-from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
+from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services.quickwin_service import (
@@ -10,8 +11,15 @@ from ..services.quickwin_service import (
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,
+ 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,
+ get_campaign_scope, apply_scope,
+ get_correspondance, get_available_prod_entries,
+ compute_correspondance, set_prod_pair, clear_all_pairs,
DEFAULT_GENERAL_EXCLUDES,
)
+from ..services.quickwin_log_service import get_logs, get_log_stats, clear_logs
from ..config import APP_NAME
router = APIRouter()
@@ -108,9 +116,6 @@ async def quickwin_config_save(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="/quickwin/config")
if server_id:
upsert_server_config(db, server_id, general_excludes.strip(),
specific_excludes.strip(), notes.strip())
@@ -123,9 +128,6 @@ async def quickwin_config_delete(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="/quickwin/config")
if config_id:
delete_server_config(db, config_id)
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
@@ -139,9 +141,6 @@ async def quickwin_config_bulk_add(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="/quickwin/config")
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(), "", "")
@@ -169,10 +168,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 configures, sinon tous les eligibles
- configs = get_server_configs(db)
- ids = [c.server_id for c in configs]
- if not ids:
+ # Prendre tous les serveurs eligibles (linux, en_production, secops)
eligible = get_eligible_servers(db)
ids = [s.id for s in eligible]
@@ -187,20 +183,36 @@ async def quickwin_create(request: Request, db=Depends(get_db),
return RedirectResponse(url=f"/quickwin?msg=error", status_code=303)
+@router.get("/quickwin/correspondance", response_class=HTMLResponse)
+async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db)):
+ """Redirige vers la correspondance de la derniere campagne active"""
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ runs = list_runs(db)
+ if not runs:
+ return RedirectResponse(url="/quickwin?msg=no_run")
+ return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance")
+
+
@router.get("/quickwin/{run_id}", response_class=HTMLResponse)
async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
search: str = Query(""),
status: str = Query(""),
domain: str = Query(""),
+ prereq_filter: str = Query(""),
+ snap_filter: str = Query(""),
hp_page: int = Query(1),
p_page: int = Query(1),
- per_page: int = Query(14)):
+ per_page: int = Query(14),
+ add_search: str = Query(""),
+ add_domains: str = Query(""),
+ add_envs: str = Query(""),
+ add_zones: str = Query(""),
+ show_add: int = Query(0)):
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"):
- return RedirectResponse(url="/dashboard")
run = get_run(db, run_id)
if not run:
@@ -209,9 +221,11 @@ 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)
+ step_stats_hp = get_step_stats(db, run_id, "hprod")
+ step_stats_pr = get_step_stats(db, run_id, "prod")
- hprod_all = [e for e in entries if e.branch == "hprod"]
- prod_all = [e for e in entries if e.branch == "prod"]
+ hprod_all = [e for e in entries if e.branch == "hprod" and e.status != "excluded"]
+ prod_all = [e for e in entries if e.branch == "prod" and e.status != "excluded"]
# Filtres
def apply_filters(lst):
@@ -222,6 +236,16 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
filtered = [e for e in filtered if e.status == status]
if domain:
filtered = [e for e in filtered if e.domaine == domain]
+ if prereq_filter == "ok":
+ filtered = [e for e in filtered if e.prereq_ok is True]
+ elif prereq_filter == "ko":
+ filtered = [e for e in filtered if e.prereq_ok is False]
+ elif prereq_filter == "pending":
+ filtered = [e for e in filtered if e.prereq_ok is None]
+ if snap_filter == "ok":
+ filtered = [e for e in filtered if e.snap_done is True]
+ elif snap_filter == "pending":
+ filtered = [e for e in filtered if not e.snap_done]
return filtered
hprod = apply_filters(hprod_all)
@@ -242,6 +266,9 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
p_start = (p_page - 1) * per_page
prod_page = prod[p_start:p_start + per_page]
+ # Campaign scope (domains/zones in this run)
+ scope = get_campaign_scope(db, run_id) if run.status in ("draft", "prereq") else {}
+
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
@@ -252,7 +279,10 @@ 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,
- "filters": {"search": search, "status": status, "domain": domain},
+ "step_hp": step_stats_hp, "step_pr": step_stats_pr,
+ "scope": scope,
+ "filters": {"search": search, "status": status, "domain": domain,
+ "prereq": prereq_filter, "snap": snap_filter},
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_detail.html", ctx)
@@ -270,6 +300,594 @@ async def quickwin_delete(request: Request, run_id: int, db=Depends(get_db)):
return RedirectResponse(url="/quickwin?msg=deleted", status_code=303)
+@router.post("/quickwin/{run_id}/apply-scope")
+async def quickwin_apply_scope(request: Request, run_id: int, db=Depends(get_db),
+ scope_domains: str = Form(""), scope_zones: str = Form("")):
+ """Applique le perimetre: domaines + zones selectionnes = inclus, reste = excluded"""
+ 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=f"/quickwin/{run_id}")
+ domains = [d for d in scope_domains.split(",") if d.strip()] or None
+ zones = [z for z in scope_zones.split(",") if z.strip()] or None
+ included, excluded = apply_scope(db, run_id, keep_domains=domains, keep_zones=zones, user=user)
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=scope_{included}in_{excluded}ex", status_code=303)
+
+
+@router.post("/quickwin/{run_id}/add-servers")
+async def quickwin_add_servers(request: Request, run_id: int, db=Depends(get_db),
+ server_ids: str = Form("")):
+ """Ajoute des serveurs au run (depuis le panneau d'ajout)"""
+ 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=f"/quickwin/{run_id}")
+ ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
+ added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303)
+
+
+@router.post("/quickwin/{run_id}/add-filtered")
+async def quickwin_add_filtered(request: Request, run_id: int, db=Depends(get_db),
+ add_domains: str = Form(""), add_envs: str = Form(""),
+ add_zones: str = Form(""), add_search: str = Form("")):
+ """Ajoute tous les serveurs matchant les filtres multi-select"""
+ 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=f"/quickwin/{run_id}")
+ domains = [d for d in add_domains.split(",") if d.strip()] or None
+ envs = [e for e in add_envs.split(",") if e.strip()] or None
+ zones = [z for z in add_zones.split(",") if z.strip()] or None
+ servers = get_available_servers(db, run_id, search=add_search, domains=domains, envs=envs, zones=zones)
+ ids = [s.id for s in servers]
+ added = add_entries_to_run(db, run_id, ids, user=user) if ids else 0
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=added_{added}", status_code=303)
+
+
+@router.post("/quickwin/{run_id}/remove-entries")
+async def quickwin_remove_entries(request: Request, run_id: int, db=Depends(get_db),
+ entry_ids: str = Form("")):
+ """Supprime des entries du run"""
+ 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=f"/quickwin/{run_id}")
+ ids = [int(x) for x in entry_ids.split(",") if x.strip().isdigit()]
+ removed = remove_entries_from_run(db, run_id, ids, user=user) if ids else 0
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=removed_{removed}", status_code=303)
+
+
+# -- Workflow steps --
+
+STEPS = ["draft", "prereq", "snapshot", "patching", "result", "completed"]
+
+@router.post("/quickwin/{run_id}/advance")
+async def quickwin_advance(request: Request, run_id: int, db=Depends(get_db),
+ target: str = Form("")):
+ """Avance le run vers l'etape suivante"""
+ 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=f"/quickwin/{run_id}")
+ if target in STEPS:
+ advance_run_status(db, run_id, target, user=user)
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=step_{target}", status_code=303)
+
+
+@router.get("/quickwin/{run_id}/prereq-stream")
+async def quickwin_prereq_stream(request: Request, run_id: int, db=Depends(get_db),
+ branch: str = Query("hprod")):
+ """SSE: streame les resultats prereq serveur par serveur"""
+ user = get_current_user(request)
+ if not user:
+ return StreamingResponse(iter([]), media_type="text/event-stream")
+
+ def event_generator():
+ from ..services.quickwin_prereq_service import check_server_prereqs
+ from ..services.quickwin_log_service import log_info, log_success, log_error
+ by = user.get("display_name", user.get("username", "")) if user else ""
+
+ entries = db.execute(text("""
+ SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
+ e.code as env_code
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.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
+ WHERE qe.run_id = :rid AND qe.branch = :br
+ AND qe.status NOT IN ('excluded','skipped')
+ ORDER BY s.hostname
+ """), {"rid": run_id, "br": branch}).fetchall()
+
+ total = len(entries)
+ log_info(db, run_id, "prereq",
+ f"Lancement check prerequis {branch} ({total} serveurs)", created_by=by)
+ db.commit()
+
+ yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n"
+
+ ok_count = 0
+ ko_count = 0
+ for idx, e in enumerate(entries):
+ yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'checking'})}\n\n"
+ try:
+ r = check_server_prereqs(
+ hostname=e.hostname, db=db,
+ domain_ltd=e.domain_ltd, env_code=e.env_code,
+ ssh_method=e.ssh_method or "ssh_key",
+ )
+ except Exception as ex:
+ r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
+ "disk_ok": False, "detail": str(ex), "skip": True}
+
+ prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
+ and r.get("satellite_ok", False) and r.get("disk_ok", True))
+ db.execute(text("""
+ UPDATE quickwin_entries SET
+ prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
+ prereq_detail = :detail, prereq_date = now(), updated_at = now()
+ WHERE id = :id
+ """), {
+ "id": e.id, "ok": prereq_ok,
+ "ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
+ "disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
+ })
+
+ detail_str = r.get("detail", "")
+ if prereq_ok:
+ ok_count += 1
+ log_success(db, run_id, "prereq", f"OK: {e.hostname}",
+ detail=detail_str, entry_id=e.id, hostname=e.hostname)
+ else:
+ ko_count += 1
+ log_error(db, run_id, "prereq", f"KO: {e.hostname}",
+ detail=detail_str, entry_id=e.id, hostname=e.hostname)
+ db.commit()
+
+ result_data = {
+ 'type': 'result', 'idx': idx+1, 'total': total,
+ 'hostname': e.hostname, 'ok': prereq_ok,
+ 'dns': r.get('dns_ok', False), 'ssh': r.get('ssh_ok', False),
+ 'sat': r.get('satellite_ok', False), 'disk': r.get('disk_ok', True),
+ 'fqdn': r.get('fqdn', ''), 'detail': detail_str[:200],
+ }
+ yield f"data: {json.dumps(result_data)}\n\n"
+
+ log_info(db, run_id, "prereq",
+ f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by)
+ db.commit()
+
+ yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n"
+
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
+
+
+@router.post("/quickwin/{run_id}/snapshot/mark")
+async def quickwin_snapshot_mark(request: Request, run_id: int, db=Depends(get_db),
+ entry_id: int = Form(0), done: str = Form("true")):
+ """Marque un serveur comme snapshot fait/pas fait"""
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ if entry_id:
+ mark_snapshot(db, entry_id, done == "true")
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_marked", status_code=303)
+
+
+@router.post("/quickwin/{run_id}/snapshot/mark-all")
+async def quickwin_snapshot_mark_all(request: Request, run_id: int, db=Depends(get_db),
+ branch: str = Form("hprod")):
+ """Marque tous les serveurs d'une branche comme snapshot fait"""
+ 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=f"/quickwin/{run_id}")
+ mark_all_snapshots(db, run_id, branch, True)
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=snap_all", status_code=303)
+
+
+@router.get("/quickwin/{run_id}/snapshot-stream")
+async def quickwin_snapshot_stream(request: Request, run_id: int, db=Depends(get_db),
+ branch: str = Query("hprod")):
+ """SSE: prend les snapshots VM serveur par serveur"""
+ user = get_current_user(request)
+ if not user:
+ return StreamingResponse(iter([]), media_type="text/event-stream")
+
+ def event_generator():
+ from ..services.quickwin_snapshot_service import snapshot_server
+ from ..services.quickwin_log_service import log_info, log_success, log_error, log_warn
+
+ by = user.get("display_name", user.get("username", "")) if user else ""
+
+ entries = db.execute(text("""
+ SELECT qe.id, s.hostname, s.machine_type, s.vcenter_vm_name, qe.branch
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.server_id = s.id
+ WHERE qe.run_id = :rid AND qe.branch = :br
+ AND qe.status NOT IN ('excluded','skipped')
+ ORDER BY s.hostname
+ """), {"rid": run_id, "br": branch}).fetchall()
+
+ total = len(entries)
+ physical_entries = [e for e in entries if e.machine_type == 'physical']
+ vm_entries = [e for e in entries if e.machine_type != 'physical']
+ physical_count = len(physical_entries)
+
+ log_info(db, run_id, "snapshot",
+ f"Lancement snapshots {branch} ({len(vm_entries)} VMs, {physical_count} physiques ignores)",
+ created_by=by)
+ db.commit()
+
+ yield f"data: {json.dumps({'type':'start','total':total,'vms':len(vm_entries),'physical':physical_count,'branch':branch})}\n\n"
+
+ # Marquer les physiques comme snap_done (pas de snap necessaire)
+ for e in physical_entries:
+ db.execute(text("""
+ UPDATE quickwin_entries SET snap_done = true, snap_detail = 'Serveur physique - pas de snapshot VM',
+ updated_at = now() WHERE id = :id
+ """), {"id": e.id})
+ log_warn(db, run_id, "snapshot",
+ f"Physique ignore: {e.hostname} (verifier backup Commvault)",
+ entry_id=e.id, hostname=e.hostname, created_by=by)
+ if physical_entries:
+ db.commit()
+
+ ok_count = 0
+ ko_count = 0
+ snap_name = f"QW_{run_id}_{branch}_{__import__('datetime').datetime.now().strftime('%Y%m%d_%H%M')}"
+
+ for idx, e in enumerate(vm_entries):
+ yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':len(vm_entries),'hostname':e.hostname,'status':'connecting'})}\n\n"
+
+ r = snapshot_server(
+ hostname=e.hostname,
+ vm_name=e.vcenter_vm_name,
+ branch=e.branch,
+ db=db,
+ snap_name=snap_name,
+ )
+
+ snap_ok = r.get("ok", False)
+ detail = r.get("detail", "")
+ vcenter = r.get("vcenter", "")
+
+ db.execute(text("""
+ UPDATE quickwin_entries SET
+ snap_done = :done, snap_detail = :detail, updated_at = now()
+ WHERE id = :id
+ """), {"id": e.id, "done": snap_ok, "detail": f"[{vcenter}] {detail}" if vcenter else detail})
+
+ if snap_ok:
+ ok_count += 1
+ log_success(db, run_id, "snapshot", f"OK: {e.hostname} ({vcenter})",
+ detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
+ else:
+ ko_count += 1
+ log_error(db, run_id, "snapshot", f"KO: {e.hostname}",
+ detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
+ db.commit()
+
+ result_data = {
+ 'type': 'result', 'idx': idx+1, 'total': len(vm_entries),
+ 'hostname': e.hostname, 'ok': snap_ok,
+ 'vcenter': vcenter, 'detail': detail[:200],
+ }
+ yield f"data: {json.dumps(result_data)}\n\n"
+
+ log_info(db, run_id, "snapshot",
+ f"Fin snapshots {branch}: {ok_count} OK, {ko_count} KO sur {len(vm_entries)} VMs",
+ created_by=by)
+ db.commit()
+
+ yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':len(vm_entries),'physical':physical_count})}\n\n"
+
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
+
+
+@router.post("/quickwin/{run_id}/build-commands")
+async def quickwin_build_commands(request: Request, run_id: int, db=Depends(get_db),
+ branch: str = Form("hprod")):
+ """Construit les commandes yum pour une branche"""
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ commands = build_yum_commands(db, run_id, branch)
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=commands_{len(commands)}&show_cmds={branch}", status_code=303)
+
+
+@router.get("/api/quickwin/{run_id}/commands/{branch}")
+async def quickwin_get_commands(request: Request, run_id: int, branch: str, db=Depends(get_db)):
+ """Retourne les commandes generees pour une branche"""
+ user = get_current_user(request)
+ if not user:
+ return JSONResponse({"error": "unauthorized"}, 401)
+ entries = db.execute(text("""
+ SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd,
+ e.code as env_code
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.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
+ WHERE qe.run_id = :rid AND qe.branch = :br
+ AND qe.status NOT IN ('excluded','skipped')
+ AND qe.patch_command IS NOT NULL AND qe.patch_command != ''
+ ORDER BY s.hostname
+ """), {"rid": run_id, "br": branch}).fetchall()
+ return JSONResponse([
+ {"id": e.id, "hostname": e.hostname, "command": e.patch_command}
+ for e in entries
+ ])
+
+
+@router.get("/quickwin/{run_id}/patch-stream")
+async def quickwin_patch_stream(request: Request, run_id: int, db=Depends(get_db),
+ branch: str = Query("hprod")):
+ """SSE: execute les commandes yum serveur par serveur via SSH"""
+ user = get_current_user(request)
+ if not user:
+ return StreamingResponse(iter([]), media_type="text/event-stream")
+
+ def event_generator():
+ from ..services.quickwin_prereq_service import _get_secret, _resolve_fqdn, _get_ssh_key_path, SSH_TIMEOUT, PSMP_HOST, CYBR_USER, TARGET_USER
+ from ..services.quickwin_log_service import log_info, log_success, log_error
+ import paramiko
+
+ by = user.get("display_name", user.get("username", "")) if user else ""
+
+ entries = db.execute(text("""
+ SELECT qe.id, s.hostname, qe.patch_command, s.ssh_method, s.domain_ltd,
+ e.code as env_code
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.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
+ WHERE qe.run_id = :rid AND qe.branch = :br
+ AND qe.status NOT IN ('excluded','skipped')
+ AND qe.patch_command IS NOT NULL AND qe.patch_command != ''
+ ORDER BY s.hostname
+ """), {"rid": run_id, "br": branch}).fetchall()
+
+ total = len(entries)
+ log_info(db, run_id, "patching",
+ f"Lancement patching {branch} ({total} serveurs)", created_by=by)
+ db.commit()
+
+ yield f"data: {json.dumps({'type':'start','total':total,'branch':branch})}\n\n"
+
+ ok_count = 0
+ ko_count = 0
+
+ for idx, e in enumerate(entries):
+ yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'command':e.patch_command,'status':'connecting'})}\n\n"
+
+ # Resoudre FQDN
+ fqdn, _dns_err = _resolve_fqdn(e.hostname, e.domain_ltd, e.env_code)
+ if not fqdn:
+ fqdn = e.hostname
+
+ # Connexion SSH
+ client = None
+ ssh_err = None
+ try:
+ ssh_method = e.ssh_method or "ssh_key"
+ if ssh_method == "ssh_key":
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ key_path = _get_ssh_key_path(db)
+ if not key_path:
+ raise Exception("Cle SSH non configuree (ssh_key_file dans Secrets)")
+ client.connect(fqdn, username="root", key_filename=key_path,
+ timeout=SSH_TIMEOUT, allow_agent=False, look_for_keys=False)
+ else:
+ password = _get_secret(db, "ssh_pwd_default_pass")
+ transport = paramiko.Transport((PSMP_HOST, 22))
+ psmp_user = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
+ transport.connect(username=psmp_user, password=password or "")
+ transport.set_keepalive(30)
+ client = paramiko.SSHClient()
+ client._transport = transport
+ except Exception as ex:
+ ssh_err = str(ex)
+
+ if ssh_err or not client:
+ ko_count += 1
+ detail = f"SSH echoue: {ssh_err or 'connexion impossible'}"
+ db.execute(text("""
+ UPDATE quickwin_entries SET status='failed', patch_output=:out,
+ patch_date=now(), updated_at=now() WHERE id=:id
+ """), {"id": e.id, "out": detail})
+ log_error(db, run_id, "patching", f"KO SSH: {e.hostname}",
+ detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
+ db.commit()
+ yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n"
+ continue
+
+ # Executer la commande
+ try:
+ yield f"data: {json.dumps({'type':'progress','idx':idx+1,'total':total,'hostname':e.hostname,'status':'executing','command':e.patch_command})}\n\n"
+
+ stdin, stdout, stderr = client.exec_command(e.patch_command, timeout=600)
+ output = stdout.read().decode('utf-8', errors='replace')
+ err_output = stderr.read().decode('utf-8', errors='replace')
+ exit_code = stdout.channel.recv_exit_status()
+
+ full_output = output
+ if err_output:
+ full_output += "\n--- STDERR ---\n" + err_output
+
+ # Parser la sortie yum/dnf
+ pkg_count = 0
+ packages = ""
+ summary_lines = []
+ upgrade_keywords = ("Updating", "Installing", "Upgrading",
+ "Mise à niveau", "Installation de", "Mise à jour")
+ result_keywords = ("Updated:", "Installed:", "Upgraded:",
+ "Mis à jour", "Installé", "Mis à niveau")
+ skip_keywords = ("vérification", "métadonnées", "Dernière",
+ "===", "---", "Dépendances résolues",
+ "Paquet ", "Architecture", "Version",
+ "Dépôt", "Taille")
+
+ for line in output.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if any(stripped.startswith(k) for k in result_keywords):
+ packages += stripped + "\n"
+ if any(stripped.startswith(k) for k in upgrade_keywords):
+ pkg_count += 1
+
+ # Construire un résumé pour le terminal SSE
+ for line in output.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if any(k in stripped for k in skip_keywords):
+ continue
+ summary_lines.append(stripped)
+
+ # Garder les lignes utiles (fin = résumé transaction)
+ if len(summary_lines) > 15:
+ summary_lines = summary_lines[-15:]
+ summary = "\n".join(summary_lines)
+
+ # Rien à faire ?
+ nothing = any(k in output for k in ("Rien à faire", "Nothing to do", "No packages marked"))
+ if nothing and pkg_count == 0:
+ summary = "Rien à faire — tous les paquets sont à jour."
+
+ reboot = "reboot" in output.lower() or "kernel" in output.lower()
+
+ if exit_code == 0:
+ ok_count += 1
+ status = "patched"
+ log_success(db, run_id, "patching",
+ f"OK: {e.hostname} ({pkg_count} paquets)",
+ detail=full_output[:500], entry_id=e.id,
+ hostname=e.hostname, created_by=by)
+ else:
+ ko_count += 1
+ status = "failed"
+ log_error(db, run_id, "patching",
+ f"KO: {e.hostname} (exit {exit_code})",
+ detail=full_output[:500], entry_id=e.id,
+ hostname=e.hostname, created_by=by)
+
+ db.execute(text("""
+ UPDATE quickwin_entries SET status=:st, patch_output=:out,
+ patch_packages_count=:pc, patch_packages=:pp,
+ reboot_required=:rb, patch_date=now(), updated_at=now()
+ WHERE id=:id
+ """), {"id": e.id, "st": status, "out": full_output[:5000],
+ "pc": pkg_count, "pp": packages[:2000], "rb": reboot})
+ db.commit()
+
+ yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':exit_code==0,'exit_code':exit_code,'packages':pkg_count,'reboot':reboot,'detail':summary[:1000]})}\n\n"
+
+ except Exception as ex:
+ ko_count += 1
+ detail = f"Erreur execution: {ex}"
+ db.execute(text("""
+ UPDATE quickwin_entries SET status='failed', patch_output=:out,
+ patch_date=now(), updated_at=now() WHERE id=:id
+ """), {"id": e.id, "out": detail})
+ log_error(db, run_id, "patching", f"KO: {e.hostname}",
+ detail=detail, entry_id=e.id, hostname=e.hostname, created_by=by)
+ db.commit()
+ yield f"data: {json.dumps({'type':'result','idx':idx+1,'total':total,'hostname':e.hostname,'ok':False,'detail':detail[:300]})}\n\n"
+ finally:
+ try:
+ client.close()
+ except Exception:
+ pass
+
+ log_info(db, run_id, "patching",
+ f"Fin patching {branch}: {ok_count} OK, {ko_count} KO sur {total}", created_by=by)
+ db.commit()
+ yield f"data: {json.dumps({'type':'done','ok':ok_count,'ko':ko_count,'total':total})}\n\n"
+
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
+
+
+@router.post("/quickwin/{run_id}/mark-patched")
+async def quickwin_mark_patched(request: Request, run_id: int, db=Depends(get_db),
+ entry_id: int = Form(0),
+ patch_status: str = Form("patched"),
+ packages_count: int = Form(0),
+ reboot_required: str = Form("false"),
+ patch_output: str = Form("")):
+ """Marque un serveur comme patche ou echoue"""
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ if entry_id:
+ from sqlalchemy import text
+ reboot = reboot_required == "true"
+ db.execute(text("""
+ UPDATE quickwin_entries SET status = :st, patch_packages_count = :pc,
+ reboot_required = :rb, patch_output = :po, patch_date = now(), updated_at = now()
+ WHERE id = :id
+ """), {"id": entry_id, "st": patch_status, "pc": packages_count,
+ "rb": reboot, "po": patch_output})
+ db.commit()
+ return RedirectResponse(url=f"/quickwin/{run_id}?msg=marked", status_code=303)
+
+
+# -- Logs --
+
+@router.get("/quickwin/{run_id}/logs", response_class=HTMLResponse)
+async def quickwin_logs_page(request: Request, run_id: int, db=Depends(get_db),
+ level: str = Query(""),
+ step: str = Query(""),
+ hostname: str = Query("")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ run = get_run(db, run_id)
+ if not run:
+ return RedirectResponse(url="/quickwin")
+ logs = get_logs(db, run_id, level=level or None, step=step or None,
+ hostname=hostname or None)
+ log_stats = get_log_stats(db, run_id)
+ stats_dict = {r.level: r.cnt for r in log_stats}
+ ctx = base_context(request, db, user)
+ ctx.update({
+ "app_name": APP_NAME, "run": run, "logs": logs,
+ "log_stats": stats_dict,
+ "total_logs": sum(stats_dict.values()),
+ "filters": {"level": level, "step": step, "hostname": hostname},
+ "msg": request.query_params.get("msg"),
+ })
+ return templates.TemplateResponse("quickwin_logs.html", ctx)
+
+
+@router.post("/quickwin/{run_id}/logs/clear")
+async def quickwin_logs_clear(request: Request, run_id: int, db=Depends(get_db),
+ clear_step: str = Form("")):
+ 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=f"/quickwin/{run_id}/logs")
+ clear_logs(db, run_id, step=clear_step or None)
+ return RedirectResponse(url=f"/quickwin/{run_id}/logs?msg=cleared", status_code=303)
+
+
# -- API JSON --
@router.post("/api/quickwin/entry/update")
@@ -277,9 +895,6 @@ async def quickwin_entry_update(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
- perms = get_user_perms(db, user)
- if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
- return JSONResponse({"error": "forbidden"}, 403)
body = await request.json()
entry_id = body.get("id")
field = body.get("field")
@@ -295,9 +910,6 @@ async def quickwin_inject_yum(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
- perms = get_user_perms(db, user)
- if not can_edit(perms, "campaigns"):
- return JSONResponse({"error": "forbidden"}, 403)
body = await request.json()
if not isinstance(body, list):
return JSONResponse({"error": "expected list"}, 400)
@@ -311,8 +923,90 @@ async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db))
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
- perms = get_user_perms(db, user)
- if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
- return JSONResponse({"error": "forbidden"}, 403)
ok = can_start_prod(db, run_id)
return JSONResponse({"can_start_prod": ok})
+
+
+# ========== CORRESPONDANCE HPROD ↔ PROD ==========
+
+@router.get("/quickwin/{run_id}/correspondance")
+async def quickwin_correspondance_page(request: Request, run_id: int, db=Depends(get_db),
+ search: str = Query(""), pair_filter: str = Query(""),
+ env_filter: str = Query(""), domain_filter: str = Query(""),
+ page: int = Query(1), per_page: int = Query(50)):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ run = get_run(db, run_id)
+ if not run:
+ return RedirectResponse(url="/quickwin")
+ pairs = get_correspondance(db, run_id, search=search or None,
+ pair_filter=pair_filter or None, env_filter=env_filter or None,
+ domain_filter=domain_filter or None)
+ available = get_available_prod_entries(db, run_id)
+ matched = sum(1 for p in pairs if p["is_matched"])
+ unmatched = sum(1 for p in pairs if not p["is_matched"])
+ anomalies = sum(1 for p in pairs if p["is_anomaly"])
+
+ # Get unfiltered totals for KPIs
+ all_pairs = get_correspondance(db, run_id) if (search or pair_filter or env_filter or domain_filter) else pairs
+ # Extract domain list for filter dropdown
+ domains_in_run = sorted(set(p["hprod_domaine"] for p in all_pairs if p["hprod_domaine"]))
+ total = len(all_pairs)
+ total_matched = sum(1 for p in all_pairs if p["is_matched"])
+ total_unmatched = sum(1 for p in all_pairs if not p["is_matched"])
+ total_anomalies = sum(1 for p in all_pairs if p["is_anomaly"])
+
+ # Pagination
+ per_page = max(10, min(per_page, 200))
+ total_filtered = len(pairs)
+ total_pages = max(1, (total_filtered + per_page - 1) // per_page)
+ page = max(1, min(page, total_pages))
+ start = (page - 1) * per_page
+ pairs_page = pairs[start:start + per_page]
+
+ ctx = base_context(request, db, user)
+ ctx.update({
+ "app_name": APP_NAME, "run": run, "pairs": pairs_page, "available": available,
+ "stats": {"total": total, "matched": total_matched, "unmatched": total_unmatched, "anomalies": total_anomalies},
+ "filters": {"search": search, "pair_filter": pair_filter, "env_filter": env_filter, "domain_filter": domain_filter},
+ "domains_in_run": domains_in_run,
+ "page": page, "per_page": per_page, "total_pages": total_pages, "total_filtered": total_filtered,
+ "msg": request.query_params.get("msg"),
+ })
+ return templates.TemplateResponse("quickwin_correspondance.html", ctx)
+
+
+@router.post("/quickwin/{run_id}/correspondance/auto")
+async def quickwin_correspondance_auto(request: Request, run_id: int, 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=f"/quickwin/{run_id}/correspondance")
+ m, u, a = compute_correspondance(db, run_id, user=user)
+ return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=auto&am={m}&au={u}&aa={a}", status_code=303)
+
+
+@router.post("/quickwin/{run_id}/correspondance/clear-all")
+async def quickwin_correspondance_clear(request: Request, run_id: int, db=Depends(get_db)):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ clear_all_pairs(db, run_id)
+ return RedirectResponse(url=f"/quickwin/{run_id}/correspondance?msg=cleared", status_code=303)
+
+
+@router.post("/api/quickwin/correspondance/set-pair")
+async def quickwin_set_pair_api(request: Request, db=Depends(get_db)):
+ user = get_current_user(request)
+ if not user:
+ return JSONResponse({"error": "unauthorized"}, 401)
+ body = await request.json()
+ hprod_id = body.get("hprod_id")
+ prod_id = body.get("prod_id") # 0 or null to clear
+ if not hprod_id:
+ return JSONResponse({"error": "missing hprod_id"}, 400)
+ set_prod_pair(db, hprod_id, prod_id if prod_id else None)
+ return JSONResponse({"ok": True})
diff --git a/app/routers/referentiel.py b/app/routers/referentiel.py
new file mode 100644
index 0000000..68e99ae
--- /dev/null
+++ b/app/routers/referentiel.py
@@ -0,0 +1,478 @@
+"""Router Referentiel — CRUD domaines, environnements, associations, zones"""
+from fastapi import APIRouter, Request, Depends, Form, Query
+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
+
+templates = Jinja2Templates(directory="app/templates")
+
+router = APIRouter()
+
+
+# =========================================================
+# PAGE PRINCIPALE (onglets)
+# =========================================================
+
+@router.get("/referentiel", response_class=HTMLResponse)
+def referentiel_page(request: Request, db=Depends(get_db),
+ tab: str = Query("domains")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_view(perms, "settings"):
+ return RedirectResponse(url="/dashboard")
+
+ can_modify = can_edit(perms, "settings")
+
+ # Domaines
+ domains = db.execute(text(
+ "SELECT id, name, code, description, default_excludes, default_patch_window, "
+ "default_patch_frequency, is_active, display_order FROM domains ORDER BY display_order, name"
+ )).fetchall()
+
+ # Environnements
+ envs = db.execute(text(
+ "SELECT id, name, code FROM environments ORDER BY id"
+ )).fetchall()
+
+ # Zones
+ zones = db.execute(text(
+ "SELECT id, name, description, is_dmz FROM zones ORDER BY id"
+ )).fetchall()
+
+ # Associations domain_environments
+ assocs = db.execute(text("""
+ SELECT de.id, d.name as domain_name, d.id as domain_id,
+ e.name as env_name, e.id as env_id,
+ de.responsable_nom, de.responsable_email,
+ de.referent_nom, de.referent_email,
+ de.patch_window, de.patch_excludes,
+ de.nb_servers, de.is_active
+ FROM domain_environments de
+ JOIN domains d ON de.domain_id = d.id
+ JOIN environments e ON de.environment_id = e.id
+ ORDER BY d.display_order, d.name, e.id
+ """)).fetchall()
+
+ # Domaines DNS (domain_ltd)
+ dns_domains = db.execute(text(
+ "SELECT id, name, description, is_active FROM domain_ltd_list ORDER BY name"
+ )).fetchall()
+
+ # Compteur serveurs par domain_ltd
+ dns_srv_counts = {}
+ rows = db.execute(text("""
+ SELECT dl.id, COUNT(s.id) as cnt FROM domain_ltd_list dl
+ LEFT JOIN servers s ON s.domain_ltd = dl.name
+ GROUP BY dl.id
+ """)).fetchall()
+ for r in rows:
+ dns_srv_counts[r.id] = r.cnt
+
+ # Compteur serveurs par domaine
+ dom_srv_counts = {}
+ rows = db.execute(text("""
+ SELECT d.id, COUNT(s.id) as cnt FROM domains d
+ LEFT JOIN domain_environments de ON de.domain_id = d.id
+ LEFT JOIN servers s ON s.domain_env_id = de.id
+ GROUP BY d.id
+ """)).fetchall()
+ for r in rows:
+ dom_srv_counts[r.id] = r.cnt
+
+ # Compteur serveurs par env
+ env_srv_counts = {}
+ rows = db.execute(text("""
+ SELECT e.id, COUNT(s.id) as cnt FROM environments e
+ LEFT JOIN domain_environments de ON de.environment_id = e.id
+ LEFT JOIN servers s ON s.domain_env_id = de.id
+ GROUP BY e.id
+ """)).fetchall()
+ for r in rows:
+ env_srv_counts[r.id] = r.cnt
+
+ # Compteur serveurs par zone
+ zone_srv_counts = {}
+ rows = db.execute(text("""
+ SELECT z.id, COUNT(s.id) as cnt FROM zones z
+ LEFT JOIN servers s ON s.zone_id = z.id
+ GROUP BY z.id
+ """)).fetchall()
+ for r in rows:
+ zone_srv_counts[r.id] = r.cnt
+
+ return templates.TemplateResponse("referentiel.html", {
+ "request": request, "user": user, "perms": perms,
+ "can_modify": can_modify, "tab": tab,
+ "domains": domains, "envs": envs, "zones": zones, "assocs": assocs,
+ "dns_domains": dns_domains, "dns_srv_counts": dns_srv_counts,
+ "dom_srv_counts": dom_srv_counts,
+ "env_srv_counts": env_srv_counts,
+ "zone_srv_counts": zone_srv_counts,
+ })
+
+
+# =========================================================
+# DOMAINES CRUD
+# =========================================================
+
+@router.post("/referentiel/domains/add")
+def domain_add(request: Request, db=Depends(get_db),
+ name: str = Form(...), code: str = Form(...),
+ description: str = Form(""),
+ default_excludes: str = Form(""),
+ default_patch_window: str = Form(""),
+ display_order: int = Form(0)):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=domains")
+
+ db.execute(text("""
+ INSERT INTO domains (name, code, description, default_excludes,
+ default_patch_window, display_order)
+ VALUES (:name, :code, :desc, :excl, :pw, :ord)
+ """), {"name": name.strip(), "code": code.strip().upper(),
+ "desc": description.strip(), "excl": default_excludes.strip(),
+ "pw": default_patch_window.strip(), "ord": display_order})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=domains&msg=added", status_code=303)
+
+
+@router.post("/referentiel/domains/{domain_id}/edit")
+def domain_edit(request: Request, domain_id: int, db=Depends(get_db),
+ name: str = Form(...), code: str = Form(...),
+ description: str = Form(""),
+ default_excludes: str = Form(""),
+ default_patch_window: str = Form(""),
+ display_order: int = Form(0),
+ is_active: str = Form("off")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=domains")
+
+ active = is_active == "on"
+ db.execute(text("""
+ UPDATE domains SET name=:name, code=:code, description=:desc,
+ default_excludes=:excl, default_patch_window=:pw,
+ display_order=:ord, is_active=:act, updated_at=now()
+ WHERE id=:id
+ """), {"id": domain_id, "name": name.strip(), "code": code.strip().upper(),
+ "desc": description.strip(), "excl": default_excludes.strip(),
+ "pw": default_patch_window.strip(), "ord": display_order, "act": active})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=domains&msg=updated", status_code=303)
+
+
+@router.post("/referentiel/domains/{domain_id}/delete")
+def domain_delete(request: Request, domain_id: int, 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, "settings"):
+ return RedirectResponse(url="/referentiel?tab=domains")
+
+ # Verifier s'il y a des serveurs lies
+ cnt = db.execute(text("""
+ SELECT COUNT(*) as c FROM servers s
+ JOIN domain_environments de ON s.domain_env_id = de.id
+ WHERE de.domain_id = :id
+ """), {"id": domain_id}).fetchone().c
+ if cnt > 0:
+ return RedirectResponse(
+ url=f"/referentiel?tab=domains&msg=nodelete&detail={cnt}",
+ status_code=303)
+
+ db.execute(text("DELETE FROM domain_environments WHERE domain_id = :id"), {"id": domain_id})
+ db.execute(text("DELETE FROM domains WHERE id = :id"), {"id": domain_id})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=domains&msg=deleted", status_code=303)
+
+
+# =========================================================
+# ENVIRONNEMENTS CRUD
+# =========================================================
+
+@router.post("/referentiel/envs/add")
+def env_add(request: Request, db=Depends(get_db),
+ name: str = Form(...), code: str = Form(...)):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=envs")
+
+ db.execute(text("""
+ INSERT INTO environments (name, code) VALUES (:name, :code)
+ """), {"name": name.strip(), "code": code.strip().upper()})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=envs&msg=added", status_code=303)
+
+
+@router.post("/referentiel/envs/{env_id}/edit")
+def env_edit(request: Request, env_id: int, db=Depends(get_db),
+ name: str = Form(...), code: str = Form(...)):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=envs")
+
+ db.execute(text("""
+ UPDATE environments SET name=:name, code=:code WHERE id=:id
+ """), {"id": env_id, "name": name.strip(), "code": code.strip().upper()})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=envs&msg=updated", status_code=303)
+
+
+@router.post("/referentiel/envs/{env_id}/delete")
+def env_delete(request: Request, env_id: int, 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, "settings"):
+ return RedirectResponse(url="/referentiel?tab=envs")
+
+ cnt = db.execute(text("""
+ SELECT COUNT(*) as c FROM servers s
+ JOIN domain_environments de ON s.domain_env_id = de.id
+ WHERE de.environment_id = :id
+ """), {"id": env_id}).fetchone().c
+ if cnt > 0:
+ return RedirectResponse(
+ url=f"/referentiel?tab=envs&msg=nodelete&detail={cnt}",
+ status_code=303)
+
+ db.execute(text("DELETE FROM domain_environments WHERE environment_id = :id"), {"id": env_id})
+ db.execute(text("DELETE FROM environments WHERE id = :id"), {"id": env_id})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=envs&msg=deleted", status_code=303)
+
+
+# =========================================================
+# ZONES CRUD
+# =========================================================
+
+@router.post("/referentiel/zones/add")
+def zone_add(request: Request, db=Depends(get_db),
+ name: str = Form(...), description: str = Form(""),
+ is_dmz: str = Form("off")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=zones")
+
+ db.execute(text("""
+ INSERT INTO zones (name, description, is_dmz) VALUES (:name, :desc, :dmz)
+ """), {"name": name.strip(), "desc": description.strip(), "dmz": is_dmz == "on"})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=zones&msg=added", status_code=303)
+
+
+@router.post("/referentiel/zones/{zone_id}/edit")
+def zone_edit(request: Request, zone_id: int, db=Depends(get_db),
+ name: str = Form(...), description: str = Form(""),
+ is_dmz: str = Form("off")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=zones")
+
+ db.execute(text("""
+ UPDATE zones SET name=:name, description=:desc, is_dmz=:dmz WHERE id=:id
+ """), {"id": zone_id, "name": name.strip(), "desc": description.strip(),
+ "dmz": is_dmz == "on"})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=zones&msg=updated", status_code=303)
+
+
+@router.post("/referentiel/zones/{zone_id}/delete")
+def zone_delete(request: Request, zone_id: int, 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, "settings"):
+ return RedirectResponse(url="/referentiel?tab=zones")
+
+ cnt = db.execute(text(
+ "SELECT COUNT(*) as c FROM servers WHERE zone_id = :id"
+ ), {"id": zone_id}).fetchone().c
+ if cnt > 0:
+ return RedirectResponse(
+ url=f"/referentiel?tab=zones&msg=nodelete&detail={cnt}",
+ status_code=303)
+
+ db.execute(text("DELETE FROM zones WHERE id = :id"), {"id": zone_id})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=zones&msg=deleted", status_code=303)
+
+
+# =========================================================
+# ASSOCIATIONS DOMAIN x ENV
+# =========================================================
+
+@router.post("/referentiel/assocs/add")
+def assoc_add(request: Request, db=Depends(get_db),
+ domain_id: int = Form(...), environment_id: int = Form(...),
+ responsable_nom: str = Form(""), responsable_email: str = Form(""),
+ referent_nom: str = Form(""), referent_email: str = Form(""),
+ patch_window: str = Form(""), patch_excludes: str = Form("")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=assocs")
+
+ existing = db.execute(text(
+ "SELECT id FROM domain_environments WHERE domain_id=:d AND environment_id=:e"
+ ), {"d": domain_id, "e": environment_id}).fetchone()
+ if existing:
+ return RedirectResponse(url="/referentiel?tab=assocs&msg=exists", status_code=303)
+
+ db.execute(text("""
+ INSERT INTO domain_environments (domain_id, environment_id, responsable_nom,
+ responsable_email, referent_nom, referent_email, patch_window, patch_excludes)
+ VALUES (:d, :e, :rn, :re, :fn, :fe, :pw, :pe)
+ """), {"d": domain_id, "e": environment_id,
+ "rn": responsable_nom.strip(), "re": responsable_email.strip(),
+ "fn": referent_nom.strip(), "fe": referent_email.strip(),
+ "pw": patch_window.strip(), "pe": patch_excludes.strip()})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=assocs&msg=added", status_code=303)
+
+
+@router.post("/referentiel/assocs/{assoc_id}/edit")
+def assoc_edit(request: Request, assoc_id: int, db=Depends(get_db),
+ responsable_nom: str = Form(""), responsable_email: str = Form(""),
+ referent_nom: str = Form(""), referent_email: str = Form(""),
+ patch_window: str = Form(""), patch_excludes: str = Form(""),
+ is_active: str = Form("off")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=assocs")
+
+ db.execute(text("""
+ UPDATE domain_environments SET responsable_nom=:rn, responsable_email=:re,
+ referent_nom=:fn, referent_email=:fe, patch_window=:pw,
+ patch_excludes=:pe, is_active=:act
+ WHERE id=:id
+ """), {"id": assoc_id,
+ "rn": responsable_nom.strip(), "re": responsable_email.strip(),
+ "fn": referent_nom.strip(), "fe": referent_email.strip(),
+ "pw": patch_window.strip(), "pe": patch_excludes.strip(),
+ "act": is_active == "on"})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=assocs&msg=updated", status_code=303)
+
+
+@router.post("/referentiel/assocs/{assoc_id}/delete")
+def assoc_delete(request: Request, assoc_id: int, 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, "settings"):
+ return RedirectResponse(url="/referentiel?tab=assocs")
+
+ cnt = db.execute(text(
+ "SELECT COUNT(*) as c FROM servers WHERE domain_env_id = :id"
+ ), {"id": assoc_id}).fetchone().c
+ if cnt > 0:
+ return RedirectResponse(
+ url=f"/referentiel?tab=assocs&msg=nodelete&detail={cnt}",
+ status_code=303)
+
+ db.execute(text("DELETE FROM domain_environments WHERE id = :id"), {"id": assoc_id})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=assocs&msg=deleted", status_code=303)
+
+
+# =========================================================
+# DOMAINES DNS (domain_ltd)
+# =========================================================
+
+@router.post("/referentiel/dns/add")
+def dns_add(request: Request, db=Depends(get_db),
+ name: str = Form(...), description: str = Form("")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=dns")
+
+ db.execute(text("""
+ INSERT INTO domain_ltd_list (name, description)
+ VALUES (:name, :desc)
+ """), {"name": name.strip().lower(), "desc": description.strip()})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=dns&msg=added", status_code=303)
+
+
+@router.post("/referentiel/dns/{dns_id}/edit")
+def dns_edit(request: Request, dns_id: int, db=Depends(get_db),
+ name: str = Form(...), description: str = Form(""),
+ is_active: str = Form("off")):
+ user = get_current_user(request)
+ if not user:
+ return RedirectResponse(url="/login")
+ perms = get_user_perms(db, user)
+ if not can_edit(perms, "settings"):
+ return RedirectResponse(url="/referentiel?tab=dns")
+
+ old = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone()
+ new_name = name.strip().lower()
+ db.execute(text("""
+ UPDATE domain_ltd_list SET name=:name, description=:desc, is_active=:act WHERE id=:id
+ """), {"id": dns_id, "name": new_name, "desc": description.strip(),
+ "act": is_active == "on"})
+ # Propager le renommage sur les serveurs
+ if old and old.name != new_name:
+ db.execute(text("UPDATE servers SET domain_ltd=:new WHERE domain_ltd=:old"),
+ {"old": old.name, "new": new_name})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=dns&msg=updated", status_code=303)
+
+
+@router.post("/referentiel/dns/{dns_id}/delete")
+def dns_delete(request: Request, dns_id: int, 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, "settings"):
+ return RedirectResponse(url="/referentiel?tab=dns")
+
+ row = db.execute(text("SELECT name FROM domain_ltd_list WHERE id=:id"), {"id": dns_id}).fetchone()
+ if row:
+ cnt = db.execute(text(
+ "SELECT COUNT(*) as c FROM servers WHERE domain_ltd = :n"
+ ), {"n": row.name}).fetchone().c
+ if cnt > 0:
+ return RedirectResponse(
+ url=f"/referentiel?tab=dns&msg=nodelete&detail={cnt}",
+ status_code=303)
+
+ db.execute(text("DELETE FROM domain_ltd_list WHERE id = :id"), {"id": dns_id})
+ db.commit()
+ return RedirectResponse(url="/referentiel?tab=dns&msg=deleted", status_code=303)
diff --git a/app/routers/servers.py b/app/routers/servers.py
index 7b5d80b..2257b1c 100644
--- a/app/routers/servers.py
+++ b/app/routers/servers.py
@@ -2,7 +2,7 @@
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
-from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit
+from ..dependencies import get_db, get_current_user
from ..services.server_service import (
get_server_full, get_server_tags, get_server_ips,
list_servers, update_server, get_reference_data
@@ -24,9 +24,6 @@ async def servers_list(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_view(perms, "servers"):
- return RedirectResponse(url="/dashboard")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
@@ -50,9 +47,6 @@ async def servers_export_csv(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_view(perms, "servers"):
- return RedirectResponse(url="/dashboard")
import io, csv
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search}
servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc")
@@ -78,10 +72,7 @@ async def servers_export_csv(request: Request, db=Depends(get_db),
async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
- return HTMLResponse("
Non autorise
", status_code=401)
- perms = get_user_perms(db, user)
- if not can_view(perms, "servers"):
- return HTMLResponse("Acces interdit
", status_code=403)
+ return HTMLResponse("Non autorise
")
s = get_server_full(db, server_id)
if not s:
return HTMLResponse("Serveur non trouve
")
@@ -96,17 +87,23 @@ async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
- return HTMLResponse("Non autorise
", status_code=401)
- perms = get_user_perms(db, user)
- if not can_edit(perms, "servers"):
- return HTMLResponse("Acces interdit
", status_code=403)
+ return HTMLResponse("Non autorise
")
s = get_server_full(db, server_id)
if not s:
return HTMLResponse("Serveur non trouve
")
domains, envs = get_reference_data(db)
ips = get_server_ips(db, server_id)
+ from sqlalchemy import text as sqlt
+ dns_list = db.execute(sqlt(
+ "SELECT name FROM domain_ltd_list WHERE is_active = true ORDER BY name"
+ )).fetchall()
+ zones_list = db.execute(sqlt(
+ "SELECT name FROM zones ORDER BY name"
+ )).fetchall()
return templates.TemplateResponse("partials/server_edit.html", {
- "request": request, "s": s, "domains": domains, "envs": envs, "ips": ips
+ "request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
+ "dns_list": [r.name for r in dns_list],
+ "zones_list": [r.name for r in zones_list],
})
@@ -118,15 +115,12 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
referent_nom: str = Form(None), mode_operatoire: str = Form(None),
commentaire: str = Form(None),
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
- ssh_method: str = Form(None),
+ ssh_method: str = Form(None), domain_ltd: str = Form(None),
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)):
user = get_current_user(request)
if not user:
- return HTMLResponse("Non autorise
", status_code=401)
- perms = get_user_perms(db, user)
- if not can_edit(perms, "servers"):
- return HTMLResponse("Acces interdit
", status_code=403)
+ return HTMLResponse("Non autorise
")
data = {
"domain_code": domain_code, "env_code": env_code, "zone": zone,
@@ -134,7 +128,7 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
"responsable_nom": responsable_nom, "referent_nom": referent_nom,
"mode_operatoire": mode_operatoire, "commentaire": commentaire,
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion,
- "ssh_method": ssh_method,
+ "ssh_method": ssh_method, "domain_ltd": domain_ltd,
"pref_patch_jour": pref_patch_jour, "pref_patch_heure": pref_patch_heure,
}
update_server(db, server_id, data, user.get("sub"))
@@ -154,9 +148,6 @@ async def servers_bulk(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, "servers"):
- return RedirectResponse(url="/servers", status_code=303)
if not server_ids or not bulk_field or not bulk_value:
return RedirectResponse(url="/servers", status_code=303)
@@ -207,10 +198,7 @@ async def servers_bulk(request: Request, db=Depends(get_db),
async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
- return HTMLResponse("Non autorise
", status_code=401)
- perms = get_user_perms(db, user)
- if not can_edit(perms, "servers"):
- return HTMLResponse("Acces interdit
", status_code=403)
+ return HTMLResponse("Non autorise
")
result = sync_server_qualys(db, server_id)
s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid) if s else []
diff --git a/app/services/quickwin_log_service.py b/app/services/quickwin_log_service.py
new file mode 100644
index 0000000..734fbf5
--- /dev/null
+++ b/app/services/quickwin_log_service.py
@@ -0,0 +1,73 @@
+"""Service logs QuickWin — journalisation des actions et erreurs par campagne"""
+from sqlalchemy import text
+
+
+def log_entry(db, run_id, step, level, message, detail=None,
+ entry_id=None, hostname=None, created_by=None):
+ """Ajoute une ligne de log pour un run QuickWin.
+ level: info, warn, error, success"""
+ db.execute(text("""
+ INSERT INTO quickwin_logs (run_id, entry_id, hostname, step, level, message, detail, created_by)
+ VALUES (:rid, :eid, :host, :step, :lvl, :msg, :det, :by)
+ """), {
+ "rid": run_id, "eid": entry_id, "host": hostname,
+ "step": step, "lvl": level, "msg": message,
+ "det": detail, "by": created_by,
+ })
+
+
+def log_info(db, run_id, step, message, **kwargs):
+ log_entry(db, run_id, step, "info", message, **kwargs)
+
+
+def log_warn(db, run_id, step, message, **kwargs):
+ log_entry(db, run_id, step, "warn", message, **kwargs)
+
+
+def log_error(db, run_id, step, message, **kwargs):
+ log_entry(db, run_id, step, "error", message, **kwargs)
+
+
+def log_success(db, run_id, step, message, **kwargs):
+ log_entry(db, run_id, step, "success", message, **kwargs)
+
+
+def get_logs(db, run_id, level=None, step=None, hostname=None, limit=500):
+ """Recupere les logs d'un run avec filtres optionnels."""
+ where = ["run_id = :rid"]
+ params = {"rid": run_id}
+ if level:
+ where.append("level = :lvl")
+ params["lvl"] = level
+ if step:
+ where.append("step = :step")
+ params["step"] = step
+ if hostname:
+ where.append("hostname ILIKE :host")
+ params["host"] = f"%{hostname}%"
+ params["lim"] = limit
+ return db.execute(text(f"""
+ SELECT * FROM quickwin_logs
+ WHERE {' AND '.join(where)}
+ ORDER BY created_at DESC
+ LIMIT :lim
+ """), params).fetchall()
+
+
+def get_log_stats(db, run_id):
+ """Compteurs par level pour un run."""
+ return db.execute(text("""
+ SELECT level, COUNT(*) as cnt
+ FROM quickwin_logs WHERE run_id = :rid
+ GROUP BY level ORDER BY level
+ """), {"rid": run_id}).fetchall()
+
+
+def clear_logs(db, run_id, step=None):
+ """Supprime les logs d'un run (optionnel: seulement une etape)."""
+ if step:
+ db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid AND step = :step"),
+ {"rid": run_id, "step": step})
+ else:
+ db.execute(text("DELETE FROM quickwin_logs WHERE run_id = :rid"), {"rid": run_id})
+ db.commit()
diff --git a/app/services/quickwin_prereq_service.py b/app/services/quickwin_prereq_service.py
new file mode 100644
index 0000000..6c8f0ae
--- /dev/null
+++ b/app/services/quickwin_prereq_service.py
@@ -0,0 +1,387 @@
+"""Service prereq QuickWin — resolution DNS, SSH, disque, satellite
+Adapte au contexte SANEF : PSMP CyberArk + SSH key selon methode serveur"""
+import socket
+import logging
+import os
+
+log = logging.getLogger("quickwin.prereq")
+
+try:
+ import paramiko
+ PARAMIKO_OK = True
+except ImportError:
+ PARAMIKO_OK = False
+ log.warning("paramiko non disponible — checks SSH impossibles")
+
+# --- Constantes ---
+DOMP = "sanef.groupe" # domaine prod/preprod/dev
+DOMR = "sanef-rec.fr" # domaine recette/test
+
+PSMP_HOST = "psmp.sanef.fr"
+CYBR_USER = "CYBP01336"
+TARGET_USER = "cybsecope"
+
+SSH_KEY_FILE_DEFAULT = "/opt/patchcenter/keys/id_rsa_cybglobal.pem"
+SSH_TIMEOUT = 15
+
+# Seuils disque (% utilise)
+DISK_MAX_PCT = 90 # >90% = KO
+
+# Banniere CyberArk a filtrer
+BANNER_FILTERS = [
+ "GROUPE SANEF", "propriete du Groupe", "accederait", "emprisonnement",
+ "Article 323", "code penal", "Authorized uses only", "CyberArk",
+ "This session", "session is being",
+]
+
+
+def _get_settings(db):
+ """Charge les settings utiles depuis la table settings"""
+ from sqlalchemy import text
+ rows = db.execute(text(
+ "SELECT key, value FROM settings WHERE key IN "
+ "('psmp_host','default_ssh_timeout','disk_min_root_mb')"
+ )).fetchall()
+ return {r.key: r.value for r in rows}
+
+
+def _get_secret(db, key):
+ """Recupere un secret dechiffre depuis app_secrets"""
+ try:
+ from ..services.secrets_service import get_secret
+ return get_secret(db, key)
+ except Exception:
+ return None
+
+
+# =========================================================
+# 1. RESOLUTION DNS
+# =========================================================
+
+def _detect_env(hostname):
+ """Detecte l'environnement par la 2e lettre du hostname (convention SANEF)
+ p=prod, i=preprod, r=recette, v/t=test, d=dev"""
+ if len(hostname) < 2:
+ return "unknown"
+ c = hostname[1].lower()
+ if c == "p":
+ return "prod"
+ elif c == "i":
+ return "preprod"
+ elif c == "r":
+ return "recette"
+ elif c in ("v", "t"):
+ return "test"
+ elif c == "d":
+ return "dev"
+ return "unknown"
+
+
+def _resolve_fqdn(hostname, domain_ltd=None, env_code=None):
+ """Resout le hostname en FQDN testable.
+ Retourne (fqdn, None) ou (None, error_msg).
+
+ Logique:
+ - Prod/Preprod/Dev: domp d'abord, puis domr
+ - Recette/Test: domr d'abord, puis domp
+ - Utilise domain_ltd si dispo, sinon detection par hostname
+ """
+ if "." in hostname:
+ # Deja un FQDN
+ if _dns_resolves(hostname):
+ return hostname, None
+ return None, f"FQDN {hostname} non resolvable"
+
+ # Determiner l'ordre des domaines
+ env = _detect_env(hostname)
+ if env_code:
+ ec = env_code.upper()
+ if ec in ("PRD", "PPR", "DEV"):
+ env = "prod"
+ elif ec in ("REC",):
+ env = "recette"
+ elif ec in ("TES", "TS1", "TS2"):
+ env = "test"
+
+ if env in ("prod", "preprod", "dev"):
+ domains_order = [DOMP, DOMR]
+ elif env in ("recette", "test"):
+ domains_order = [DOMR, DOMP]
+ else:
+ # Fallback: utiliser domain_ltd si dispo
+ if domain_ltd and domain_ltd.strip():
+ alt = DOMR if domain_ltd.strip() == DOMP else DOMP
+ domains_order = [domain_ltd.strip(), alt]
+ else:
+ domains_order = [DOMP, DOMR]
+
+ # Tenter resolution dans l'ordre
+ for dom in domains_order:
+ fqdn = f"{hostname}.{dom}"
+ if _dns_resolves(fqdn):
+ return fqdn, None
+
+ return None, f"DNS KO: {hostname} non resolu ({'/'.join(domains_order)})"
+
+
+def _dns_resolves(fqdn):
+ """Verifie si un FQDN se resout en IP"""
+ try:
+ socket.getaddrinfo(fqdn, 22, socket.AF_INET, socket.SOCK_STREAM)
+ return True
+ except (socket.gaierror, socket.herror, OSError):
+ return False
+
+
+# =========================================================
+# 2. TEST SSH
+# =========================================================
+
+def _get_ssh_key_path(db=None):
+ """Retourne le chemin de la cle SSH. Cherche d'abord dans app_secrets (ssh_key_file),
+ puis fallback sur le chemin par defaut."""
+ if db:
+ secret_path = _get_secret(db, "ssh_key_file")
+ if secret_path and secret_path.strip() and os.path.exists(secret_path.strip()):
+ return secret_path.strip()
+ if os.path.exists(SSH_KEY_FILE_DEFAULT):
+ return SSH_KEY_FILE_DEFAULT
+ return None
+
+
+def _load_ssh_key(db=None):
+ """Charge la cle SSH privee depuis le chemin configure en base ou par defaut"""
+ key_path = _get_ssh_key_path(db)
+ if not key_path:
+ return None
+ for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
+ try:
+ return cls.from_private_key_file(key_path)
+ except Exception:
+ continue
+ return None
+
+
+def _ssh_via_psmp(fqdn, password):
+ """Connexion SSH via PSMP CyberArk (interactive auth).
+ Username format: CYBP01336@cybsecope@fqdn"""
+ if not password:
+ return None, "PSMP: pas de mot de passe configure"
+ try:
+ username = f"{CYBR_USER}@{TARGET_USER}@{fqdn}"
+ transport = paramiko.Transport((PSMP_HOST, 22))
+ transport.connect()
+
+ def handler(title, instructions, prompt_list):
+ return [password] * len(prompt_list)
+
+ transport.auth_interactive(username, handler)
+ client = paramiko.SSHClient()
+ client._transport = transport
+ return client, None
+ except Exception as e:
+ return None, f"PSMP: {str(e)[:120]}"
+
+
+def _ssh_via_key(fqdn, ssh_user, key):
+ """Connexion SSH directe par cle"""
+ if not key:
+ return None, "SSH key: cle non trouvee"
+ if not ssh_user:
+ return None, "SSH key: user non configure"
+ try:
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ client.connect(fqdn, port=22, username=ssh_user, pkey=key,
+ timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
+ return client, None
+ except Exception as e:
+ return None, f"SSH key: {str(e)[:120]}"
+
+
+def _ssh_connect(fqdn, ssh_method, db):
+ """Connecte au serveur selon la methode (ssh_psmp ou ssh_key).
+ Retourne (client, error_msg)"""
+ if not PARAMIKO_OK:
+ return None, "paramiko non installe"
+
+ if ssh_method == "ssh_psmp":
+ # PSMP: password depuis app_secrets
+ password = _get_secret(db, "ssh_pwd_default_pass")
+ client, err = _ssh_via_psmp(fqdn, password)
+ if client:
+ return client, None
+ # Fallback: tenter par cle
+ key = _load_ssh_key(db)
+ ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER
+ client2, err2 = _ssh_via_key(fqdn, ssh_user, key)
+ if client2:
+ return client2, None
+ return None, err # retourner l'erreur PSMP originale
+ else:
+ # ssh_key: user depuis secrets, cle depuis fichier
+ ssh_user = _get_secret(db, "ssh_pwd_default_user") or TARGET_USER
+ key = _load_ssh_key(db)
+ client, err = _ssh_via_key(fqdn, ssh_user, key)
+ if client:
+ return client, None
+ # Fallback: tenter via PSMP
+ password = _get_secret(db, "ssh_pwd_default_pass")
+ if password:
+ client2, err2 = _ssh_via_psmp(fqdn, password)
+ if client2:
+ return client2, None
+ return None, err
+
+
+def _ssh_exec(client, cmd, timeout=12):
+ """Execute une commande via SSH et retourne (stdout, stderr, returncode).
+ Filtre les bannieres CyberArk."""
+ try:
+ chan = client._transport.open_session()
+ chan.settimeout(timeout)
+ chan.exec_command(cmd)
+ out = b""
+ err = b""
+ while True:
+ try:
+ chunk = chan.recv(8192)
+ if not chunk:
+ break
+ out += chunk
+ except Exception:
+ break
+ try:
+ err = chan.recv_stderr(8192)
+ except Exception:
+ pass
+ rc = chan.recv_exit_status()
+ chan.close()
+ # Filtrer bannieres
+ out_str = out.decode("utf-8", errors="replace")
+ lines = [l for l in out_str.splitlines() if not any(b in l for b in BANNER_FILTERS)]
+ return "\n".join(lines), err.decode("utf-8", errors="replace"), rc
+ except Exception as e:
+ return "", str(e), -1
+
+
+# =========================================================
+# 3. TEST ESPACE DISQUE
+# =========================================================
+
+def _check_disk(client):
+ """Verifie l'espace disque / et /var via sudo df.
+ Retourne (ok, detail_msg)"""
+ out, err, rc = _ssh_exec(client, "sudo df / /var --output=target,pcent 2>/dev/null | tail -n +2")
+ if rc != 0 or not out.strip():
+ return True, "Disque: non verifie (df echoue)"
+
+ ok = True
+ parts = []
+ for line in out.strip().splitlines():
+ tokens = line.split()
+ if len(tokens) >= 2:
+ mount = tokens[0]
+ pct_str = tokens[-1].replace("%", "").strip()
+ if pct_str.isdigit():
+ pct = int(pct_str)
+ if pct >= DISK_MAX_PCT:
+ ok = False
+ parts.append(f"{mount}={pct}% KO")
+ else:
+ parts.append(f"{mount}={pct}%")
+
+ if not parts:
+ return True, "Disque: non verifie"
+ return ok, "Disque: " + ", ".join(parts)
+
+
+# =========================================================
+# 4. TEST SATELLITE / YUM REPOS
+# =========================================================
+
+def _check_satellite(client):
+ """Verifie l'enregistrement Satellite et les repos YUM.
+ Retourne (ok, detail_msg)"""
+ # Tenter subscription-manager d'abord
+ out, err, rc = _ssh_exec(client, "sudo subscription-manager status 2>/dev/null | head -5")
+ if rc == 0 and "Current" in out:
+ return True, "Satellite: OK (subscription-manager)"
+
+ # Fallback: yum repolist
+ out2, err2, rc2 = _ssh_exec(client, "sudo yum repolist 2>/dev/null | tail -1")
+ if rc2 == 0 and out2.strip():
+ line = out2.strip()
+ # Si "repolist: 0" => pas de repos
+ if "repolist: 0" in line.lower():
+ return False, "Satellite: KO (0 repos)"
+ return True, f"Satellite: OK ({line[:60]})"
+
+ return False, "Satellite: KO (pas de repos ni subscription)"
+
+
+# =========================================================
+# ORCHESTRATEUR PRINCIPAL
+# =========================================================
+
+def check_server_prereqs(hostname, db, domain_ltd=None, env_code=None,
+ ssh_method="ssh_key"):
+ """Verification complete des prerequis d'un serveur QuickWin.
+
+ Etapes:
+ 1. Resolution DNS (domp/domr selon env)
+ 2. Test SSH (PSMP ou key selon ssh_method)
+ 3. Espace disque (sudo df)
+ 4. Satellite/YUM
+
+ Retourne dict:
+ dns_ok, ssh_ok, disk_ok, satellite_ok,
+ fqdn, detail, skip (True si serveur a ignorer)
+ """
+ result = {
+ "dns_ok": False, "ssh_ok": False, "disk_ok": False, "satellite_ok": False,
+ "fqdn": "", "detail": "", "skip": False,
+ }
+ detail_parts = []
+
+ # 1. Resolution DNS
+ fqdn, dns_err = _resolve_fqdn(hostname, domain_ltd, env_code)
+ if not fqdn:
+ result["detail"] = dns_err
+ result["skip"] = True
+ log.warning(f"[{hostname}] {dns_err}")
+ return result
+ result["dns_ok"] = True
+ result["fqdn"] = fqdn
+ detail_parts.append(f"DNS: OK ({fqdn})")
+
+ # 2. Test SSH
+ client, ssh_err = _ssh_connect(fqdn, ssh_method, db)
+ if not client:
+ detail_parts.append(f"SSH: KO ({ssh_err})")
+ result["detail"] = " | ".join(detail_parts)
+ result["skip"] = True
+ log.warning(f"[{hostname}] SSH KO: {ssh_err}")
+ return result
+ result["ssh_ok"] = True
+ method_label = "PSMP" if ssh_method == "ssh_psmp" else "key"
+ detail_parts.append(f"SSH: OK ({method_label})")
+
+ try:
+ # 3. Espace disque
+ disk_ok, disk_detail = _check_disk(client)
+ result["disk_ok"] = disk_ok
+ detail_parts.append(disk_detail)
+
+ # 4. Satellite
+ sat_ok, sat_detail = _check_satellite(client)
+ result["satellite_ok"] = sat_ok
+ detail_parts.append(sat_detail)
+ finally:
+ try:
+ client.close()
+ except Exception:
+ pass
+
+ result["detail"] = " | ".join(detail_parts)
+ return result
diff --git a/app/services/quickwin_service.py b/app/services/quickwin_service.py
index 115e5d0..d5848fc 100644
--- a/app/services/quickwin_service.py
+++ b/app/services/quickwin_service.py
@@ -1,6 +1,7 @@
"""Service QuickWin — gestion des campagnes + exclusions par serveur"""
import json
from sqlalchemy import text
+from .quickwin_log_service import log_info, log_warn, log_error, log_success
# Exclusions generales par defaut (reboot packages + middleware/apps)
DEFAULT_GENERAL_EXCLUDES = (
@@ -118,7 +119,7 @@ def get_run(db, run_id):
def get_run_entries(db, run_id):
return db.execute(text("""
- SELECT qe.*, s.hostname, s.os_family, s.machine_type,
+ SELECT qe.*, s.hostname, s.fqdn, s.os_family, s.machine_type,
d.name as domaine, e.name as environnement
FROM quickwin_entries qe
JOIN servers s ON qe.server_id = s.id
@@ -169,6 +170,218 @@ def delete_run(db, run_id):
db.commit()
+def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=None):
+ """Serveurs eligibles PAS encore dans ce run, avec multi-filtres.
+ domains/envs/zones: listes de noms (multi-select)."""
+ rows = db.execute(text("""
+ SELECT s.id, s.hostname, s.os_family, s.machine_type, s.domain_ltd,
+ d.name as domaine, e.name as environnement, e.code as env_code,
+ z.name as zone
+ 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 s.os_family = 'linux'
+ AND s.etat = 'en_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
+ """), {"rid": run_id}).fetchall()
+ filtered = rows
+ if search:
+ filtered = [r for r in filtered if search.lower() in r.hostname.lower()]
+ if domains:
+ filtered = [r for r in filtered if (r.domaine or '') in domains]
+ if envs:
+ filtered = [r for r in filtered if (r.environnement or '') in envs]
+ if zones:
+ filtered = [r for r in filtered if (r.zone or '') in zones]
+ return filtered
+
+
+def get_available_filters(db, run_id):
+ """Retourne les domaines, envs et zones disponibles (avec compteurs)."""
+ rows = db.execute(text("""
+ SELECT d.name as domaine, e.name as environnement, z.name as zone
+ 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 s.os_family = 'linux'
+ AND s.etat = 'en_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()
+ domains = sorted(set(r.domaine for r in rows if r.domaine))
+ envs = sorted(set(r.environnement for r in rows if r.environnement))
+ zones = sorted(set(r.zone for r in rows if r.zone))
+ # Compteurs
+ dom_counts = {}
+ for r in rows:
+ k = r.domaine or '?'
+ dom_counts[k] = dom_counts.get(k, 0) + 1
+ env_counts = {}
+ for r in rows:
+ k = r.environnement or '?'
+ env_counts[k] = env_counts.get(k, 0) + 1
+ zone_counts = {}
+ for r in rows:
+ k = r.zone or '?'
+ zone_counts[k] = zone_counts.get(k, 0) + 1
+ return domains, envs, zones, dom_counts, env_counts, zone_counts
+
+
+def add_entries_to_run(db, run_id, server_ids, user=None):
+ """Ajoute des serveurs a un run existant. Determine auto hprod/prod.
+ Retourne le nombre de serveurs ajoutes."""
+ existing = set(r.server_id for r in db.execute(text(
+ "SELECT server_id FROM quickwin_entries WHERE run_id = :rid"
+ ), {"rid": run_id}).fetchall())
+
+ by = user.get("display_name", user.get("username", "")) if user else ""
+ 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
+ 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})
+ added += 1
+ hostnames.append(srv.hostname)
+ if added:
+ log_info(db, run_id, "servers", f"{added} serveur(s) ajoute(s)",
+ detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
+ created_by=by)
+ db.commit()
+ return added
+
+
+def remove_entries_from_run(db, run_id, entry_ids, user=None):
+ """Supprime des entries d'un run. Retourne le nombre de suppressions."""
+ if not entry_ids:
+ return 0
+ by = user.get("display_name", user.get("username", "")) if user else ""
+ # Log hostnames avant suppression
+ rows = db.execute(text("""
+ SELECT s.hostname FROM quickwin_entries qe
+ JOIN servers s ON qe.server_id = s.id
+ WHERE qe.run_id = :rid AND qe.id = ANY(:ids)
+ """), {"rid": run_id, "ids": entry_ids}).fetchall()
+ hostnames = [r.hostname for r in rows]
+ result = db.execute(text(
+ "DELETE FROM quickwin_entries WHERE run_id = :rid AND id = ANY(:ids)"
+ ), {"rid": run_id, "ids": entry_ids})
+ removed = result.rowcount
+ if removed:
+ log_warn(db, run_id, "servers", f"{removed} serveur(s) supprime(s)",
+ detail=", ".join(hostnames[:20]) + ("..." if len(hostnames) > 20 else ""),
+ created_by=by)
+ db.commit()
+ return removed
+
+
+def get_campaign_scope(db, run_id):
+ """Retourne domaines, envs et zones presents dans le run avec compteurs."""
+ rows = db.execute(text("""
+ SELECT d.name as domaine, e.name as environnement, z.name as zone, qe.status
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.server_id = s.id
+ 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 qe.run_id = :rid
+ """), {"rid": run_id}).fetchall()
+ dom_counts = {}
+ env_counts = {}
+ zone_counts = {}
+ dom_active = {} # non-excluded count
+ zone_active = {} # non-excluded count
+ for r in rows:
+ d = r.domaine or '?'
+ e = r.environnement or '?'
+ z = r.zone or '?'
+ dom_counts[d] = dom_counts.get(d, 0) + 1
+ env_counts[e] = env_counts.get(e, 0) + 1
+ zone_counts[z] = zone_counts.get(z, 0) + 1
+ if r.status != 'excluded':
+ dom_active[d] = dom_active.get(d, 0) + 1
+ zone_active[z] = zone_active.get(z, 0) + 1
+ domains = sorted(k for k in dom_counts if k != '?')
+ envs = sorted(k for k in env_counts if k != '?')
+ zones = sorted(k for k in zone_counts if k != '?')
+ return {
+ "domains": domains, "envs": envs, "zones": zones,
+ "dom_counts": dom_counts, "env_counts": env_counts, "zone_counts": zone_counts,
+ "dom_active": dom_active, "zone_active": zone_active,
+ }
+
+
+def apply_scope(db, run_id, keep_domains=None, keep_zones=None, user=None):
+ """Applique le perimetre: serveurs hors domaines/zones selectionnes -> excluded.
+ Serveurs dans le perimetre -> pending (si etaient excluded)."""
+ by = user.get("display_name", user.get("username", "")) if user else ""
+
+ # Recuperer toutes les entries avec leur domaine/zone
+ entries = db.execute(text("""
+ SELECT qe.id, qe.status, s.hostname, d.name as domaine, z.name as zone
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.server_id = s.id
+ LEFT JOIN domain_environments de ON s.domain_env_id = de.id
+ LEFT JOIN domains d ON de.domain_id = d.id
+ LEFT JOIN zones z ON s.zone_id = z.id
+ WHERE qe.run_id = :rid
+ """), {"rid": run_id}).fetchall()
+
+ included = 0
+ excluded = 0
+ for e in entries:
+ in_scope = True
+ if keep_domains and (e.domaine or '') not in keep_domains:
+ in_scope = False
+ if keep_zones and (e.zone or '') not in keep_zones:
+ in_scope = False
+
+ if in_scope and e.status == 'excluded':
+ db.execute(text("UPDATE quickwin_entries SET status='pending', updated_at=now() WHERE id=:id"),
+ {"id": e.id})
+ included += 1
+ elif not in_scope and e.status != 'excluded':
+ db.execute(text("UPDATE quickwin_entries SET status='excluded', updated_at=now() WHERE id=:id"),
+ {"id": e.id})
+ excluded += 1
+
+ scope_desc = []
+ if keep_domains:
+ scope_desc.append(f"domaines: {', '.join(keep_domains)}")
+ if keep_zones:
+ scope_desc.append(f"zones: {', '.join(keep_zones)}")
+ log_info(db, run_id, "scope",
+ f"Perimetre applique: {included} inclus, {excluded} exclus",
+ detail=" | ".join(scope_desc) if scope_desc else "Tous",
+ created_by=by)
+ db.commit()
+ return included, excluded
+
+
def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
packages="", reboot_required=False, notes=""):
db.execute(text("""
@@ -207,21 +420,192 @@ def can_start_prod(db, run_id):
def get_run_stats(db, run_id):
return db.execute(text("""
SELECT
- COUNT(*) as total,
- COUNT(*) FILTER (WHERE branch = 'hprod') as hprod_total,
- COUNT(*) FILTER (WHERE branch = 'prod') as prod_total,
+ COUNT(*) FILTER (WHERE status != 'excluded') as total,
+ COUNT(*) FILTER (WHERE branch = 'hprod' AND status != 'excluded') as hprod_total,
+ COUNT(*) FILTER (WHERE branch = 'prod' AND status != 'excluded') as prod_total,
COUNT(*) FILTER (WHERE status = 'patched') as patched,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
COUNT(*) FILTER (WHERE status = 'pending') as pending,
+ COUNT(*) FILTER (WHERE status = 'pending' AND branch = 'hprod') as hprod_pending,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE branch = 'hprod' AND status = 'patched') as hprod_patched,
COUNT(*) FILTER (WHERE branch = 'prod' AND status = 'patched') as prod_patched,
- COUNT(*) FILTER (WHERE reboot_required) as reboot_count
+ COUNT(*) FILTER (WHERE reboot_required AND status != 'excluded') as reboot_count
FROM quickwin_entries WHERE run_id = :rid
"""), {"rid": run_id}).fetchone()
+def advance_run_status(db, run_id, target_status, user=None):
+ """Avance le statut du run vers l'etape suivante"""
+ VALID_TRANSITIONS = {
+ "draft": "prereq",
+ "prereq": "snapshot",
+ "snapshot": "patching",
+ "patching": "result",
+ "result": "completed",
+ }
+ run = db.execute(text("SELECT status FROM quickwin_runs WHERE id = :id"),
+ {"id": run_id}).fetchone()
+ if not run:
+ return False
+ by = user.get("display_name", user.get("username", "")) if user else ""
+ if target_status == "draft":
+ db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
+ {"st": target_status, "id": run_id})
+ log_info(db, run_id, "workflow", f"Retour a l'etape brouillon", created_by=by)
+ db.commit()
+ return True
+ expected = VALID_TRANSITIONS.get(run.status)
+ if expected != target_status:
+ log_warn(db, run_id, "workflow",
+ f"Transition refusee: {run.status} -> {target_status}", created_by=by)
+ db.commit()
+ return False
+ db.execute(text("UPDATE quickwin_runs SET status = :st, updated_at = now() WHERE id = :id"),
+ {"st": target_status, "id": run_id})
+ log_info(db, run_id, "workflow", f"Passage a l'etape: {target_status}", created_by=by)
+ db.commit()
+ return True
+
+
+def get_step_stats(db, run_id, branch=None):
+ """Stats par etape pour un run (optionnel: filtre par branch)"""
+ where = "run_id = :rid"
+ params = {"rid": run_id}
+ if branch:
+ where += " AND branch = :br"
+ params["br"] = branch
+ return db.execute(text(f"""
+ SELECT
+ COUNT(*) as total,
+ COUNT(*) FILTER (WHERE status NOT IN ('excluded','skipped')) as active,
+ COUNT(*) FILTER (WHERE prereq_ok = true) as prereq_ok,
+ COUNT(*) FILTER (WHERE prereq_ok = false) as prereq_ko,
+ COUNT(*) FILTER (WHERE prereq_ok IS NULL AND status NOT IN ('excluded','skipped')) as prereq_pending,
+ COUNT(*) FILTER (WHERE snap_done = true) as snap_ok,
+ COUNT(*) FILTER (WHERE snap_done = false AND status NOT IN ('excluded','skipped')) as snap_pending,
+ COUNT(*) FILTER (WHERE status = 'patched') as patched,
+ COUNT(*) FILTER (WHERE status = 'failed') as failed,
+ COUNT(*) FILTER (WHERE status = 'pending') as pending,
+ COUNT(*) FILTER (WHERE reboot_required = true) as reboot_count
+ FROM quickwin_entries WHERE {where}
+ """), params).fetchone()
+
+
+def check_prereqs(db, run_id, branch, user=None):
+ """Lance les verifications prerequis pour tous les serveurs d'une branche.
+ Etapes par serveur: DNS resolution, SSH, espace disque, satellite."""
+ from .quickwin_prereq_service import check_server_prereqs
+
+ entries = db.execute(text("""
+ SELECT qe.id, s.hostname, s.domain_ltd, s.ssh_method,
+ e.code as env_code
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.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
+ WHERE qe.run_id = :rid AND qe.branch = :br
+ AND qe.status NOT IN ('excluded','skipped')
+ ORDER BY s.hostname
+ """), {"rid": run_id, "br": branch}).fetchall()
+
+ by = user.get("display_name", user.get("username", "")) if user else ""
+ log_info(db, run_id, "prereq",
+ f"Lancement check prerequis {branch} ({len(entries)} serveurs)", created_by=by)
+
+ results = []
+ ok_count = 0
+ for e in entries:
+ try:
+ r = check_server_prereqs(
+ hostname=e.hostname,
+ db=db,
+ domain_ltd=e.domain_ltd,
+ env_code=e.env_code,
+ ssh_method=e.ssh_method or "ssh_key",
+ )
+ except Exception as ex:
+ r = {"dns_ok": False, "ssh_ok": False, "satellite_ok": False,
+ "disk_ok": False, "detail": str(ex), "skip": True}
+
+ prereq_ok = (r.get("dns_ok", False) and r.get("ssh_ok", False)
+ and r.get("satellite_ok", False) and r.get("disk_ok", True))
+ db.execute(text("""
+ UPDATE quickwin_entries SET
+ prereq_ok = :ok, prereq_ssh = :ssh, prereq_satellite = :sat, prereq_disk = :disk,
+ prereq_detail = :detail, prereq_date = now(), updated_at = now()
+ WHERE id = :id
+ """), {
+ "id": e.id, "ok": prereq_ok,
+ "ssh": r.get("ssh_ok", False), "sat": r.get("satellite_ok", False),
+ "disk": r.get("disk_ok", True), "detail": r.get("detail", ""),
+ })
+ # Log par serveur
+ detail_str = r.get("detail", "")
+ if prereq_ok:
+ ok_count += 1
+ log_success(db, run_id, "prereq", f"OK: {e.hostname}",
+ detail=detail_str, entry_id=e.id, hostname=e.hostname)
+ else:
+ log_error(db, run_id, "prereq", f"KO: {e.hostname}",
+ detail=detail_str, entry_id=e.id, hostname=e.hostname)
+ results.append({"hostname": e.hostname, "ok": prereq_ok, "detail": detail_str})
+
+ ko_count = len(results) - ok_count
+ log_info(db, run_id, "prereq",
+ f"Fin check {branch}: {ok_count} OK, {ko_count} KO sur {len(results)}", created_by=by)
+ db.commit()
+ return results
+
+
+def mark_snapshot(db, entry_id, done=True):
+ """Marque un serveur comme snapshot fait/pas fait"""
+ db.execute(text("""
+ UPDATE quickwin_entries SET snap_done = :done,
+ snap_date = CASE WHEN :done THEN now() ELSE NULL END,
+ updated_at = now() WHERE id = :id
+ """), {"id": entry_id, "done": done})
+ db.commit()
+
+
+def mark_all_snapshots(db, run_id, branch, done=True):
+ """Marque tous les serveurs d'une branche comme snapshot fait"""
+ db.execute(text("""
+ UPDATE quickwin_entries SET snap_done = :done,
+ snap_date = CASE WHEN :done THEN now() ELSE NULL END,
+ updated_at = now()
+ WHERE run_id = :rid AND branch = :br AND status NOT IN ('excluded','skipped')
+ """), {"rid": run_id, "br": branch, "done": done})
+ db.commit()
+
+
+def build_yum_commands(db, run_id, branch):
+ """Construit les commandes yum pour chaque serveur d'une branche.
+ Inclut tous les serveurs actifs (non excluded/skipped) avec snap fait."""
+ entries = db.execute(text("""
+ SELECT qe.id, s.hostname, qe.general_excludes, qe.specific_excludes
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.server_id = s.id
+ WHERE qe.run_id = :rid AND qe.branch = :br
+ AND qe.status NOT IN ('excluded','skipped')
+ AND qe.snap_done = true
+ ORDER BY s.hostname
+ """), {"rid": run_id, "br": branch}).fetchall()
+
+ commands = []
+ for e in entries:
+ excludes = (e.general_excludes or "") + " " + (e.specific_excludes or "")
+ parts = [p.strip() for p in excludes.split() if p.strip()]
+ exclude_args = " ".join(f"--exclude={p}" for p in parts)
+ cmd = f"yum update -y {exclude_args}".strip()
+ db.execute(text("UPDATE quickwin_entries SET patch_command = :cmd WHERE id = :id"),
+ {"cmd": cmd, "id": e.id})
+ commands.append({"id": e.id, "hostname": e.hostname, "command": cmd})
+ db.commit()
+ return commands
+
+
def inject_yum_history(db, data):
"""Injecte l'historique yum dans quickwin_server_config.
data = [{"server": "hostname", "yum_commands": [...]}]"""
@@ -252,3 +636,153 @@ def inject_yum_history(db, data):
inserted += 1
db.commit()
return updated, inserted
+
+
+# ========== CORRESPONDANCE HPROD ↔ PROD ==========
+
+def compute_correspondance(db, run_id, user=None):
+ """Auto-apparie chaque serveur hprod avec son homologue prod (2e lettre → p).
+ Retourne (matched, unmatched, anomalies)."""
+ by = user.get("display_name", user.get("username", "")) if user else ""
+
+ hprod_rows = db.execute(text("""
+ SELECT qe.id, LOWER(s.hostname) as hostname
+ FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
+ WHERE qe.run_id = :rid AND qe.branch = 'hprod' AND qe.status != 'excluded'
+ """), {"rid": run_id}).fetchall()
+
+ prod_rows = db.execute(text("""
+ SELECT qe.id, LOWER(s.hostname) as hostname
+ FROM quickwin_entries qe JOIN servers s ON qe.server_id = s.id
+ WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
+ """), {"rid": run_id}).fetchall()
+
+ prod_by_host = {r.hostname: r.id for r in prod_rows}
+ matched = 0
+ unmatched = 0
+ anomalies = 0
+ skipped = 0
+
+ # Existing pairs — ne pas toucher
+ existing = {r.id for r in db.execute(text("""
+ SELECT id FROM quickwin_entries
+ WHERE run_id = :rid AND branch = 'hprod' AND prod_pair_entry_id IS NOT NULL
+ """), {"rid": run_id}).fetchall()}
+
+ for h in hprod_rows:
+ if h.id in existing:
+ skipped += 1
+ continue
+ if len(h.hostname) < 2:
+ unmatched += 1
+ continue
+ candidate = h.hostname[0] + 'p' + h.hostname[2:]
+ if candidate == h.hostname:
+ anomalies += 1
+ if candidate in prod_by_host:
+ db.execute(text("""
+ UPDATE quickwin_entries SET prod_pair_entry_id = :pid WHERE id = :hid
+ """), {"pid": prod_by_host[candidate], "hid": h.id})
+ matched += 1
+ else:
+ unmatched += 1
+
+ log_info(db, run_id, "correspondance",
+ f"Auto-appariement: {matched} nouveaux, {skipped} conservés, {unmatched} sans homologue, {anomalies} anomalies",
+ created_by=by)
+ db.commit()
+ return matched, unmatched, anomalies
+
+
+def get_correspondance(db, run_id, search=None, pair_filter=None, env_filter=None, domain_filter=None):
+ """Retourne la liste des hprod avec leur homologue prod (ou NULL)."""
+ rows = db.execute(text("""
+ SELECT hp.id as hprod_id, sh.hostname as hprod_hostname,
+ dh.name as hprod_domaine, eh.name as hprod_env,
+ SUBSTRING(LOWER(sh.hostname), 2, 1) as letter2,
+ hp.prod_pair_entry_id,
+ pp.id as prod_id, sp.hostname as prod_hostname,
+ dp.name as prod_domaine
+ FROM quickwin_entries hp
+ JOIN servers sh ON hp.server_id = sh.id
+ LEFT JOIN domain_environments deh ON sh.domain_env_id = deh.id
+ LEFT JOIN domains dh ON deh.domain_id = dh.id
+ LEFT JOIN environments eh ON deh.environment_id = eh.id
+ LEFT JOIN quickwin_entries pp ON hp.prod_pair_entry_id = pp.id
+ LEFT JOIN servers sp ON pp.server_id = sp.id
+ LEFT JOIN domain_environments dep ON sp.domain_env_id = dep.id
+ LEFT JOIN domains dp ON dep.domain_id = dp.id
+ WHERE hp.run_id = :rid AND hp.branch = 'hprod' AND hp.status != 'excluded'
+ ORDER BY sh.hostname
+ """), {"rid": run_id}).fetchall()
+
+ result = []
+ for r in rows:
+ candidate = ""
+ if len(r.hprod_hostname) >= 2:
+ candidate = r.hprod_hostname[0] + 'p' + r.hprod_hostname[2:]
+ is_anomaly = (r.letter2 == 'p')
+ is_matched = r.prod_pair_entry_id is not None
+
+ if pair_filter == "matched" and not is_matched:
+ continue
+ if pair_filter == "unmatched" and is_matched:
+ continue
+ if pair_filter == "anomaly" and not is_anomaly:
+ continue
+ if env_filter:
+ env_map = {"preprod": "i", "recette": "r", "dev": "d", "test": "vt"}
+ allowed_letters = env_map.get(env_filter, "")
+ if r.letter2 not in allowed_letters:
+ continue
+ if domain_filter and (r.hprod_domaine or '') != domain_filter:
+ continue
+ if search and search.lower() not in r.hprod_hostname.lower():
+ if not (r.prod_hostname and search.lower() in r.prod_hostname.lower()):
+ continue
+
+ result.append({
+ "hprod_id": r.hprod_id,
+ "hprod_hostname": r.hprod_hostname,
+ "hprod_domaine": r.hprod_domaine or "",
+ "hprod_env": r.hprod_env or "",
+ "letter2": r.letter2,
+ "candidate": candidate,
+ "is_anomaly": is_anomaly,
+ "prod_id": r.prod_id,
+ "prod_hostname": r.prod_hostname or "",
+ "prod_domaine": r.prod_domaine or "",
+ "is_matched": is_matched,
+ })
+ return result
+
+
+def get_available_prod_entries(db, run_id):
+ """Retourne toutes les entries prod (un prod peut etre apparie a plusieurs hprod)."""
+ return db.execute(text("""
+ SELECT qe.id, s.hostname, d.name as domaine
+ FROM quickwin_entries qe
+ JOIN servers s ON qe.server_id = s.id
+ LEFT JOIN domain_environments de ON s.domain_env_id = de.id
+ LEFT JOIN domains d ON de.domain_id = d.id
+ WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status != 'excluded'
+ ORDER BY s.hostname
+ """), {"rid": run_id}).fetchall()
+
+
+def set_prod_pair(db, hprod_entry_id, prod_entry_id):
+ """Associe manuellement un hprod à un prod (ou NULL pour dissocier)."""
+ pid = prod_entry_id if prod_entry_id else None
+ db.execute(text("""
+ UPDATE quickwin_entries SET prod_pair_entry_id = :pid, updated_at = now() WHERE id = :hid
+ """), {"pid": pid, "hid": hprod_entry_id})
+ db.commit()
+
+
+def clear_all_pairs(db, run_id):
+ """Supprime tous les appariements d'un run."""
+ db.execute(text("""
+ UPDATE quickwin_entries SET prod_pair_entry_id = NULL, updated_at = now()
+ WHERE run_id = :rid AND branch = 'hprod'
+ """), {"rid": run_id})
+ db.commit()
diff --git a/app/services/quickwin_snapshot_service.py b/app/services/quickwin_snapshot_service.py
new file mode 100644
index 0000000..ea98385
--- /dev/null
+++ b/app/services/quickwin_snapshot_service.py
@@ -0,0 +1,144 @@
+"""Service snapshot QuickWin — prise de snapshots VM via vSphere/pyvmomi
+Ordre de recherche des VM sur les vCenters:
+ - Hors-prod: Senlis (vpgesavcs1) → Nanterre (vpmetavcs1) → DR (vpsicavcs1)
+ - Prod: Nanterre (vpmetavcs1) → Senlis (vpgesavcs1) → DR (vpsicavcs1)
+Physiques: pas de snapshot, alerte Commvault."""
+import ssl
+import logging
+from datetime import datetime
+
+log = logging.getLogger("quickwin.snapshot")
+
+try:
+ from pyVim.connect import SmartConnect, Disconnect
+ from pyVmomi import vim
+ PYVMOMI_OK = True
+except ImportError:
+ PYVMOMI_OK = False
+ log.warning("pyvmomi non disponible — snapshots impossibles")
+
+
+def _get_secret(db, key):
+ try:
+ from ..services.secrets_service import get_secret
+ return get_secret(db, key)
+ except Exception:
+ return None
+
+
+def _connect_vcenter(endpoint, user, password):
+ """Connexion a un vCenter. Retourne un ServiceInstance ou None."""
+ try:
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ctx.check_hostname = False
+ ctx.verify_mode = ssl.CERT_NONE
+ si = SmartConnect(host=endpoint, user=user, pwd=password, sslContext=ctx)
+ return si
+ except Exception as e:
+ log.warning(f"Connexion vCenter {endpoint} echouee: {e}")
+ return None
+
+
+def _find_vm(si, vm_name):
+ """Cherche une VM par nom dans le vCenter. Retourne l'objet VM ou None."""
+ content = si.RetrieveContent()
+ container = content.viewManager.CreateContainerView(
+ content.rootFolder, [vim.VirtualMachine], True)
+ try:
+ for vm in container.view:
+ if vm.name.lower() == vm_name.lower():
+ return vm
+ finally:
+ container.Destroy()
+ return None
+
+
+def _take_snapshot(vm, snap_name, description=""):
+ """Prend un snapshot de la VM. Retourne (ok, message)."""
+ try:
+ task = vm.CreateSnapshot_Task(
+ name=snap_name,
+ description=description,
+ memory=False,
+ quiesce=True,
+ )
+ # Attendre la fin du task
+ while task.info.state in (vim.TaskInfo.State.queued, vim.TaskInfo.State.running):
+ import time
+ time.sleep(2)
+ if task.info.state == vim.TaskInfo.State.success:
+ return True, "Snapshot OK"
+ else:
+ err = str(task.info.error) if task.info.error else "Echec inconnu"
+ return False, f"Snapshot echoue: {err}"
+ except Exception as e:
+ return False, f"Erreur snapshot: {e}"
+
+
+def get_vcenter_order(db, branch):
+ """Retourne la liste ordonnee des vCenters selon la branche.
+ hprod: Senlis → Nanterre → DR
+ prod: Nanterre → Senlis → DR"""
+ from sqlalchemy import text
+ vcenters = db.execute(text(
+ "SELECT id, name, endpoint FROM vcenters WHERE is_active = true ORDER BY id"
+ )).fetchall()
+
+ vc_map = {}
+ for vc in vcenters:
+ ep = vc.endpoint.lower()
+ if "vpgesavcs1" in ep:
+ vc_map["senlis"] = vc
+ elif "vpmetavcs1" in ep:
+ vc_map["nanterre"] = vc
+ elif "vpsicavcs1" in ep:
+ vc_map["dr"] = vc
+ else:
+ vc_map.setdefault("other", []).append(vc)
+
+ if branch == "prod":
+ order = [vc_map.get("nanterre"), vc_map.get("senlis"), vc_map.get("dr")]
+ else:
+ order = [vc_map.get("senlis"), vc_map.get("nanterre"), vc_map.get("dr")]
+
+ return [v for v in order if v is not None]
+
+
+def snapshot_server(hostname, vm_name, branch, db, snap_name=None):
+ """Prend un snapshot pour un serveur.
+ Cherche la VM sur les vCenters dans l'ordre selon la branche.
+ Retourne dict: {ok, vcenter, detail, skipped}"""
+ if not PYVMOMI_OK:
+ return {"ok": False, "vcenter": "", "detail": "pyvmomi non installe", "skipped": True}
+
+ vc_user = _get_secret(db, "vcenter_user")
+ vc_pass = _get_secret(db, "vcenter_pass")
+ if not vc_user or not vc_pass:
+ return {"ok": False, "vcenter": "", "detail": "Credentials vCenter manquants (vcenter_user/vcenter_pass dans Settings > Secrets)", "skipped": True}
+
+ search_name = vm_name or hostname
+ if not snap_name:
+ snap_name = f"QW_{datetime.now().strftime('%Y%m%d_%H%M')}"
+
+ vcenters = get_vcenter_order(db, branch)
+ if not vcenters:
+ return {"ok": False, "vcenter": "", "detail": "Aucun vCenter actif configure", "skipped": True}
+
+ for vc in vcenters:
+ si = _connect_vcenter(vc.endpoint, vc_user, vc_pass)
+ if not si:
+ continue
+ try:
+ vm = _find_vm(si, search_name)
+ if vm:
+ ok, msg = _take_snapshot(vm, snap_name,
+ description=f"QuickWin auto-snapshot {hostname}")
+ return {"ok": ok, "vcenter": vc.name, "detail": msg}
+ finally:
+ try:
+ Disconnect(si)
+ except Exception:
+ pass
+
+ tried = ", ".join(vc.name for vc in vcenters)
+ return {"ok": False, "vcenter": "", "detail": f"VM '{search_name}' non trouvee sur: {tried}"}
diff --git a/app/services/server_service.py b/app/services/server_service.py
index ba8f2d6..181e5d7 100644
--- a/app/services/server_service.py
+++ b/app/services/server_service.py
@@ -208,7 +208,7 @@ def update_server(db, server_id, data, username):
params = {"id": server_id}
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
"referent_nom", "mode_operatoire", "commentaire", "ssh_method",
- "pref_patch_jour", "pref_patch_heure"]
+ "domain_ltd", "pref_patch_jour", "pref_patch_heure"]
changed = []
for field in direct_fields:
if data.get(field) is not None:
diff --git a/app/templates/base.html b/app/templates/base.html
index d1191b3..425732e 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -61,7 +61,9 @@
{% if p.qualys %}Décodeur {% endif %}
{% if p.qualys %}Agents {% endif %}
{% if p.campaigns %}Safe Patching {% endif %}
- {% if p.campaigns or p.quickwin %}QuickWin {% endif %}
+ {% if p.campaigns or p.quickwin %}QuickWin {% endif %}
+ {% if p.campaigns or p.quickwin %}Config exclusions {% endif %}
+ {% if p.campaigns or p.quickwin %}Correspondance {% endif %}
{% if p.planning %}Planning {% endif %}
{% if p.audit %}Audit {% endif %}
{% if p.audit in ('edit', 'admin') %}Spécifique {% endif %}
@@ -70,6 +72,7 @@
{% if p.servers %}Contacts {% endif %}
{% if p.users %}Utilisateurs {% endif %}
{% if p.settings %}Settings {% endif %}
+ {% if p.settings %}Référentiel {% endif %}
diff --git a/app/templates/partials/server_edit.html b/app/templates/partials/server_edit.html
index 7e96307..ed93a6e 100644
--- a/app/templates/partials/server_edit.html
+++ b/app/templates/partials/server_edit.html
@@ -36,10 +36,17 @@
{% for e in envs %}{{ e.name }} {% endfor %}
+
+ Domaine DNS (domain_ltd)
+
+ -- Aucun --
+ {% for d in dns_list %}{{ d }} {% endfor %}
+
+
Zone
- {% for z in ['LAN','DMZ','EMV'] %}{{ z }} {% endfor %}
+ {% for z in zones_list %}{{ z }} {% endfor %}
@@ -70,18 +77,18 @@
- Mode opératoire
+ Mode opératoire
diff --git a/app/templates/quickwin_correspondance.html b/app/templates/quickwin_correspondance.html
new file mode 100644
index 0000000..2c7ee8d
--- /dev/null
+++ b/app/templates/quickwin_correspondance.html
@@ -0,0 +1,258 @@
+{% extends "base.html" %}
+{% block title %}Correspondance QuickWin #{{ run.id }}{% endblock %}
+
+{% macro qs(pg=page) -%}
+?page={{ pg }}&per_page={{ per_page }}&search={{ filters.search or '' }}&pair_filter={{ filters.pair_filter or '' }}&domain_filter={{ filters.domain_filter or '' }}&env_filter={{ filters.env_filter or '' }}
+{%- endmacro %}
+
+{% block content %}
+
+
+
← Retour campagne
+
Correspondance H-Prod ↔ Prod
+
{{ run.label }} — Appariement des serveurs hors-production avec leur homologue production
+
+
+
+
+
+
+
+{% if msg %}
+{% if msg == 'auto' %}
+{% set am = request.query_params.get('am', '0') %}
+{% set au = request.query_params.get('au', '0') %}
+{% set aa = request.query_params.get('aa', '0') %}
+
+ Auto-appariement terminé : {{ am }} apparié(s), {{ au }} sans homologue, {{ aa }} anomalie(s)
+
+{% elif msg == 'cleared' %}
+
+ Tous les appariements ont été supprimés.
+
+{% elif msg == 'bulk' %}
+{% set bc = request.query_params.get('bc', '0') %}
+
+ {{ bc }} appariement(s) modifié(s) en masse.
+
+{% endif %}
+{% endif %}
+
+
+
+
+
{{ stats.total }}
+
Total H-Prod
+
+
+
{{ stats.matched }}
+
Appariés
+
+
+
{{ stats.unmatched }}
+
Sans homologue
+
+
+
{{ stats.anomalies }}
+
Anomalies
+
+
+
+
+
+
+
+
+ 0 sélectionné(s)
+ Dissocier la sélection
+ |
+ Associer la sélection à :
+
+ -- Serveur prod --
+ {% for a in available %}
+ {{ a.hostname }}{% if a.domaine %} ({{ a.domaine }}){% endif %}
+ {% endfor %}
+
+ Associer
+
+
+
+
+
+
+{% if total_pages > 1 %}
+
+ {% if page > 1 %}
+
←
+ {% endif %}
+ {% for pg in range(1, total_pages + 1) %}
+ {% if pg == page %}
+
{{ pg }}
+ {% elif pg <= 3 or pg >= total_pages - 1 or (pg >= page - 1 and pg <= page + 1) %}
+
{{ pg }}
+ {% elif pg == 4 or pg == total_pages - 2 %}
+
…
+ {% endif %}
+ {% endfor %}
+ {% if page < total_pages %}
+
→
+ {% endif %}
+
+{% endif %}
+
+
+{% endblock %}
diff --git a/app/templates/quickwin_detail.html b/app/templates/quickwin_detail.html
index 6f9aeb8..8339efd 100644
--- a/app/templates/quickwin_detail.html
+++ b/app/templates/quickwin_detail.html
@@ -1,6 +1,18 @@
{% extends "base.html" %}
{% block title %}QuickWin #{{ run.id }}{% endblock %}
+{% set STEPS = [
+ ("draft", "Brouillon", "#94a3b8"),
+ ("prereq", "Pr\u00e9requis", "#00d4ff"),
+ ("snapshot", "Snapshot", "#a78bfa"),
+ ("patching", "Patching", "#ffcc00"),
+ ("result", "R\u00e9sultats", "#00ff88"),
+ ("completed", "Termin\u00e9", "#10b981"),
+] %}
+{% set current_step_idx = namespace(val=0) %}
+{% for s in STEPS %}{% if s[0] == run.status %}{% set current_step_idx.val = loop.index0 %}{% endif %}{% endfor %}
+{% set can_modify = run.status in ('draft', 'prereq') %}
+
{% macro qs(hp=hp_page, pp=p_page) -%}
?hp_page={{ hp }}&p_page={{ pp }}&per_page={{ per_page }}&search={{ filters.search or '' }}&status={{ filters.status or '' }}&domain={{ filters.domain or '' }}
{%- endmacro %}
@@ -10,18 +22,11 @@
← Retour campagnes
{{ run.label }}
-
S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }} — pas de reboot nécessaire
+
S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}
- {% if run.status == 'draft' %}
-
Brouillon
- {% elif run.status == 'hprod_done' %}
-
H-Prod terminé
- {% elif run.status == 'completed' %}
-
Terminé
- {% else %}
-
{{ run.status }}
- {% endif %}
+
Correspondance
+
Logs
@@ -32,7 +37,42 @@
{{ msg }}
{% endif %}
-
+
+
+
+ {% for step_id, step_label, step_color in STEPS %}
+ {% set idx = loop.index0 %}
+ {% set is_current = (step_id == run.status) %}
+ {% set is_done = (idx < current_step_idx.val) %}
+
+
+ {% if is_done %}✓{% else %}{{ idx + 1 }}{% endif %}
+
+
{{ step_label }}
+
+ {% if not loop.last %}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% if current_step_idx.val > 0 %}
+
+ {% else %}
{% endif %}
+ {% if current_step_idx.val < 5 %}
+
+ {% else %}
{% endif %}
+
+
+
+
{{ stats.total }}
@@ -60,67 +100,353 @@
-
+
+{% if run.status == 'draft' and scope %}
+
+
+
+ Périmètre de la campagne
+
+
Cochez les domaines et zones à inclure. Les serveurs hors périmètre seront marqués « Exclu ».
+
+
+
+{% endif %}
+
+
+
+{% if run.status == 'prereq' %}
+
+
Vérification des prérequis
+
Vérifie : résolution DNS, SSH (PSMP/Key), Satellite/YUM, espace disque (<90%)
+
+
+ {{ step_hp.prereq_ok }} OK
+ {{ step_hp.prereq_ko }} KO
+ {{ step_hp.prereq_pending }} en attente
+ (H-Prod)
+
+
+
+ Lancer check H-Prod
+ {% if prod_ok %}
+ Lancer check Prod
+ {% endif %}
+ Arrêter
+
+
+
+
+{% endif %}
+
+{% if run.status == 'snapshot' %}
+
+
Snapshots VM
+
Connexion vSphere → recherche VM → snapshot automatique. Les serveurs physiques sont ignorés (vérifier backup Commvault).
+
+
+ {{ step_hp.snap_ok }} fait(s)
+ {{ step_hp.snap_pending }} en attente
+ (H-Prod)
+
+
+
+ Prendre Snapshots H-Prod
+ {% if prod_ok %}
+ Prendre Snapshots Prod
+ {% endif %}
+
+ Arrêter
+ Ordre vCenter : H-Prod = Senlis → Nanterre → DR | Prod = Nanterre → Senlis → DR
+
+
+
+
+{% endif %}
+
+{% if run.status == 'patching' %}
+
+{% if prod_ok and stats.prod_total > 0 %}
+
+
+
Préparation Production
+
Avant de patcher la prod, lancez les checks prereq et snapshots sur les serveurs production.
+
+ Check Prereq Prod
+ Arrêter
+ Prendre Snapshots Prod
+ Arrêter
+
+
+
+
+
+
+
+{% endif %}
+
+
+
Exécution du patching
+
Étape 1 : Générer les commandes. Étape 2 : Vérifier. Étape 3 : Exécuter via SSH.
+
+
+
+
+ {% if prod_ok %}
+
+ {% endif %}
+ Voir commandes H-Prod
+ {% if prod_ok %}
+ Voir commandes Prod
+ {% endif %}
+
+
+
+
+
+
+
+ 2. Exécuter les commandes
+ Arrêter
+
+
+
+
+
+ Serveur
+ Commande
+
+
+
+
+
+
+
+
+
+{% endif %}
+
+{% if run.status == 'result' %}
+
+
Résultats
+
+
+
{{ stats.patched }}
+
Patchés
+
+
+
{{ stats.failed }}
+
KO
+
+
+
{{ stats.pending }}
+
En attente
+
+
+
{{ stats.reboot_count }}
+
Reboot
+
+
+
+{% endif %}
+
+{% if run.status == 'completed' %}
+
+
Campagne terminée
+
{{ stats.patched }} patché(s), {{ stats.failed }} KO, {{ stats.reboot_count }} reboot(s).
+
Télécharger le rapport
+
+{% endif %}
+
+
-
{% if not prod_ok %}
-
Hors-production d'abord : {{ stats.pending }} serveur(s) hprod en attente. Terminer le hprod avant de lancer le prod.
+
Hors-production d'abord : {{ stats.hprod_pending }} serveur(s) hprod en attente.
{% endif %}
-
+
+{% macro entry_table(rows, branch_label, branch_color, branch_key, page_num, total_pages, total_count) %}
-
HORS-PRODUCTION ({{ hprod_total }})
+
{{ branch_label }} ({{ total_count }})
- {{ hprod|selectattr('status','eq','patched')|list|length }} OK
- {{ hprod|selectattr('status','eq','failed')|list|length }} KO
- {{ hprod|selectattr('status','eq','pending')|list|length }} en attente
+ {{ rows|selectattr('status','eq','patched')|list|length }} OK
+ {{ rows|selectattr('status','eq','failed')|list|length }} KO
+ {{ rows|selectattr('status','eq','pending')|list|length }} en attente
+ {% if can_modify %}
+ Supprimer sélection
+ {% endif %}
+ {% if can_modify %} {% endif %}
Serveur
Domaine
Env
Statut
+ {% if run.status in ('prereq','snapshot','patching','result','completed') %}
+ Prereq
+ {% endif %}
+ {% if run.status in ('snapshot','patching','result','completed') %}
+ Snap
+ {% endif %}
Exclusions gén.
Exclusions spéc.
Packages
Date patch
Reboot
Notes
+ {% if run.status == 'patching' %}
+ Action
+ {% endif %}
- {% for e in hprod %}
+ {% for e in rows %}
- {{ e.hostname }}
+ {% if can_modify %} {% endif %}
+ {{ e.hostname }}
{{ e.domaine or '?' }}
{{ e.environnement or '?' }}
@@ -131,6 +457,27 @@
{% elif e.status == 'skipped' %}Ignoré
{% else %}En attente {% endif %}
+ {% if run.status in ('prereq','snapshot','patching','result','completed') %}
+
+ {% if e.prereq_ok == true %}✓
+ {% elif e.prereq_ok == false %}✗
+ {% else %}— {% endif %}
+
+ {% endif %}
+ {% if run.status in ('snapshot','patching','result','completed') %}
+
+ {% if e.snap_done %}✓
+ {% else %}
+ {% if run.status == 'snapshot' %}
+
+ {% else %}— {% endif %}
+ {% endif %}
+
+ {% endif %}
{{ e.general_excludes or '—' }}
@@ -143,91 +490,53 @@
{{ e.notes or '—' }}
+ {% if run.status == 'patching' %}
+
+ {% if e.status == 'pending' and e.prereq_ok and e.snap_done %}
+
+
+
+
+ {% endif %}
+
+ {% endif %}
{% endfor %}
- {% if not hprod %}Aucun serveur hors-production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %} {% endif %}
+ {% if not rows %}Aucun serveur{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %} {% endif %}
-
- {% if hp_total_pages > 1 %}
+ {% if total_pages > 1 %}
-
Page {{ hp_page }} / {{ hp_total_pages }} — {{ hprod_total }} serveur(s)
+
Page {{ page_num }} / {{ total_pages }} — {{ total_count }} serveur(s)
- {% if hp_page > 1 %}
Précédent {% endif %}
- {% if hp_page < hp_total_pages %}
Suivant {% endif %}
+ {% if branch_key == 'hp' %}
+ {% if page_num > 1 %}
Préc. {% endif %}
+ {% if page_num < total_pages %}
Suiv. {% endif %}
+ {% else %}
+ {% if page_num > 1 %}
Préc. {% endif %}
+ {% if page_num < total_pages %}
Suiv. {% endif %}
+ {% endif %}
{% endif %}
+{% endmacro %}
+
+
+{{ entry_table(hprod, "HORS-PRODUCTION", "#00d4ff", "hp", hp_page, hp_total_pages, hprod_total) }}
{% if prod_ok %}
-
-
-
PRODUCTION ({{ prod_total }})
-
- {{ prod|selectattr('status','eq','patched')|list|length }} OK
- {{ prod|selectattr('status','eq','failed')|list|length }} KO
- {{ prod|selectattr('status','eq','pending')|list|length }} en attente
-
-
-
-
-
- Serveur
- Domaine
- Env
- Statut
- Exclusions gén.
- Exclusions spéc.
- Packages
- Date patch
- Reboot
- Notes
-
-
- {% for e in prod %}
-
- {{ e.hostname }}
- {{ e.domaine or '?' }}
- {{ e.environnement or '?' }}
-
- {% if e.status == 'patched' %}Patché
- {% elif e.status == 'failed' %}KO
- {% elif e.status == 'in_progress' %}En cours
- {% elif e.status == 'excluded' %}Exclu
- {% else %}En attente {% endif %}
-
-
- {{ e.general_excludes or '—' }}
-
-
- {{ e.specific_excludes or '—' }}
-
- {{ e.patch_packages_count or '—' }}
- {{ e.patch_date.strftime('%d/%m %H:%M') if e.patch_date else '—' }}
- {% if e.reboot_required %}OUI {% else %}—{% endif %}
-
- {{ e.notes or '—' }}
-
-
- {% endfor %}
- {% if not prod %}Aucun serveur production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %} {% endif %}
-
-
-
-
- {% if p_total_pages > 1 %}
-
-
Page {{ p_page }} / {{ p_total_pages }} — {{ prod_total }} serveur(s)
-
- {% if p_page > 1 %}
Précédent {% endif %}
- {% if p_page < p_total_pages %}
Suivant {% endif %}
-
-
- {% endif %}
-
+{{ entry_table(prod, "PRODUCTION", "#ffcc00", "pr", p_page, p_total_pages, prod_total) }}
{% endif %}
{% if run.notes %}
@@ -237,13 +546,81 @@
{% endif %}
+
+
+
{% endblock %}
diff --git a/app/templates/quickwin_logs.html b/app/templates/quickwin_logs.html
new file mode 100644
index 0000000..53a6ef2
--- /dev/null
+++ b/app/templates/quickwin_logs.html
@@ -0,0 +1,107 @@
+{% extends "base.html" %}
+{% block title %}Logs QuickWin #{{ run.id }}{% endblock %}
+
+{% block content %}
+
+
+
← Retour campagne
+
Logs — {{ run.label }}
+
S{{ '%02d'|format(run.week_number) }} {{ run.year }} — {{ total_logs }} entrée(s)
+
+
+
+
+{% if msg %}
+
{{ msg }}
+{% endif %}
+
+
+
+
+
{{ total_logs }}
+
Total
+
+
+
{{ log_stats.get('success', 0) }}
+
Success
+
+
+
{{ log_stats.get('info', 0) }}
+
Info
+
+
+
{{ log_stats.get('warn', 0) }}
+
Warn
+
+
+
{{ log_stats.get('error', 0) }}
+
Error
+
+
+
+
+
+
+
+
+
+
+
+ Date
+ Niveau
+ Étape
+ Hostname
+ Message
+ Par
+
+
+ {% for l in logs %}
+
+ {{ l.created_at.strftime('%d/%m %H:%M:%S') }}
+
+ {% if l.level == 'success' %}OK
+ {% elif l.level == 'info' %}INFO
+ {% elif l.level == 'warn' %}WARN
+ {% elif l.level == 'error' %}ERR
+ {% else %}{{ l.level }} {% endif %}
+
+ {{ l.step }}
+ {{ l.hostname or '' }}
+
+ {{ l.message }}
+ {% if l.detail %}
+ {{ l.detail }}
+ {% endif %}
+
+ {{ l.created_by or '' }}
+
+ {% endfor %}
+ {% if not logs %}
+ Aucun log{% if filters.level or filters.step or filters.hostname %} pour ces filtres{% endif %}
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/app/templates/referentiel.html b/app/templates/referentiel.html
new file mode 100644
index 0000000..3e694a4
--- /dev/null
+++ b/app/templates/referentiel.html
@@ -0,0 +1,408 @@
+{% extends "base.html" %}
+{% block title %}Référentiel{% endblock %}
+
+{% block content %}
+
+
+
Référentiel
+
Gestion centralisée des domaines, environnements, zones et associations
+
+
+
+{% set msg = request.query_params.get('msg', '') %}
+{% set detail = request.query_params.get('detail', '') %}
+{% if msg == 'added' %}
+
+ Elément ajouté avec succès.
+
+{% elif msg == 'updated' %}
+
+ Elément mis à jour.
+
+{% elif msg == 'deleted' %}
+
+ Elément supprimé.
+
+{% elif msg == 'nodelete' %}
+
+ Suppression impossible : {{ detail }} serveur(s) lié(s). Dissociez-les d'abord.
+
+{% elif msg == 'exists' %}
+
+ Cette association domaine × environnement existe déjà.
+
+{% endif %}
+
+
+
+ {% for t, label in [('domains','Domaines'), ('envs','Environnements'), ('assocs','Associations'), ('zones','Zones'), ('dns','Domaines DNS')] %}
+
+ {{ label }}
+
+ {% endfor %}
+
+
+
+
+
+{% if tab == 'domains' %}
+
+
+
+ ID
+ Nom
+ Code
+ Description
+ Ordre
+ Actif
+ Serveurs
+ Actions
+
+
+ {% for d in domains %}
+
+
+ {% endfor %}
+
+
+
+
+{% if can_modify %}
+
+{% endif %}
+
+
+
+
+{% elif tab == 'envs' %}
+
+
+{% if can_modify %}
+
+{% endif %}
+
+
+
+
+{% elif tab == 'assocs' %}
+
+
+{% if can_modify %}
+
+{% endif %}
+
+
+
+
+{% elif tab == 'zones' %}
+
+
+{% if can_modify %}
+
+{% endif %}
+
+
+
+
+{% elif tab == 'dns' %}
+
+
+
Suffixes DNS utilisés dans le champ domain_ltd des serveurs (ex: sanef.groupe, sanef-rec.fr)
+
+
+
+
+{% if can_modify %}
+
+{% endif %}
+
+{% endif %}
+{% endblock %}