From 677f621c81469b5a990f147598f751562cc7864f Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Mon, 13 Apr 2026 21:11:58 +0200 Subject: [PATCH] Admin applications + correspondance cleanup + tools presentation DSI - Admin applications: CRUD module (list/add/edit/delete/assign/multi-app) avec push iTop bidirectionnel (applications.py + 3 templates) - Correspondance prod<->hors-prod: migration vers server_correspondance globale, suppression ancien code quickwin, ajout filtre environnement et solution applicative, colonne environnement dans builder - Servers page: colonne application_name + equivalent(s) via get_links_bulk, filtre application_id, push iTop sur changement application - Patching: bulk_update_application, bulk_update_excludes, validations - Fix paramiko sftp.put (remote_path -> positional arg) - Tools: wiki_to_pdf.py (DokuWiki -> PDF) + generate_ppt.py (PPTX 19 slides DSI patching) + contenu source (processus_patching.txt, script_presentation.txt) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/main.py | 3 +- app/routers/applications.py | 481 +++++++++++ app/routers/patching.py | 2 + app/routers/quickwin.py | 100 +-- app/routers/servers.py | 5 +- app/services/agent_deploy_service.py | 2 +- app/services/correspondance_service.py | 8 +- app/services/itop_service.py | 7 +- app/services/quickwin_service.py | 153 +--- app/services/server_service.py | 17 +- app/templates/admin_applications.html | 184 ++++ app/templates/admin_applications_assign.html | 126 +++ app/templates/admin_applications_multi.html | 79 ++ app/templates/base.html | 4 +- app/templates/partials/server_detail.html | 2 +- app/templates/partials/server_edit.html | 2 +- app/templates/patching_correspondance.html | 15 +- app/templates/qualys_agents.html | 2 +- app/templates/quickwin_correspondance.html | 258 ------ app/templates/quickwin_detail.html | 2 +- app/templates/servers.html | 6 +- tools/generate_ppt.py | 834 +++++++++++++++++++ tools/processus_patching.txt | 335 ++++++++ tools/script_presentation.txt | 391 +++++++++ tools/wiki_to_pdf.py | 362 ++++++++ 25 files changed, 2848 insertions(+), 532 deletions(-) create mode 100644 app/routers/applications.py create mode 100644 app/templates/admin_applications.html create mode 100644 app/templates/admin_applications_assign.html create mode 100644 app/templates/admin_applications_multi.html delete mode 100644 app/templates/quickwin_correspondance.html create mode 100644 tools/generate_ppt.py create mode 100644 tools/processus_patching.txt create mode 100644 tools/script_presentation.txt create mode 100644 tools/wiki_to_pdf.py diff --git a/app/main.py b/app/main.py index c437b1c..0cfd835 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, SessionLocalDemo -from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching +from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, quickwin, referentiel, patching, applications class PermissionsMiddleware(BaseHTTPMiddleware): @@ -63,6 +63,7 @@ app.include_router(qualys.router) app.include_router(quickwin.router) app.include_router(referentiel.router) app.include_router(patching.router) +app.include_router(applications.router) @app.get("/") diff --git a/app/routers/applications.py b/app/routers/applications.py new file mode 100644 index 0000000..750f812 --- /dev/null +++ b/app/routers/applications.py @@ -0,0 +1,481 @@ +"""Router Administration — gestion des applications (solutions applicatives). + +Catalogue applications local + sync bidirectionnelle avec iTop ApplicationSolution. +""" +from fastapi import APIRouter, Request, Depends, Query, Form +from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import text +from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context +from ..config import APP_NAME + +router = APIRouter() +templates = Jinja2Templates(directory="app/templates") + + +CRIT_CHOICES = [ + ("critique", "Critique"), + ("haute", "Haute"), + ("standard", "Standard"), + ("basse", "Basse"), +] +STATUS_CHOICES = [ + ("active", "Active"), + ("obsolete", "Obsolete"), + ("implementation", "En implémentation"), +] + +# Reverse map pour push iTop (iTop utilise low/medium/high/critical) +CRIT_TO_ITOP = {"critique": "critical", "haute": "high", "standard": "medium", "basse": "low"} + + +def _check_admin(request, db): + user = get_current_user(request) + if not user: + return None, None, RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_view(perms, "settings") and not can_admin(perms, "users"): + return None, None, RedirectResponse(url="/dashboard") + return user, perms, None + + +def _push_itop(db, itop_id, fields, operation="update"): + """Push vers iTop (best effort). operation : 'update' | 'delete' | 'create'.""" + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if not (url and u and p): + return {"pushed": False, "msg": "Credentials iTop manquants"} + client = ITopClient(url, u, p) + if operation == "update": + r = client.update("ApplicationSolution", itop_id, fields) + elif operation == "create": + r = client.create("ApplicationSolution", fields) + if r.get("code") == 0 and r.get("objects"): + new_id = list(r["objects"].values())[0]["key"] + return {"pushed": True, "itop_id": int(new_id), "msg": "Créée dans iTop"} + elif operation == "delete": + r = client._call("core/delete", **{"class": "ApplicationSolution", + "key": str(itop_id), "comment": "PatchCenter delete"}) + if r.get("code") == 0: + return {"pushed": True, "msg": "iTop OK"} + return {"pushed": False, "msg": (r.get("message") or "")[:120]} + except Exception as e: + return {"pushed": False, "msg": str(e)[:120]} + + +@router.get("/admin/applications", response_class=HTMLResponse) +async def applications_page(request: Request, db=Depends(get_db), + search: str = Query(""), criticite: str = Query(""), + status: str = Query(""), has_itop: str = Query(""), + domain: str = Query("")): + user, perms, redirect = _check_admin(request, db) + if redirect: + return redirect + + where = ["1=1"] + params = {} + if search: + where.append("(a.nom_court ILIKE :s OR a.nom_complet ILIKE :s)") + params["s"] = f"%{search}%" + if criticite: + where.append("a.criticite = :c"); params["c"] = criticite + if status: + where.append("a.status = :st"); params["st"] = status + if has_itop == "yes": + where.append("a.itop_id IS NOT NULL") + elif has_itop == "no": + where.append("a.itop_id IS NULL") + if domain: + where.append("""a.id IN ( + SELECT DISTINCT s.application_id 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 + WHERE d.name = :dom AND s.application_id IS NOT NULL + )""") + params["dom"] = domain + wc = " AND ".join(where) + + apps = db.execute(text(f""" + SELECT a.id, a.itop_id, a.nom_court, a.nom_complet, a.description, + a.criticite, a.status, a.editeur, a.created_at, a.updated_at, + (SELECT COUNT(*) FROM servers s WHERE s.application_id = a.id) as nb_servers, + (SELECT string_agg(DISTINCT d.name, ', ' ORDER BY d.name) + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + WHERE s.application_id = a.id AND d.name IS NOT NULL) as domains + FROM applications a + WHERE {wc} + ORDER BY a.nom_court + """), params).fetchall() + + domains_list = db.execute(text("SELECT name FROM domains ORDER BY name")).fetchall() + + stats = { + "total": db.execute(text("SELECT COUNT(*) FROM applications")).scalar() or 0, + "from_itop": db.execute(text("SELECT COUNT(*) FROM applications WHERE itop_id IS NOT NULL")).scalar() or 0, + "used": db.execute(text("SELECT COUNT(*) FROM applications WHERE id IN (SELECT DISTINCT application_id FROM servers WHERE application_id IS NOT NULL)")).scalar() or 0, + } + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "apps": apps, "stats": stats, + "crit_choices": CRIT_CHOICES, "status_choices": STATUS_CHOICES, + "domains_list": [d.name for d in domains_list], + "filters": {"search": search, "criticite": criticite, "status": status, + "has_itop": has_itop, "domain": domain}, + "can_edit": can_admin(perms, "users") or can_edit(perms, "settings"), + "msg": request.query_params.get("msg", ""), + }) + return templates.TemplateResponse("admin_applications.html", ctx) + + +@router.post("/admin/applications/add") +async def applications_add(request: Request, db=Depends(get_db), + nom_court: str = Form(...), nom_complet: str = Form(""), + description: str = Form(""), editeur: str = Form(""), + criticite: str = Form("basse"), status: str = Form("active"), + push_itop: str = Form("")): + user, perms, redirect = _check_admin(request, db) + if redirect: + return redirect + if not (can_admin(perms, "users") or can_edit(perms, "settings")): + return RedirectResponse(url="/admin/applications?msg=forbidden", status_code=303) + + name = nom_court.strip()[:50] + full = (nom_complet.strip() or name)[:200] + + existing = db.execute(text("SELECT id FROM applications WHERE LOWER(nom_court)=LOWER(:n)"), + {"n": name}).fetchone() + if existing: + return RedirectResponse(url="/admin/applications?msg=exists", status_code=303) + + itop_id = None + if push_itop == "on": + r = _push_itop(db, None, { + "name": name, + "description": description.strip()[:500], + "business_criticity": CRIT_TO_ITOP.get(criticite, "low"), + "status": status, + }, operation="create") + if r.get("pushed") and r.get("itop_id"): + itop_id = r["itop_id"] + + db.execute(text("""INSERT INTO applications (nom_court, nom_complet, description, + editeur, criticite, status, itop_id) + VALUES (:n, :nc, :d, :e, :c, :s, :iid)"""), + {"n": name, "nc": full, "d": description.strip()[:500], + "e": editeur.strip()[:100], "c": criticite, "s": status, "iid": itop_id}) + db.commit() + return RedirectResponse(url="/admin/applications?msg=added", status_code=303) + + +@router.post("/admin/applications/{app_id}/edit") +async def applications_edit(request: Request, app_id: int, db=Depends(get_db), + nom_court: str = Form(...), nom_complet: str = Form(""), + description: str = Form(""), editeur: str = Form(""), + criticite: str = Form("basse"), status: str = Form("active")): + user, perms, redirect = _check_admin(request, db) + if redirect: + return redirect + if not (can_admin(perms, "users") or can_edit(perms, "settings")): + return RedirectResponse(url="/admin/applications?msg=forbidden", status_code=303) + + row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"), + {"id": app_id}).fetchone() + if not row: + return RedirectResponse(url="/admin/applications?msg=notfound", status_code=303) + + name = nom_court.strip()[:50] + full = (nom_complet.strip() or name)[:200] + + db.execute(text("""UPDATE applications SET nom_court=:n, nom_complet=:nc, + description=:d, editeur=:e, criticite=:c, status=:s, updated_at=NOW() + WHERE id=:id"""), + {"n": name, "nc": full, "d": description.strip()[:500], + "e": editeur.strip()[:100], "c": criticite, "s": status, "id": app_id}) + db.commit() + + # Propager le nom court aux serveurs liés + db.execute(text("UPDATE servers SET application_name=:an WHERE application_id=:aid"), + {"an": name, "aid": app_id}) + db.commit() + + # Push iTop si lié + itop_msg = "" + if row.itop_id: + r = _push_itop(db, row.itop_id, { + "name": name, + "description": description.strip()[:500], + "business_criticity": CRIT_TO_ITOP.get(criticite, "low"), + "status": status, + }, operation="update") + itop_msg = "_itop_ok" if r.get("pushed") else "_itop_ko" + + return RedirectResponse(url=f"/admin/applications?msg=edited{itop_msg}", status_code=303) + + +@router.get("/admin/applications/multi-app", response_class=HTMLResponse) +async def applications_multi_app(request: Request, db=Depends(get_db)): + """Liste les serveurs qui sont liés à plusieurs apps (source : iTop applicationsolution_list).""" + user, perms, redirect = _check_admin(request, db) + if redirect: + return redirect + + multi = [] + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + # VMs + r = client._call("core/get", **{"class": "VirtualMachine", "key": "SELECT VirtualMachine", + "output_fields": "name,applicationsolution_list"}) + for k, v in (r.get("objects") or {}).items(): + apps = v["fields"].get("applicationsolution_list", []) + if len(apps) >= 2: + multi.append({ + "hostname": v["fields"].get("name"), + "apps": [{"name": a.get("applicationsolution_name"), + "itop_id": int(a.get("applicationsolution_id", 0))} for a in apps] + }) + # Servers + r2 = client._call("core/get", **{"class": "Server", "key": "SELECT Server", + "output_fields": "name,applicationsolution_list"}) + for k, v in (r2.get("objects") or {}).items(): + apps = v["fields"].get("applicationsolution_list", []) + if len(apps) >= 2: + multi.append({ + "hostname": v["fields"].get("name"), + "apps": [{"name": a.get("applicationsolution_name"), + "itop_id": int(a.get("applicationsolution_id", 0))} for a in apps] + }) + except Exception as e: + pass + + # Enrichir : app actuelle dans PatchCenter + for m in multi: + hn = (m["hostname"] or "").split(".")[0].lower() + row = db.execute(text("""SELECT application_id, application_name FROM servers + WHERE LOWER(hostname)=:h"""), {"h": hn}).fetchone() + m["current_app_name"] = row.application_name if row else None + m["current_app_id"] = row.application_id if row else None + + ctx = base_context(request, db, user) + ctx.update({"app_name": APP_NAME, "multi_servers": multi}) + return templates.TemplateResponse("admin_applications_multi.html", ctx) + + +@router.post("/admin/applications/keep-single-app") +async def applications_keep_single(request: Request, db=Depends(get_db)): + """Pour un serveur, garde une seule app parmi plusieurs (PatchCenter + push iTop).""" + user, perms, redirect = _check_admin(request, db) + if redirect: + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) + if not (can_admin(perms, "users") or can_edit(perms, "settings")): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + + body = await request.json() + hostname = (body.get("hostname") or "").strip().split(".")[0].lower() + keep_itop_id = body.get("keep_itop_id") + if not hostname or not keep_itop_id: + return JSONResponse({"ok": False, "msg": "Paramètres manquants"}) + + # Trouver app locale par itop_id + app = db.execute(text("SELECT id, nom_court FROM applications WHERE itop_id=:iid"), + {"iid": int(keep_itop_id)}).fetchone() + if not app: + return JSONResponse({"ok": False, "msg": "App iTop introuvable dans catalogue"}) + + # Update local + db.execute(text("""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW() + WHERE LOWER(hostname)=:h"""), + {"aid": app.id, "an": app.nom_court, "h": hostname}) + db.commit() + + # Push iTop : remplacer applicationsolution_list par [keep] + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + for cls in ("VirtualMachine", "Server"): + r = client._call("core/get", **{"class": cls, + "key": f'SELECT {cls} WHERE name = "{hostname}"', "output_fields": "name"}) + if r.get("objects"): + vm_id = list(r["objects"].values())[0]["key"] + client.update(cls, vm_id, + {"applicationsolution_list": [{"applicationsolution_id": int(keep_itop_id)}]}) + break + except Exception: + pass + + return JSONResponse({"ok": True, "app_name": app.nom_court}) + + +@router.get("/admin/applications/{app_id}/assign", response_class=HTMLResponse) +async def applications_assign_page(request: Request, app_id: int, db=Depends(get_db), + search: str = Query(""), domain: str = Query(""), + env: str = Query(""), assigned: str = Query(""), + page: int = Query(1), per_page: int = Query(50)): + """Page d'association en masse de serveurs à une application.""" + user, perms, redirect = _check_admin(request, db) + if redirect: + return redirect + + app = db.execute(text("""SELECT id, itop_id, nom_court, nom_complet + FROM applications WHERE id=:id"""), {"id": app_id}).fetchone() + if not app: + return RedirectResponse(url="/admin/applications?msg=notfound", status_code=303) + + where = ["s.etat NOT IN ('stock','obsolete')"] + params = {} + if search: + where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" + if domain: + where.append("d.name = :dom"); params["dom"] = domain + if env: + where.append("e.name = :env"); params["env"] = env + if assigned == "none": + where.append("s.application_id IS NULL") + elif assigned == "other": + where.append("s.application_id IS NOT NULL AND s.application_id != :aid") + params["aid"] = app_id + elif assigned == "current": + where.append("s.application_id = :aid2") + params["aid2"] = app_id + wc = " AND ".join(where) + + total = db.execute(text(f"""SELECT COUNT(*) FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE {wc}"""), params).scalar() or 0 + per_page = max(20, min(per_page, 200)) + total_pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(page, total_pages)) + offset = (page - 1) * per_page + + rows = db.execute(text(f"""SELECT s.id, s.hostname, s.os_family, s.application_id, s.application_name, + d.name as domain_name, e.name as env_name + FROM servers s + LEFT JOIN domain_environments de ON s.domain_env_id = de.id + LEFT JOIN domains d ON de.domain_id = d.id + LEFT JOIN environments e ON de.environment_id = e.id + WHERE {wc} + ORDER BY s.hostname + LIMIT :lim OFFSET :off"""), {**params, "lim": per_page, "off": offset}).fetchall() + + domains_list = db.execute(text("SELECT name FROM domains ORDER BY name")).fetchall() + envs_list = db.execute(text("SELECT name FROM environments ORDER BY name")).fetchall() + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "app": app, "servers": rows, + "total": total, "page": page, "per_page": per_page, "total_pages": total_pages, + "domains_list": [d.name for d in domains_list], + "envs_list": [e.name for e in envs_list], + "filters": {"search": search, "domain": domain, "env": env, "assigned": assigned}, + }) + return templates.TemplateResponse("admin_applications_assign.html", ctx) + + +@router.post("/admin/applications/{app_id}/assign") +async def applications_assign(request: Request, app_id: int, db=Depends(get_db)): + user, perms, redirect = _check_admin(request, db) + if redirect: + return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401) + if not (can_admin(perms, "users") or can_edit(perms, "settings")): + return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403) + + app = db.execute(text("SELECT id, itop_id, nom_court FROM applications WHERE id=:id"), + {"id": app_id}).fetchone() + if not app: + return JSONResponse({"ok": False, "msg": "Application introuvable"}, status_code=404) + + body = await request.json() + server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()] + if not server_ids: + return JSONResponse({"ok": False, "msg": "Aucun serveur sélectionné"}) + + placeholders = ",".join(str(i) for i in server_ids) + db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW() + WHERE id IN ({placeholders})"""), + {"aid": app_id, "an": app.nom_court}) + db.commit() + + # Push iTop + itop_pushed = 0 + itop_errors = 0 + if app.itop_id: + try: + from ..services.itop_service import ITopClient + from ..services.secrets_service import get_secret + url = get_secret(db, "itop_url") + u = get_secret(db, "itop_user") + p = get_secret(db, "itop_pass") + if url and u and p: + client = ITopClient(url, u, p) + new_list = [{"applicationsolution_id": int(app.itop_id)}] + hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall() + for h in hosts: + try: + rr = client._call("core/get", **{"class": "VirtualMachine", + "key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', + "output_fields": "name"}) + if rr.get("objects"): + vm_id = list(rr["objects"].values())[0]["key"] + upd = client.update("VirtualMachine", vm_id, + {"applicationsolution_list": new_list}) + if upd.get("code") == 0: + itop_pushed += 1 + else: + itop_errors += 1 + except Exception: + itop_errors += 1 + except Exception: + pass + + return JSONResponse({"ok": True, "updated": len(server_ids), + "itop_pushed": itop_pushed, "itop_errors": itop_errors, + "app_name": app.nom_court}) + + +@router.post("/admin/applications/{app_id}/delete") +async def applications_delete(request: Request, app_id: int, db=Depends(get_db)): + user, perms, redirect = _check_admin(request, db) + if redirect: + return redirect + if not (can_admin(perms, "users") or can_edit(perms, "settings")): + return RedirectResponse(url="/admin/applications?msg=forbidden", status_code=303) + + row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"), + {"id": app_id}).fetchone() + if not row: + return RedirectResponse(url="/admin/applications?msg=notfound", status_code=303) + + # Délier les serveurs d'abord + n_servers = db.execute(text("UPDATE servers SET application_id=NULL, application_name=NULL WHERE application_id=:aid"), + {"aid": app_id}).rowcount + + # Supprimer localement + db.execute(text("DELETE FROM applications WHERE id=:id"), {"id": app_id}) + db.commit() + + # Push iTop delete si lié + itop_msg = "" + if row.itop_id: + r = _push_itop(db, row.itop_id, {}, operation="delete") + itop_msg = "_itop_ok" if r.get("pushed") else "_itop_ko" + + return RedirectResponse(url=f"/admin/applications?msg=deleted_{n_servers}{itop_msg}", status_code=303) diff --git a/app/routers/patching.py b/app/routers/patching.py index 51a1b92..f34180c 100644 --- a/app/routers/patching.py +++ b/app/routers/patching.py @@ -338,6 +338,7 @@ async def correspondance_page(request: Request, db=Depends(get_db), servers = corr.get_servers_for_builder(db, search=search, app=application, domain=domain, env=env) + server_links = corr.get_links_bulk(db, [s.id for s in servers]) applications = db.execute(text("""SELECT DISTINCT application_name FROM servers WHERE application_name IS NOT NULL AND application_name != '' @@ -355,6 +356,7 @@ async def correspondance_page(request: Request, db=Depends(get_db), ctx = base_context(request, db, user) ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats, + "server_links": server_links, "applications": applications, "envs": [e.name for e in envs], "domains": [d.name for d in domains], diff --git a/app/routers/quickwin.py b/app/routers/quickwin.py index 1f0f49c..599e2a6 100644 --- a/app/routers/quickwin.py +++ b/app/routers/quickwin.py @@ -15,8 +15,6 @@ from ..services.quickwin_service import ( 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 @@ -140,17 +138,8 @@ async def quickwin_create(request: Request, db=Depends(get_db), @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") - perms = get_user_perms(db, user) - if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"): - return RedirectResponse(url="/dashboard") - runs = list_runs(db) - if not runs: - return RedirectResponse(url="/quickwin?msg=no_run") - return RedirectResponse(url=f"/quickwin/{runs[0].id}/correspondance") + """Redirige vers la nouvelle correspondance globale.""" + return RedirectResponse(url="/patching/correspondance", status_code=303) @router.get("/quickwin/{run_id}", response_class=HTMLResponse) @@ -887,86 +876,7 @@ async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db)) return JSONResponse({"can_start_prod": ok}) -# ========== CORRESPONDANCE HPROD ↔ PROD ========== - +# Correspondance par-run supprimée — utiliser /patching/correspondance (global) @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}) +async def quickwin_correspondance_deprecated(request: Request, run_id: int, db=Depends(get_db)): + return RedirectResponse(url="/patching/correspondance", status_code=303) diff --git a/app/routers/servers.py b/app/routers/servers.py index 6fccf21..650d7f1 100644 --- a/app/routers/servers.py +++ b/app/routers/servers.py @@ -20,7 +20,7 @@ async def servers_list(request: Request, db=Depends(get_db), domain: str = Query(None), env: str = Query(None), tier: str = Query(None), etat: str = Query(None), os: str = Query(None), owner: str = Query(None), - application: str = Query(None), + application: str = Query(None), application_id: int = Query(None), search: str = Query(None), page: int = Query(1), sort: str = Query("hostname"), sort_dir: str = Query("asc")): user = get_current_user(request) @@ -28,7 +28,8 @@ async def servers_list(request: Request, db=Depends(get_db), return RedirectResponse(url="/login") filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, - "owner": owner, "application": application, "search": search} + "owner": owner, "application": application, "application_id": application_id, + "search": search} servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir) domains_list, envs_list = get_reference_data(db) diff --git a/app/services/agent_deploy_service.py b/app/services/agent_deploy_service.py index 86e8ae8..4e4d43b 100644 --- a/app/services/agent_deploy_service.py +++ b/app/services/agent_deploy_service.py @@ -226,7 +226,7 @@ def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, emit(f"Copie {pkg_name} ({pkg_size} Mo)...") sftp = client.open_sftp() - sftp.put(package_path, remote_path=f"/tmp/{pkg_name}") + sftp.put(package_path, f"/tmp/{pkg_name}") sftp.close() emit("Copie terminee") diff --git a/app/services/correspondance_service.py b/app/services/correspondance_service.py index 68eebe3..6f8adf9 100644 --- a/app/services/correspondance_service.py +++ b/app/services/correspondance_service.py @@ -53,7 +53,7 @@ def detect_correspondances(db, dry_run=False): # Tous les serveurs actifs (exclut stock/obsolete) rows = db.execute(text("""SELECT id, hostname FROM servers - WHERE etat NOT IN ('stock','obsolete') ORDER BY hostname""")).fetchall() + WHERE etat NOT IN ('stock','obsolete','eol') ORDER BY hostname""")).fetchall() by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)] for r in rows: @@ -125,7 +125,7 @@ def detect_correspondances(db, dry_run=False): def get_servers_for_builder(db, search="", app="", domain="", env=""): """Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes. Exclut les serveurs en stock / obsolete (décommissionnés, EOL).""" - where = ["s.etat NOT IN ('stock','obsolete')"] + where = ["s.etat NOT IN ('stock','obsolete','eol')"] params = {} if search: where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" @@ -181,7 +181,7 @@ def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id): def get_correspondance_view(db, search="", app="", env=""): """Vue hiérarchique des correspondances groupées par application. Exclut les serveurs en stock/obsolete.""" - where = ["s.etat NOT IN ('stock','obsolete')"] + where = ["s.etat NOT IN ('stock','obsolete','eol')"] params = {} if search: where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%" @@ -330,7 +330,7 @@ def get_orphan_nonprod(db): LEFT JOIN environments e ON de.environment_id = e.id LEFT JOIN domains d ON de.domain_id = d.id WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%' - AND s.etat NOT IN ('stock','obsolete') + AND s.etat NOT IN ('stock','obsolete','eol') AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) ORDER BY s.application_name, s.hostname LIMIT 500 diff --git a/app/services/itop_service.py b/app/services/itop_service.py index c83a03d..b982ba7 100644 --- a/app/services/itop_service.py +++ b/app/services/itop_service.py @@ -337,9 +337,10 @@ def sync_from_itop(db, itop_url, itop_user, itop_pass): "patch_excludes,domain_ldap_name,last_patch_date," "applicationsolution_list") - # PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete) + # PatchCenter etat = iTop status (meme enum: production, implementation, stock, obsolete, eol) itop_status = {"production": "production", "stock": "stock", - "implementation": "implementation", "obsolete": "obsolete"} + "implementation": "implementation", "obsolete": "obsolete", + "eol": "eol"} for v in vms: hostname = v.get("name", "").split(".")[0].lower() @@ -562,7 +563,7 @@ def sync_to_itop(db, itop_url, itop_user, itop_pass): itop_vms[v["name"].split(".")[0].lower()] = v status_map = {"production": "production", "implementation": "implementation", - "stock": "stock", "obsolete": "obsolete"} + "stock": "stock", "obsolete": "obsolete", "eol": "eol"} tier_map = {"tier0": "Tier 0", "tier1": "Tier 1", "tier2": "Tier 2", "tier3": "Tier 3"} # Build OSVersion cache: name.lower() → itop_id diff --git a/app/services/quickwin_service.py b/app/services/quickwin_service.py index 886d336..7df42e5 100644 --- a/app/services/quickwin_service.py +++ b/app/services/quickwin_service.py @@ -680,151 +680,8 @@ def inject_yum_history(db, data): 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() +# Correspondance HPROD ↔ PROD : logique déplacée vers server_correspondance (global) +# Les fonctions obsolètes ont été supprimées : compute_correspondance, get_correspondance, +# get_available_prod_entries, set_prod_pair, clear_all_pairs. +# La colonne prod_pair_entry_id de quickwin_entries est laissée en place pour compatibilité +# mais n'est plus utilisée. Les liens sont désormais dans server_correspondance. diff --git a/app/services/server_service.py b/app/services/server_service.py index 318d992..b06c00b 100644 --- a/app/services/server_service.py +++ b/app/services/server_service.py @@ -115,17 +115,20 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as if filters.get("tier"): where.append("s.tier = :tier"); params["tier"] = filters["tier"] if filters.get("etat"): - if filters["etat"] == "obsolete": - where.append("s.licence_support = 'obsolete'") - else: - where.append("s.etat = :etat"); params["etat"] = filters["etat"] - where.append("COALESCE(s.licence_support, '') != 'obsolete'") + where.append("s.etat = :etat"); params["etat"] = filters["etat"] if filters.get("os"): where.append("s.os_family = :os"); params["os"] = filters["os"] if filters.get("owner"): where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"] - if filters.get("application"): - where.append("s.application_name = :application"); params["application"] = filters["application"] + if filters.get("application_id"): + where.append("s.application_id = :app_id"); params["app_id"] = filters["application_id"] + elif filters.get("application"): + # Matche soit application_name exact, soit via jointure application catalogue (nom_court ou nom_complet) + where.append("""(s.application_name = :application + OR s.application_id IN (SELECT id FROM applications + WHERE LOWER(nom_court) = LOWER(:application) + OR LOWER(nom_complet) = LOWER(:application)))""") + params["application"] = filters["application"] if filters.get("search"): where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%" diff --git a/app/templates/admin_applications.html b/app/templates/admin_applications.html new file mode 100644 index 0000000..a5c9874 --- /dev/null +++ b/app/templates/admin_applications.html @@ -0,0 +1,184 @@ +{% extends 'base.html' %} +{% block title %}Administration — Applications{% endblock %} +{% block content %} +
+
+

