From 4b1794d4d1dffa3f956398d22a1dce259cb34948 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Fri, 17 Apr 2026 08:47:25 +0000 Subject: [PATCH] Add page Historique patching : vue unifiee import xlsx + campagnes + quickwin --- app/routers/patch_history.py | 163 +++++++++--- app/templates/base.html | 431 ++++++++++++++++--------------- app/templates/patch_history.html | 139 ++++++++++ 3 files changed, 481 insertions(+), 252 deletions(-) create mode 100644 app/templates/patch_history.html diff --git a/app/routers/patch_history.py b/app/routers/patch_history.py index f995f22..78a313c 100644 --- a/app/routers/patch_history.py +++ b/app/routers/patch_history.py @@ -1,4 +1,4 @@ -"""Router Historique patching — vue de patch_history""" +"""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 @@ -13,7 +13,8 @@ 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), page: int = Query(1)): + hostname: str = Query(None), source: str = Query(None), + page: int = Query(1)): user = get_current_user(request) if not user: return RedirectResponse(url="/login") @@ -25,14 +26,27 @@ async def patch_history_page(request: Request, db=Depends(get_db), per_page = 100 offset = (page - 1) * per_page - # KPIs kpis = {} - kpis["total"] = db.execute(text( + kpis["total_ph"] = db.execute(text( "SELECT COUNT(*) FROM patch_history WHERE EXTRACT(YEAR FROM date_patch)=:y" ), {"y": year}).scalar() - kpis["servers"] = db.execute(text( - "SELECT COUNT(DISTINCT server_id) 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() @@ -41,56 +55,131 @@ async def patch_history_page(request: Request, db=Depends(get_db), 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 - # Par semaine + 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 TO_CHAR(date_patch, 'IW') as week_num, - COUNT(DISTINCT server_id) as servers - FROM patch_history - WHERE EXTRACT(YEAR FROM date_patch)=:y - GROUP BY TO_CHAR(date_patch, 'IW') - ORDER BY week_num + 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() - # Filtres - where = ["EXTRACT(YEAR FROM ph.date_patch)=:y"] + 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.append("EXTRACT(WEEK FROM ph.date_patch)=:wk") + where_ph.append("EXTRACT(WEEK FROM ph.date_patch)=:wk") + where_qw.append("qr.week_number=:wk") params["wk"] = week if hostname: - where.append("s.hostname ILIKE :h") + where_ph.append("s.hostname ILIKE :h") + where_qw.append("s.hostname ILIKE :h") params["h"] = f"%{hostname}%" - wc = " AND ".join(where) + if source == "import": + where_ph.append("ph.campaign_id IS NULL") + elif source == "standard": + where_ph.append("c.campaign_type='standard'") - total_filtered = db.execute(text( - f"SELECT COUNT(*) FROM patch_history ph JOIN servers s ON ph.server_id=s.id WHERE {wc}" - ), params).scalar() + wc_ph = " AND ".join(where_ph) + wc_qw = " AND ".join(where_qw) + skip_qw = source in ("import", "standard") + skip_ph = source == "quickwin" + + count_parts = [] + if not skip_ph: + count_parts.append(f""" + SELECT COUNT(*) FROM patch_history ph + JOIN servers s ON ph.server_id=s.id + LEFT JOIN campaigns c ON ph.campaign_id=c.id + WHERE {wc_ph} + """) + if not skip_qw: + count_parts.append(f""" + SELECT COUNT(*) FROM quickwin_entries qe + JOIN quickwin_runs qr ON qe.run_id=qr.id + JOIN servers s ON qe.server_id=s.id + 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, + z.name as zone, + 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 + JOIN servers s ON ph.server_id=s.id + LEFT JOIN zones z ON s.zone_id=z.id + LEFT JOIN campaigns c ON ph.campaign_id=c.id + 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, + z.name as zone, + '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 + 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 + 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 zone, 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 s.id as sid, s.hostname, s.os_family, s.etat, - ph.date_patch, ph.status, ph.notes, - z.name as zone - FROM patch_history ph - JOIN servers s ON ph.server_id = s.id - LEFT JOIN zones z ON s.zone_id = z.id - WHERE {wc} - ORDER BY ph.date_patch DESC + SELECT * FROM ({union_sql}) combined + ORDER BY date_patch DESC NULLS LAST LIMIT :limit OFFSET :offset """), params).fetchall() - # Années dispo years = db.execute(text(""" - SELECT DISTINCT EXTRACT(YEAR FROM date_patch)::int as y - FROM patch_history ORDER BY y DESC + 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, "rows": rows, - "year": year, "week": week, "hostname": hostname, - "page": page, "per_page": per_page, "total_filtered": total_filtered, - "years": [y.y for y in years], + "kpis": kpis, "by_week": by_week, "by_source": by_source, + "rows": rows, "year": year, "week": week, "hostname": hostname, + "source": source, "page": page, "per_page": per_page, + "total_filtered": total_filtered, "years": [y.y for y in years], }) diff --git a/app/templates/base.html b/app/templates/base.html index aa7ec11..74570c1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,215 +1,216 @@ - - - - - - {{ app_name }} - {% block title %}{% endblock %} - - - - - - - - {% if user %} -
- -
- -
-
- {{ user.display or user.sub }} - {% if user.auth == 'ldap' %}(AD){% endif %} - · - {{ user.sub }} - {{ user.role }} - Deconnexion -
-
-
-
- {% block content %}{% endblock %} -
-
-
-
-
-
- {% else %} - {% block fullpage %}{% endblock %} - {% endif %} -
- - - - - - + + + + + + {{ app_name }} - {% block title %}{% endblock %} + + + + + + + + {% if user %} +
+ +
+ +
+
+ {{ user.display or user.sub }} + {% if user.auth == 'ldap' %}(AD){% endif %} + · + {{ user.sub }} + {{ user.role }} + Deconnexion +
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+
+
+ {% else %} + {% block fullpage %}{% endblock %} + {% endif %} +
+ + + + + + diff --git a/app/templates/patch_history.html b/app/templates/patch_history.html new file mode 100644 index 0000000..8f0ea85 --- /dev/null +++ b/app/templates/patch_history.html @@ -0,0 +1,139 @@ +{% extends 'base.html' %} +{% block title %}Historique patching{% endblock %} +{% block content %} +
+
+

