"""Router Historique patching — vue unifiee patch_history + quickwin_entries""" from fastapi import APIRouter, Request, Depends, Query from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text from ..dependencies import get_db, get_current_user from ..config import APP_NAME router = APIRouter() templates = Jinja2Templates(directory="app/templates") @router.get("/patching/historique", response_class=HTMLResponse) async def patch_history_page(request: Request, db=Depends(get_db), year: int = Query(None), week: int = Query(None), hostname: str = Query(None), source: str = Query(None), os_family: str = Query(None), zone: str = Query(None), domain: str = Query(None), intervenant: str = Query(None), page: int = Query(1)): user = get_current_user(request) if not user: return RedirectResponse(url="/login") from datetime import datetime if not year: year = datetime.now().year per_page = 100 offset = (page - 1) * per_page kpis = {} kpis["total_ph"] = db.execute(text( "SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y" ), {"y": year}).scalar() kpis["total_qw"] = db.execute(text(""" SELECT COUNT(*) FROM quickwin_entries qe JOIN quickwin_runs qr ON qe.run_id=qr.id WHERE qe.status='patched' AND qr.year=:y """), {"y": year}).scalar() kpis["total"] = kpis["total_ph"] + kpis["total_qw"] kpis["servers"] = db.execute(text(""" SELECT COUNT(DISTINCT sid) FROM ( SELECT server_id AS sid FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y UNION SELECT qe.server_id FROM quickwin_entries qe JOIN quickwin_runs qr ON qe.run_id=qr.id WHERE qe.status='patched' AND qr.year=:y ) u """), {"y": year}).scalar() kpis["patchables"] = db.execute(text( "SELECT COUNT(*) FROM servers WHERE etat='Production' AND patch_os_owner='secops'" )).scalar() kpis["never"] = db.execute(text(""" SELECT COUNT(*) FROM servers s WHERE s.etat='Production' AND s.patch_os_owner='secops' AND NOT EXISTS (SELECT 1 FROM patch_history ph WHERE ph.server_id=s.id AND EXTRACT(YEAR FROM ph.date_patch)=:y) AND NOT EXISTS (SELECT 1 FROM quickwin_entries qe JOIN quickwin_runs qr ON qe.run_id=qr.id WHERE qe.server_id=s.id AND qe.status='patched' AND qr.year=:y) """), {"y": year}).scalar() kpis["coverage_pct"] = round((kpis["servers"] / kpis["patchables"] * 100), 1) if kpis["patchables"] else 0 by_source = {} by_source["import"] = db.execute(text( "SELECT COUNT(*) FROM patch_history WHERE campaign_id IS NULL AND EXTRACT(YEAR FROM date_patch)=:y" ), {"y": year}).scalar() by_source["standard"] = db.execute(text(""" SELECT COUNT(*) FROM patch_history ph JOIN campaigns c ON ph.campaign_id=c.id WHERE c.campaign_type='standard' AND EXTRACT(YEAR FROM ph.date_patch)=:y """), {"y": year}).scalar() by_source["quickwin"] = kpis["total_qw"] by_week = db.execute(text(""" SELECT week_num, SUM(cnt)::int as servers FROM ( SELECT TO_CHAR(date_patch, 'IW') as week_num, COUNT(DISTINCT server_id) as cnt FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y GROUP BY TO_CHAR(date_patch, 'IW') UNION ALL SELECT LPAD(qr.week_number::text, 2, '0') as week_num, COUNT(DISTINCT qe.server_id) as cnt FROM quickwin_entries qe JOIN quickwin_runs qr ON qe.run_id=qr.id WHERE qe.status='patched' AND qr.year=:y GROUP BY qr.week_number ) u GROUP BY week_num ORDER BY week_num """), {"y": year}).fetchall() # Listes pour les filtres (selon annee courante) filter_opts = {} filter_opts["os"] = [r.os for r in db.execute(text(""" SELECT DISTINCT s.os_family as os FROM servers s WHERE s.os_family IS NOT NULL AND s.os_family <> '' ORDER BY 1 """)).fetchall()] filter_opts["zones"] = [r.zone for r in db.execute(text(""" SELECT DISTINCT z.name as zone FROM zones z ORDER BY 1 """)).fetchall()] filter_opts["domains"] = [r.dom for r in db.execute(text(""" SELECT DISTINCT d.name as dom FROM domains d ORDER BY 1 """)).fetchall()] filter_opts["intervenants"] = [r.interv for r in db.execute(text(""" SELECT DISTINCT intervenant_name as interv FROM patch_history WHERE intervenant_name IS NOT NULL AND intervenant_name <> '' ORDER BY 1 """)).fetchall()] where_ph = ["EXTRACT(YEAR FROM ph.date_patch)=:y"] where_qw = ["qr.year=:y", "qe.status='patched'"] params = {"y": year, "limit": per_page, "offset": offset} if week: where_ph.append("EXTRACT(WEEK FROM ph.date_patch)=:wk") where_qw.append("qr.week_number=:wk") params["wk"] = week if hostname: where_ph.append("s.hostname ILIKE :h") where_qw.append("s.hostname ILIKE :h") params["h"] = f"%{hostname}%" if os_family: where_ph.append("s.os_family=:os") where_qw.append("s.os_family=:os") params["os"] = os_family if zone: where_ph.append("z.name=:zn") where_qw.append("z.name=:zn") params["zn"] = zone if domain: where_ph.append("d.name=:dm") where_qw.append("d.name=:dm") params["dm"] = domain if intervenant: where_ph.append("ph.intervenant_name=:iv") where_qw.append("1=0") # quickwin n'a pas ce champ params["iv"] = intervenant if source == "import": where_ph.append("ph.campaign_id IS NULL") elif source == "standard": where_ph.append("c.campaign_type='standard'") wc_ph = " AND ".join(where_ph) wc_qw = " AND ".join(where_qw) skip_qw = source in ("import", "standard") or bool(intervenant) skip_ph = source == "quickwin" ph_joins = """ JOIN servers s ON ph.server_id=s.id LEFT JOIN zones z ON s.zone_id=z.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 campaigns c ON ph.campaign_id=c.id """ qw_joins = """ JOIN quickwin_runs qr ON qe.run_id=qr.id JOIN servers s ON qe.server_id=s.id LEFT JOIN zones z ON s.zone_id=z.id LEFT JOIN domain_environments de ON s.domain_env_id=de.id LEFT JOIN domains d ON de.domain_id=d.id """ count_parts = [] if not skip_ph: count_parts.append(f"SELECT COUNT(*) FROM patch_history ph {ph_joins} WHERE {wc_ph}") if not skip_qw: count_parts.append(f"SELECT COUNT(*) FROM quickwin_entries qe {qw_joins} WHERE {wc_qw}") count_sql = " + ".join(f"({p})" for p in count_parts) if count_parts else "0" total_filtered = db.execute(text(f"SELECT {count_sql}"), params).scalar() union_parts = [] if not skip_ph: union_parts.append(f""" SELECT s.id as sid, s.hostname, s.os_family, s.etat, ph.date_patch, ph.status, ph.notes, ph.intervenant_name, z.name as zone, d.name as domain_name, CASE WHEN ph.campaign_id IS NULL THEN 'import' ELSE COALESCE(c.campaign_type, 'standard') END as source_type, c.id as campaign_id, c.label as campaign_label, NULL::int as run_id, NULL::text as run_label FROM patch_history ph {ph_joins} WHERE {wc_ph} """) if not skip_qw: union_parts.append(f""" SELECT s.id as sid, s.hostname, s.os_family, s.etat, qe.patch_date as date_patch, qe.status, qe.notes, NULL::text as intervenant_name, z.name as zone, d.name as domain_name, 'quickwin' as source_type, NULL::int as campaign_id, NULL::text as campaign_label, qr.id as run_id, qr.label as run_label FROM quickwin_entries qe {qw_joins} WHERE {wc_qw} """) if not union_parts: union_parts.append("""SELECT NULL::int as sid, NULL as hostname, NULL as os_family, NULL as etat, NULL::timestamptz as date_patch, NULL as status, NULL as notes, NULL as intervenant_name, NULL as zone, NULL as domain_name, NULL as source_type, NULL::int as campaign_id, NULL as campaign_label, NULL::int as run_id, NULL as run_label WHERE 1=0""") union_sql = " UNION ALL ".join(union_parts) rows = db.execute(text(f""" SELECT * FROM ({union_sql}) combined ORDER BY date_patch DESC NULLS LAST LIMIT :limit OFFSET :offset """), params).fetchall() years = db.execute(text(""" SELECT DISTINCT y FROM ( SELECT EXTRACT(YEAR FROM date_patch)::int as y FROM patch_history UNION SELECT year as y FROM quickwin_runs ) u ORDER BY y DESC """)).fetchall() return templates.TemplateResponse("patch_history.html", { "request": request, "user": user, "app_name": APP_NAME, "kpis": kpis, "by_week": by_week, "by_source": by_source, "rows": rows, "year": year, "week": week, "hostname": hostname, "source": source, "os_family": os_family, "zone": zone, "domain": domain, "intervenant": intervenant, "filter_opts": filter_opts, "page": page, "per_page": per_page, "total_filtered": total_filtered, "years": [y.y for y in years], })