Applications (Solutions applicatives)

+

Catalogue des solutions applicatives. Synchronisé bidirectionnellement avec iTop.

+
+
+ Serveurs multi-app + {% if can_edit %} + + {% endif %} +
+
+ +{% if msg %} +
+ {% if msg == 'added' %}Application créée. + {% elif msg == 'exists' %}Cette application existe déjà (nom court). + {% elif msg.startswith('edited') %}Application modifiée{% if 'itop_ok' in msg %} et poussée vers iTop{% elif 'itop_ko' in msg %} (push iTop échoué){% endif %}. + {% elif msg.startswith('deleted') %}Application supprimée (dissociée de {{ msg.split('_')[1] if '_' in msg else '?' }} serveurs){% if 'itop_ok' in msg %} + iTop{% elif 'itop_ko' in msg %} — push iTop KO{% endif %}. + {% elif msg == 'forbidden' %}Permission refusée. + {% elif msg == 'notfound' %}Application introuvable. + {% else %}{{ msg }}{% endif %} +
+{% endif %} + + +
+
{{ stats.total }}
Total applications
+
{{ stats.from_itop }}
Liées iTop
+
{{ stats.used }}
Utilisées (avec serveurs)
+
{{ stats.total - stats.used }}
Non utilisées
+
+ + +
+
+ + + + + + + Reset + {{ apps|length }} apps +
+
+ + +
+ + + + + + + + + + {% if can_edit %}{% endif %} + + + {% for a in apps %} + + + + + + + + + {% if can_edit %} + + {% endif %} + + {% endfor %} + {% if not apps %} + + {% endif %} + +
Nom courtNom completCriticitéStatutiTop IDDomaine(s)Serveurs liésActions
{{ a.nom_court }}{{ (a.nom_complet or '-')[:60] }} + {{ a.criticite }} + + {{ a.status }} + {{ a.itop_id or '—' }}{{ (a.domains or '—')[:50] }} + {% if a.nb_servers %}{{ a.nb_servers }}{% else %}0{% endif %} + + + Serveurs + +
+ +
+
Aucune application
+
+ + +{% if can_edit %} + + + +{% endif %} +{% endblock %} diff --git a/app/templates/admin_applications_assign.html b/app/templates/admin_applications_assign.html new file mode 100644 index 0000000..6d5470f --- /dev/null +++ b/app/templates/admin_applications_assign.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% block title %}Associer serveurs — {{ app.nom_court }}{% endblock %} +{% block content %} +
+
+ ← Applications +

