Historique patching : filtres OS/zone/domaine/intervenant + colonnes table

This commit is contained in:
Pierre & Lumière 2026-04-17 12:10:45 +00:00
parent 14f809335e
commit 2ab2ceabba
4 changed files with 122 additions and 39 deletions

View File

@ -14,6 +14,8 @@ templates = Jinja2Templates(directory="app/templates")
async def patch_history_page(request: Request, db=Depends(get_db), async def patch_history_page(request: Request, db=Depends(get_db),
year: int = Query(None), week: int = Query(None), year: int = Query(None), week: int = Query(None),
hostname: str = Query(None), source: str = 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)): page: int = Query(1)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
@ -87,6 +89,25 @@ async def patch_history_page(request: Request, db=Depends(get_db),
) u GROUP BY week_num ORDER BY week_num ) u GROUP BY week_num ORDER BY week_num
"""), {"y": year}).fetchall() """), {"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_ph = ["EXTRACT(YEAR FROM ph.date_patch)=:y"]
where_qw = ["qr.year=:y", "qe.status='patched'"] where_qw = ["qr.year=:y", "qe.status='patched'"]
params = {"y": year, "limit": per_page, "offset": offset} params = {"y": year, "limit": per_page, "offset": offset}
@ -99,6 +120,22 @@ async def patch_history_page(request: Request, db=Depends(get_db),
where_ph.append("s.hostname ILIKE :h") where_ph.append("s.hostname ILIKE :h")
where_qw.append("s.hostname ILIKE :h") where_qw.append("s.hostname ILIKE :h")
params["h"] = f"%{hostname}%" 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": if source == "import":
where_ph.append("ph.campaign_id IS NULL") where_ph.append("ph.campaign_id IS NULL")
elif source == "standard": elif source == "standard":
@ -107,24 +144,29 @@ async def patch_history_page(request: Request, db=Depends(get_db),
wc_ph = " AND ".join(where_ph) wc_ph = " AND ".join(where_ph)
wc_qw = " AND ".join(where_qw) wc_qw = " AND ".join(where_qw)
skip_qw = source in ("import", "standard") skip_qw = source in ("import", "standard") or bool(intervenant)
skip_ph = source == "quickwin" 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 = [] count_parts = []
if not skip_ph: if not skip_ph:
count_parts.append(f""" count_parts.append(f"SELECT COUNT(*) FROM patch_history ph {ph_joins} WHERE {wc_ph}")
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: if not skip_qw:
count_parts.append(f""" count_parts.append(f"SELECT COUNT(*) FROM quickwin_entries qe {qw_joins} WHERE {wc_qw}")
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" 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() total_filtered = db.execute(text(f"SELECT {count_sql}"), params).scalar()
@ -132,34 +174,33 @@ async def patch_history_page(request: Request, db=Depends(get_db),
if not skip_ph: if not skip_ph:
union_parts.append(f""" union_parts.append(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat, SELECT s.id as sid, s.hostname, s.os_family, s.etat,
ph.date_patch, ph.status, ph.notes, ph.date_patch, ph.status, ph.notes, ph.intervenant_name,
z.name as zone, z.name as zone, d.name as domain_name,
CASE WHEN ph.campaign_id IS NULL THEN 'import' CASE WHEN ph.campaign_id IS NULL THEN 'import'
ELSE COALESCE(c.campaign_type, 'standard') END as source_type, ELSE COALESCE(c.campaign_type, 'standard') END as source_type,
c.id as campaign_id, c.label as campaign_label, c.id as campaign_id, c.label as campaign_label,
NULL::int as run_id, NULL::text as run_label NULL::int as run_id, NULL::text as run_label
FROM patch_history ph FROM patch_history ph {ph_joins}
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} WHERE {wc_ph}
""") """)
if not skip_qw: if not skip_qw:
union_parts.append(f""" union_parts.append(f"""
SELECT s.id as sid, s.hostname, s.os_family, s.etat, SELECT s.id as sid, s.hostname, s.os_family, s.etat,
qe.patch_date as date_patch, qe.status, qe.notes, qe.patch_date as date_patch, qe.status, qe.notes,
z.name as zone, NULL::text as intervenant_name,
z.name as zone, d.name as domain_name,
'quickwin' as source_type, 'quickwin' as source_type,
NULL::int as campaign_id, NULL::text as campaign_label, NULL::int as campaign_id, NULL::text as campaign_label,
qr.id as run_id, qr.label as run_label qr.id as run_id, qr.label as run_label
FROM quickwin_entries qe FROM quickwin_entries qe {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
WHERE {wc_qw} WHERE {wc_qw}
""") """)
if not union_parts: 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_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) union_sql = " UNION ALL ".join(union_parts)
rows = db.execute(text(f""" rows = db.execute(text(f"""
@ -180,6 +221,8 @@ async def patch_history_page(request: Request, db=Depends(get_db),
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "app_name": APP_NAME,
"kpis": kpis, "by_week": by_week, "by_source": by_source, "kpis": kpis, "by_week": by_week, "by_source": by_source,
"rows": rows, "year": year, "week": week, "hostname": hostname, "rows": rows, "year": year, "week": week, "hostname": hostname,
"source": source, "page": page, "per_page": per_page, "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], "total_filtered": total_filtered, "years": [y.y for y in years],
}) })

View File

@ -78,10 +78,26 @@
<select name="source" class="text-xs py-1 px-2"> <select name="source" class="text-xs py-1 px-2">
<option value="">Toutes sources</option> <option value="">Toutes sources</option>
<option value="import" {% if source == 'import' %}selected{% endif %}>Import xlsx</option> <option value="import" {% if source == 'import' %}selected{% endif %}>Import xlsx</option>
<option value="standard" {% if source == 'standard' %}selected{% endif %}>Campagne standard</option> <option value="standard" {% if source == 'standard' %}selected{% endif %}>Campagne std</option>
<option value="quickwin" {% if source == 'quickwin' %}selected{% endif %}>QuickWin</option> <option value="quickwin" {% if source == 'quickwin' %}selected{% endif %}>QuickWin</option>
</select> </select>
<input type="text" name="hostname" value="{{ hostname or '' }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px"> <select name="os_family" class="text-xs py-1 px-2">
<option value="">Tous OS</option>
{% for o in filter_opts.os %}<option value="{{ o }}" {% if os_family == o %}selected{% endif %}>{{ o }}</option>{% endfor %}
</select>
<select name="zone" class="text-xs py-1 px-2">
<option value="">Toutes zones</option>
{% for z in filter_opts.zones %}<option value="{{ z }}" {% if zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
<select name="domain" class="text-xs py-1 px-2">
<option value="">Tous domaines</option>
{% for d in filter_opts.domains %}<option value="{{ d }}" {% if domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
<select name="intervenant" class="text-xs py-1 px-2">
<option value="">Tous intervenants</option>
{% for i in filter_opts.intervenants %}<option value="{{ i }}" {% if intervenant == i %}selected{% endif %}>{{ i }}</option>{% endfor %}
</select>
<input type="text" name="hostname" value="{{ hostname or '' }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:140px">
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button> <button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
<a href="/patching/historique?year={{ year }}" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a> <a href="/patching/historique?year={{ year }}" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
<span class="text-xs text-gray-500 ml-auto">{{ total_filtered }} résultat{{ 's' if total_filtered != 1 }}</span> <span class="text-xs text-gray-500 ml-auto">{{ total_filtered }} résultat{{ 's' if total_filtered != 1 }}</span>
@ -95,9 +111,11 @@
<th class="p-2 text-left">Hostname</th> <th class="p-2 text-left">Hostname</th>
<th class="p-2 text-center">OS</th> <th class="p-2 text-center">OS</th>
<th class="p-2 text-center">Zone</th> <th class="p-2 text-center">Zone</th>
<th class="p-2 text-center">Domaine</th>
<th class="p-2 text-center">État</th> <th class="p-2 text-center">État</th>
<th class="p-2 text-center">Date</th> <th class="p-2 text-center">Date</th>
<th class="p-2 text-center">Semaine</th> <th class="p-2 text-center">Sem.</th>
<th class="p-2 text-center">Intervenant</th>
<th class="p-2 text-center">Source</th> <th class="p-2 text-center">Source</th>
<th class="p-2 text-center">Status</th> <th class="p-2 text-center">Status</th>
<th class="p-2 text-left">Notes</th> <th class="p-2 text-left">Notes</th>
@ -108,9 +126,11 @@
<td class="p-2 font-mono text-cyber-accent"><a href="/servers/{{ r.sid }}" class="hover:underline">{{ r.hostname }}</a></td> <td class="p-2 font-mono text-cyber-accent"><a href="/servers/{{ r.sid }}" class="hover:underline">{{ r.hostname }}</a></td>
<td class="p-2 text-center text-gray-400">{{ (r.os_family or '-')[:6] }}</td> <td class="p-2 text-center text-gray-400">{{ (r.os_family or '-')[:6] }}</td>
<td class="p-2 text-center"><span class="badge {% if r.zone == 'DMZ' %}badge-red{% else %}badge-gray{% endif %}">{{ r.zone or '-' }}</span></td> <td class="p-2 text-center"><span class="badge {% if r.zone == 'DMZ' %}badge-red{% else %}badge-gray{% endif %}">{{ r.zone or '-' }}</span></td>
<td class="p-2 text-center text-gray-300">{{ (r.domain_name or '-')[:10] }}</td>
<td class="p-2 text-center"><span class="badge {% if r.etat == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (r.etat or '-')[:6] }}</span></td> <td class="p-2 text-center"><span class="badge {% if r.etat == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (r.etat or '-')[:6] }}</span></td>
<td class="p-2 text-center text-gray-300">{{ r.date_patch.strftime('%Y-%m-%d %H:%M') if r.date_patch else '-' }}</td> <td class="p-2 text-center text-gray-300">{{ r.date_patch.strftime('%Y-%m-%d %H:%M') if r.date_patch else '-' }}</td>
<td class="p-2 text-center text-gray-400">{% if r.date_patch %}S{{ r.date_patch.strftime('%V') }}{% else %}-{% endif %}</td> <td class="p-2 text-center text-gray-400">{% if r.date_patch %}S{{ r.date_patch.strftime('%V') }}{% else %}-{% endif %}</td>
<td class="p-2 text-center text-gray-300">{{ r.intervenant_name or '-' }}</td>
<td class="p-2 text-center"> <td class="p-2 text-center">
{% if r.source_type == 'import' %}<span class="badge" style="background:#1e3a5f;color:#60a5fa;">xlsx</span> {% if r.source_type == 'import' %}<span class="badge" style="background:#1e3a5f;color:#60a5fa;">xlsx</span>
{% elif r.source_type == 'standard' %}<a href="/campaigns/{{ r.campaign_id }}" class="badge" style="background:#164e63;color:#22d3ee;text-decoration:none">{{ r.campaign_label or 'Campagne' }}</a> {% elif r.source_type == 'standard' %}<a href="/campaigns/{{ r.campaign_id }}" class="badge" style="background:#164e63;color:#22d3ee;text-decoration:none">{{ r.campaign_label or 'Campagne' }}</a>
@ -118,11 +138,11 @@
{% else %}<span class="badge badge-gray">{{ r.source_type or '?' }}</span>{% endif %} {% else %}<span class="badge badge-gray">{{ r.source_type or '?' }}</span>{% endif %}
</td> </td>
<td class="p-2 text-center"><span class="badge {% if r.status == 'ok' or r.status == 'patched' %}badge-green{% elif r.status == 'ko' or r.status == 'failed' %}badge-red{% else %}badge-yellow{% endif %}">{{ r.status }}</span></td> <td class="p-2 text-center"><span class="badge {% if r.status == 'ok' or r.status == 'patched' %}badge-green{% elif r.status == 'ko' or r.status == 'failed' %}badge-red{% else %}badge-yellow{% endif %}">{{ r.status }}</span></td>
<td class="p-2 text-gray-400" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ r.notes or '' }}">{{ (r.notes or '-')[:50] }}</td> <td class="p-2 text-gray-400" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ r.notes or '' }}">{{ (r.notes or '-')[:40] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
{% if not rows %} {% if not rows %}
<tr><td colspan="9" class="p-6 text-center text-gray-500">Aucun event de patching pour ce filtre</td></tr> <tr><td colspan="11" class="p-6 text-center text-gray-500">Aucun event de patching pour ce filtre</td></tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
@ -131,9 +151,10 @@
<!-- Pagination --> <!-- Pagination -->
{% if total_filtered > per_page %} {% if total_filtered > per_page %}
<div class="flex justify-center gap-2 mt-4"> <div class="flex justify-center gap-2 mt-4">
{% if page > 1 %}<a href="?year={{ year }}{% if week %}&week={{ week }}{% endif %}{% if source %}&source={{ source }}{% endif %}{% if hostname %}&hostname={{ hostname }}{% endif %}&page={{ page - 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">← Précédent</a>{% endif %} {% set qs = 'year=' ~ year ~ ('&week=' ~ week if week else '') ~ ('&source=' ~ source if source else '') ~ ('&os_family=' ~ os_family if os_family else '') ~ ('&zone=' ~ zone if zone else '') ~ ('&domain=' ~ domain if domain else '') ~ ('&intervenant=' ~ intervenant if intervenant else '') ~ ('&hostname=' ~ hostname if hostname else '') %}
{% if page > 1 %}<a href="?{{ qs }}&page={{ page - 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">← Précédent</a>{% endif %}
<span class="text-xs text-gray-500 py-1">Page {{ page }} / {{ ((total_filtered - 1) // per_page) + 1 }}</span> <span class="text-xs text-gray-500 py-1">Page {{ page }} / {{ ((total_filtered - 1) // per_page) + 1 }}</span>
{% if page * per_page < total_filtered %}<a href="?year={{ year }}{% if week %}&week={{ week }}{% endif %}{% if source %}&source={{ source }}{% endif %}{% if hostname %}&hostname={{ hostname }}{% endif %}&page={{ page + 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">Suivant →</a>{% endif %} {% if page * per_page < total_filtered %}<a href="?{{ qs }}&page={{ page + 1 }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-1 text-xs">Suivant →</a>{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,10 @@
-- Migration 2026-04-17 : ajout colonne intervenant_name a patch_history
-- pour stocker le nom d'intervenant libre provenant du xlsx (ex "Khalid", "Thierno")
-- sans FK users (car ne correspond pas forcement a un user patchcenter)
BEGIN;
ALTER TABLE patch_history ADD COLUMN IF NOT EXISTS intervenant_name varchar(100);
CREATE INDEX IF NOT EXISTS idx_ph_intervenant_name ON patch_history (intervenant_name);
COMMIT;

View File

@ -119,7 +119,7 @@ def collect_events(wb, hosts):
stats = {"histo_2025_s1": 0, "histo_2025_s2": 0, stats = {"histo_2025_s1": 0, "histo_2025_s2": 0,
"weekly": 0, "no_server": 0, "weekly_no_color": 0} "weekly": 0, "no_server": 0, "weekly_no_color": 0}
# --- Histo-2025 : col L (12) date S1, col M (13) flag S1, col O (15) date S2, col P (16) flag S2 # --- Histo-2025 : col B (2) Intervenant, col L (12) date S1, col M (13) flag S1, col O (15) date S2, col P (16) flag S2
if "Histo-2025" in wb.sheetnames: if "Histo-2025" in wb.sheetnames:
ws = wb["Histo-2025"] ws = wb["Histo-2025"]
for row_idx in range(2, ws.max_row + 1): for row_idx in range(2, ws.max_row + 1):
@ -131,12 +131,16 @@ def collect_events(wb, hosts):
stats["no_server"] += 1 stats["no_server"] += 1
continue continue
interv = ws.cell(row=row_idx, column=2).value
interv = str(interv).strip() if interv else None
date_s1 = parse_date_cell(ws.cell(row=row_idx, column=12).value) date_s1 = parse_date_cell(ws.cell(row=row_idx, column=12).value)
flag_s1 = ws.cell(row=row_idx, column=13).value flag_s1 = ws.cell(row=row_idx, column=13).value
if flag_s1 and isinstance(flag_s1, int) and flag_s1 >= 1: if flag_s1 and isinstance(flag_s1, int) and flag_s1 >= 1:
dt = date_s1 or datetime(2025, 6, 30, 0, 0) dt = date_s1 or datetime(2025, 6, 30, 0, 0)
events.append({"sid": sid, "dt": dt, "status": "ok", events.append({"sid": sid, "dt": dt, "status": "ok",
"notes": f"Histo-2025 S1 (x{flag_s1})"}) "notes": f"Histo-2025 S1 (x{flag_s1})",
"interv": interv})
stats["histo_2025_s1"] += 1 stats["histo_2025_s1"] += 1
date_s2 = parse_date_cell(ws.cell(row=row_idx, column=15).value) date_s2 = parse_date_cell(ws.cell(row=row_idx, column=15).value)
@ -144,7 +148,8 @@ def collect_events(wb, hosts):
if flag_s2 and isinstance(flag_s2, int) and flag_s2 >= 1: if flag_s2 and isinstance(flag_s2, int) and flag_s2 >= 1:
dt = date_s2 or datetime(2025, 12, 31, 0, 0) dt = date_s2 or datetime(2025, 12, 31, 0, 0)
events.append({"sid": sid, "dt": dt, "status": "ok", events.append({"sid": sid, "dt": dt, "status": "ok",
"notes": f"Histo-2025 S2 (x{flag_s2})"}) "notes": f"Histo-2025 S2 (x{flag_s2})",
"interv": interv})
stats["histo_2025_s2"] += 1 stats["histo_2025_s2"] += 1
# --- Weekly sheets S02..S52 : nom colore VERT = patche (2026) # --- Weekly sheets S02..S52 : nom colore VERT = patche (2026)
@ -170,6 +175,9 @@ def collect_events(wb, hosts):
stats["no_server"] += 1 stats["no_server"] += 1
continue continue
interv = ws.cell(row=row_idx, column=2).value
interv = str(interv).strip() if interv else None
# col N (14) = Date, col O (15) = Heure # col N (14) = Date, col O (15) = Heure
date_val = ws.cell(row=row_idx, column=14).value date_val = ws.cell(row=row_idx, column=14).value
hour_val = ws.cell(row=row_idx, column=15).value hour_val = ws.cell(row=row_idx, column=15).value
@ -180,7 +188,8 @@ def collect_events(wb, hosts):
# sinon : heure = 00:00 par defaut (deja dans dt_base) # sinon : heure = 00:00 par defaut (deja dans dt_base)
events.append({"sid": sid, "dt": dt_base, "status": "ok", events.append({"sid": sid, "dt": dt_base, "status": "ok",
"notes": f"Semaine {wk:02d} 2026"}) "notes": f"Semaine {wk:02d} 2026",
"interv": interv})
stats["weekly"] += 1 stats["weekly"] += 1
return events, stats return events, stats
@ -235,8 +244,8 @@ def main():
skipped += 1 skipped += 1
continue continue
conn.execute(text(""" conn.execute(text("""
INSERT INTO patch_history (server_id, date_patch, status, notes) INSERT INTO patch_history (server_id, date_patch, status, notes, intervenant_name)
VALUES (:sid, :dt, :status, :notes) VALUES (:sid, :dt, :status, :notes, :interv)
"""), ev) """), ev)
inserted += 1 inserted += 1