Historique patching

+

Vue unifiée : imports xlsx + campagnes standard + QuickWin.

+
+
+ {% for y in years %}{{ y }}{% endfor %} +
+
+ + +
+
+
{{ kpis.total }}
+
Events {{ year }}
+
+
+
{{ kpis.servers }}
+
Serveurs distincts
+
+
+
{{ kpis.patchables }}
+
Patchables SecOps
+
+
+
{{ kpis.never }}
+
Jamais patchés {{ year }}
+
+
+
{{ kpis.coverage_pct }}%
+
Couverture
+
+
+ + +
+ +
{{ by_source.import }}
+
Import xlsx
+
+ +
{{ by_source.standard }}
+
Campagnes standard
+
+ +
{{ by_source.quickwin }}
+
QuickWin
+
+
+ + +{% if by_week %} +
+

Serveurs patchés par semaine ({{ year }})

+
+ {% set max_val = by_week|map(attribute='servers')|max %} + {% for w in by_week %} + +
+ {{ w.week_num }} +
+ {% endfor %} +
+
+{% endif %} + + +
+
+ + + + + + Reset + {{ total_filtered }} résultat{{ 's' if total_filtered != 1 }} +
+
+ + +
+ + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + + + + {% endfor %} + {% if not rows %} + + {% endif %} + +
HostnameOSZoneÉtatDateSemaineSourceStatusNotes
{{ r.hostname }}{{ (r.os_family or '-')[:6] }}{{ r.zone or '-' }}{{ (r.etat or '-')[:6] }}{{ r.date_patch.strftime('%Y-%m-%d %H:%M') if r.date_patch else '-' }}{% if r.date_patch %}S{{ r.date_patch.strftime('%V') }}{% else %}-{% endif %} + {% if r.source_type == 'import' %}xlsx + {% elif r.source_type == 'standard' %}{{ r.campaign_label or 'Campagne' }} + {% elif r.source_type == 'quickwin' %}{{ r.run_label or 'QuickWin' }} + {% else %}{{ r.source_type or '?' }}{% endif %} + {{ r.status }}{{ (r.notes or '-')[:50] }}
Aucun event de patching pour ce filtre
+
+ + +{% if total_filtered > per_page %} +
+ {% if page > 1 %}← Précédent{% endif %} + Page {{ page }} / {{ ((total_filtered - 1) // per_page) + 1 }} + {% if page * per_page < total_filtered %}Suivant →{% endif %} +
+{% endif %} +{% endblock %}