Associer serveurs à : {{ app.nom_court }}

+

Sélectionner des serveurs et les lier à cette application. Push iTop automatique.

+
+
+ + +
+
+ + + + + + Reset + {{ total }} serveurs +
+
+ + + + + +
+ + + + + + + + + + + {% for s in servers %} + + + + + + + + + {% endfor %} + {% if not servers %} + + {% endif %} + +
HostnameOSDomaineEnvApp actuelle
+ + {{ s.hostname }}{{ s.os_family or '-' }}{{ s.domain_name or '-' }}{{ s.env_name or '-' }} + {% if s.application_id == app.id %} + ✓ déjà lié + {% elif s.application_name %} + {{ s.application_name }} + {% else %} + + {% endif %} +
Aucun serveur
+
+ + +{% if total_pages > 1 %} +
+ {% for p in range(1, total_pages + 1) %} + {{ p }} + {% endfor %} +
+{% endif %} + + +{% endblock %} diff --git a/app/templates/admin_applications_multi.html b/app/templates/admin_applications_multi.html new file mode 100644 index 0000000..a26cafd --- /dev/null +++ b/app/templates/admin_applications_multi.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} +{% block title %}Serveurs multi-applications{% endblock %} +{% block content %} +
+
+ ← Applications +

Serveurs liés à plusieurs applications

+

Source : iTop (applicationsolution_list avec 2+ entrées). Cliquer sur une app pour la garder comme unique.

+
+
+ +
+ Règle : si un serveur apparaît sous plusieurs apps, souvent c'est une duplication (même app avec noms différents) ou une erreur de saisie. Sélectionner l'app à conserver → les autres seront retirées du serveur (côté iTop également). +
+ +{% if not multi_servers %} +
+

Aucun serveur avec plusieurs applications dans iTop.

+
+{% else %} +
+ + + + + + + + + {% for m in multi_servers %} + + + + + + + {% endfor %} + +
HostnameApp actuelle (PatchCenter)Apps iTopAction
{{ m.hostname }}{{ m.current_app_name or '—' }} + {% for a in m.apps %} + + {% endfor %} + + +
+
+{% endif %} + + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 8aa27b5..2af5258 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -101,7 +101,6 @@ Vue d'ensemble {% if p.quickwin in ('edit', 'admin') or p.campaigns in ('edit', 'admin') %} Config exclusion - Correspondance {% endif %} @@ -149,7 +148,8 @@
{% if p.servers or p.contacts %}Contacts{% endif %} - {% if p.users %}Utilisateurs{% endif %} + {% if p.users %}Utilisateurs{% endif %} + {% if p.settings or p.users %}Applications{% endif %} {% if p.settings %}Settings{% endif %} {% if p.settings or p.referentiel %}Référentiel{% endif %}
diff --git a/app/templates/partials/server_detail.html b/app/templates/partials/server_detail.html index 08e7fa2..2b5afc8 100644 --- a/app/templates/partials/server_detail.html +++ b/app/templates/partials/server_detail.html @@ -51,7 +51,7 @@
Environnement{{ s.environnement }}
Zone{{ s.zone or 'LAN' }}
Tier{{ s.tier }}
-
Etat{{ s.etat }}
+
Etat{% if s.etat == 'obsolete' %}Décommissionné{% elif s.etat == 'eol' %}EOL{% else %}{{ s.etat }}{% endif %}
diff --git a/app/templates/partials/server_edit.html b/app/templates/partials/server_edit.html index c72ffbe..c014e8f 100644 --- a/app/templates/partials/server_edit.html +++ b/app/templates/partials/server_edit.html @@ -58,7 +58,7 @@
diff --git a/app/templates/patching_correspondance.html b/app/templates/patching_correspondance.html index fc938fa..0ffbf6f 100644 --- a/app/templates/patching_correspondance.html +++ b/app/templates/patching_correspondance.html @@ -115,10 +115,17 @@ {{ (s.application_name or '-')[:35] }} {{ s.domain_name or '-' }} {{ s.zone_name or '-' }} - - {% if s.n_as_prod %}{{ s.n_as_prod }}P{% endif %} - {% if s.n_as_nonprod %}{{ s.n_as_nonprod }}N{% endif %} - {% if not s.n_as_prod and not s.n_as_nonprod %}-{% endif %} + + {% set link = server_links.get(s.id, {}) %} + {% if link and link.as_prod %} + → non-prod : + {% for l in link.as_prod %}{{ l.hostname }}{% if not loop.last %}, {% endif %}{% endfor %} + {% elif link and link.as_nonprod %} + → prod : + {% for l in link.as_nonprod %}{{ l.hostname }}{% if not loop.last %}, {% endif %}{% endfor %} + {% else %} + + {% endif %} {% if can_edit %} diff --git a/app/templates/qualys_agents.html b/app/templates/qualys_agents.html index ba5669f..933e804 100644 --- a/app/templates/qualys_agents.html +++ b/app/templates/qualys_agents.html @@ -201,7 +201,7 @@ function refreshAgents() { {{ s.domain or '-' }} {{ s.env or '-' }} {% if s.zone == 'DMZ' %}DMZ{% else %}{{ s.zone or '-' }}{% endif %} - {{ (s.etat or '-')[:8] }} + {% if s.etat == 'obsolete' %}Décom.{% elif s.etat == 'eol' %}EOL{% elif s.etat == 'production' %}Prod{% else %}{{ (s.etat or '-')[:8] }}{% endif %} {% endfor %} diff --git a/app/templates/quickwin_correspondance.html b/app/templates/quickwin_correspondance.html deleted file mode 100644 index 2c7ee8d..0000000 --- a/app/templates/quickwin_correspondance.html +++ /dev/null @@ -1,258 +0,0 @@ -{% 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
-
-
- - -
- - - - - - - Reset - {{ total_filtered }} résultat(s) -
- - -
- 0 sélectionné(s) - - | - Associer la sélection à : - - -
- - -
-
- - - - - - - - - - - - - - {% for p in pairs %} - - - - - - - - - - - - {% endfor %} - {% if not pairs %} - - {% endif %} - -
Serveur H-ProdDomaineEnvCandidat autoStatutServeur Prod appariéDomaine ProdAction
{{ p.hprod_hostname }}{{ p.hprod_domaine }} - {% if p.is_anomaly %}{{ p.hprod_env or '?' }} - {% else %}{{ p.hprod_env }}{% endif %} - {{ p.candidate }} - {% if p.is_matched %}OK - {% elif p.is_anomaly %}! - {% else %}--{% endif %} - - {% if p.is_matched %} - {{ p.prod_hostname }} - {% else %} - - {% endif %} - - {% if p.is_matched %}{{ p.prod_domaine }}{% endif %} - - {% if p.is_matched %} - - {% else %} - - {% endif %} -
Aucun résultat{% if filters.search or filters.pair_filter %} pour ces filtres{% endif %}
-
-
- - -{% 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 694909d..a04c785 100644 --- a/app/templates/quickwin_detail.html +++ b/app/templates/quickwin_detail.html @@ -25,7 +25,7 @@

S{{ '%02d'|format(run.week_number) }} {{ run.year }} — Créé par {{ run.created_by_name or '?' }}

- Correspondance + Correspondance Validations Logs
diff --git a/app/templates/servers.html b/app/templates/servers.html index 2faa236..7a3a8ea 100644 --- a/app/templates/servers.html +++ b/app/templates/servers.html @@ -36,7 +36,7 @@ {% for t in ['tier0','tier1','tier2','tier3'] %}{% endfor %}