feat(patching): import planning xlsx (etape 1) - tables patch_planning_imports + rows, page upload + selecteur semaine + tableau

This commit is contained in:
Pierre & Lumière 2026-05-04 12:57:35 +02:00
parent e79678b640
commit 557015325b
5 changed files with 693 additions and 2 deletions

View File

@ -12,7 +12,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms from .dependencies import get_current_user, get_user_perms
from .database import SessionLocal, SessionLocalDemo from .database import SessionLocal, SessionLocalDemo
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications, patch_history, duty from .routers import auth, dashboard, servers, settings, users, campaigns, planning, planning_import, specifics, audit, contacts, qualys, qualys_tags, quickwin, referentiel, patching, applications, patch_history, duty
class PermissionsMiddleware(BaseHTTPMiddleware): class PermissionsMiddleware(BaseHTTPMiddleware):
@ -62,6 +62,7 @@ app.include_router(settings.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(campaigns.router) app.include_router(campaigns.router)
app.include_router(planning.router) app.include_router(planning.router)
app.include_router(planning_import.router)
app.include_router(specifics.router) app.include_router(specifics.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(contacts.router) app.include_router(contacts.router)

View File

@ -0,0 +1,395 @@
"""Router import du planning de patching depuis Excel.
Fonctionnalités :
- Upload xlsx (multi-feuilles, 1 feuille = 1 semaine S02..S52)
- Liste des imports précédents
- Affichage du contenu d'un import : sélecteur de semaine + tableau des serveurs
- Endpoints JSON pour AJAX (sélection de semaine sans rechargement)
Le module pré-patching et le patching by-step seront branchés en étape 2/3.
"""
import io
import json
import re
from datetime import date, datetime
from fastapi import APIRouter, Request, Depends, UploadFile, File, 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, base_context
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# Colonnes attendues dans les feuilles Sxx (ordre indicatif, on matche par regex/lower)
KNOWN_COLUMNS = {
"asset_name": [r"asset\s*name", r"\bnom\b"],
"intervenant": [r"intervenant"],
"environnement": [r"environnement|environement"],
"domaine": [r"^domaine"],
"os": [r"^\s*os\s*$"],
"os_version": [r"version\s*os"],
"application_name": [r"application", r"logiciel"],
"valideur_ra": [r"valideur"],
"description": [r"description"],
"assistant": [r"^assistant"],
"commentaire": [r"commentaire|impact"],
"duree_coupure": [r"dur.+coupure"],
"jour": [r"^\s*jour\s*$|date\s*pr.+vis"],
"heure": [r"^\s*heure"],
"pb_espace_disque": [r"espace\s*disque"],
"date_patch_realise":[r"date\s*du?\s*patch.+r.+alis"],
}
SHEET_WEEK_RE = re.compile(r"^S\s*0?(\d+)$", re.IGNORECASE)
def _can_import(perms):
"""Droit d'importer = niveau edit/admin sur planning ou campaigns."""
return can_edit(perms, "planning") or can_edit(perms, "campaigns")
def _to_iso(v):
if v is None:
return None
if isinstance(v, (datetime, date)):
return v.isoformat()
return str(v)
def _coerce_date(v):
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
return None
def _coerce_bool(v):
if v is None or v == "":
return None
if isinstance(v, bool):
return v
s = str(v).strip().lower()
if s in ("true", "vrai", "oui", "yes", "1", "x"):
return True
if s in ("false", "faux", "non", "no", "0"):
return False
return None
def _norm_header(h):
if h is None:
return ""
return re.sub(r"\s+", " ", str(h)).strip().lower()
def _build_column_map(headers):
"""Mappe l'index de colonne → nom logique (asset_name, intervenant, ...)."""
col_map = {}
used_logical = set()
for idx, h in enumerate(headers):
norm = _norm_header(h)
if not norm:
continue
for logical, patterns in KNOWN_COLUMNS.items():
if logical in used_logical:
continue
for pat in patterns:
if re.search(pat, norm):
col_map[idx] = logical
used_logical.add(logical)
break
if logical in used_logical:
break
return col_map
def _parse_sheet(ws, sheet_name):
"""Parse une feuille xlsx → liste de dict {logical_col: value, _raw: {header: value}}."""
rows_iter = ws.iter_rows(values_only=True)
try:
headers = next(rows_iter)
except StopIteration:
return [], []
headers = [h for h in headers]
col_map = _build_column_map(headers)
parsed = []
for ridx, row in enumerate(rows_iter, start=1):
if row is None:
continue
if all(c is None or (isinstance(c, str) and not c.strip()) for c in row):
continue
rec = {"row_index": ridx}
raw = {}
for cidx, val in enumerate(row):
header = headers[cidx] if cidx < len(headers) else f"col_{cidx}"
header_str = _norm_header(header) or f"col_{cidx}"
raw[header_str] = _to_iso(val)
if cidx in col_map:
rec[col_map[cidx]] = val
rec["_raw"] = raw
parsed.append(rec)
return headers, parsed
def _list_imports(db):
return db.execute(text("""
SELECT i.id, i.filename, i.year, i.sheet_count, i.row_count,
i.uploaded_at, u.username as uploaded_by_name
FROM patch_planning_imports i
LEFT JOIN users u ON u.id = i.uploaded_by
ORDER BY i.uploaded_at DESC
LIMIT 50
""")).fetchall()
# ────────────────────────────────────────────────────────────────────────
# Pages
# ────────────────────────────────────────────────────────────────────────
@router.get("/patching/import", response_class=HTMLResponse)
async def import_index(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
return RedirectResponse(url="/dashboard")
imports = _list_imports(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"imports": imports,
"current_import": None,
"can_import": _can_import(perms),
"msg": request.query_params.get("msg"),
"err": request.query_params.get("err"),
})
return templates.TemplateResponse("patching_import.html", ctx)
@router.get("/patching/import/{import_id}", response_class=HTMLResponse)
async def import_view(request: Request, import_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_view(perms, "planning") or can_view(perms, "campaigns")):
return RedirectResponse(url="/dashboard")
imp = db.execute(text("""
SELECT i.*, u.username as uploaded_by_name
FROM patch_planning_imports i
LEFT JOIN users u ON u.id = i.uploaded_by
WHERE i.id = :id
"""), {"id": import_id}).fetchone()
if not imp:
return RedirectResponse(url="/patching/import?err=notfound")
sheets = db.execute(text("""
SELECT sheet_name, week_number, COUNT(*) as nb
FROM patch_planning_import_rows
WHERE import_id = :id
GROUP BY sheet_name, week_number
ORDER BY week_number NULLS LAST, sheet_name
"""), {"id": import_id}).fetchall()
imports = _list_imports(db)
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"imports": imports,
"current_import": imp,
"sheets": sheets,
"can_import": _can_import(perms),
"msg": request.query_params.get("msg"),
"err": request.query_params.get("err"),
})
return templates.TemplateResponse("patching_import.html", ctx)
# ────────────────────────────────────────────────────────────────────────
# JSON : rows d'une feuille
# ────────────────────────────────────────────────────────────────────────
@router.get("/patching/import/{import_id}/sheet/{sheet_name}")
async def import_sheet_json(request: Request, import_id: int, sheet_name: str,
db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
rows = db.execute(text("""
SELECT r.id, r.row_index, r.asset_name, r.intervenant, r.environnement,
r.domaine, r.os, r.os_version, r.application_name, r.valideur_ra,
r.description, r.assistant, r.commentaire, r.duree_coupure,
r.jour, r.heure, r.pb_espace_disque, r.date_patch_realise,
r.server_id, s.hostname as resolved_hostname
FROM patch_planning_import_rows r
LEFT JOIN servers s ON s.id = r.server_id
WHERE r.import_id = :id AND r.sheet_name = :sn
ORDER BY r.row_index
"""), {"id": import_id, "sn": sheet_name}).fetchall()
out = []
for r in rows:
out.append({
"id": r.id,
"row_index": r.row_index,
"asset_name": r.asset_name,
"intervenant": r.intervenant,
"environnement": r.environnement,
"domaine": r.domaine,
"os": r.os,
"os_version": r.os_version,
"application_name": r.application_name,
"valideur_ra": r.valideur_ra,
"description": r.description,
"assistant": r.assistant,
"commentaire": r.commentaire,
"duree_coupure": r.duree_coupure,
"jour": r.jour.isoformat() if r.jour else None,
"heure": r.heure,
"pb_espace_disque": r.pb_espace_disque,
"date_patch_realise": r.date_patch_realise.isoformat() if r.date_patch_realise else None,
"server_id": r.server_id,
"resolved_hostname": r.resolved_hostname,
})
return JSONResponse({"ok": True, "rows": out, "count": len(out)})
# ────────────────────────────────────────────────────────────────────────
# Upload
# ────────────────────────────────────────────────────────────────────────
@router.post("/patching/import/upload")
async def import_upload(request: Request, db=Depends(get_db),
file: UploadFile = File(...),
note: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login", status_code=303)
perms = get_user_perms(db, user)
if not _can_import(perms):
return RedirectResponse(url="/patching/import?err=denied", status_code=303)
fname = file.filename or "import.xlsx"
if not fname.lower().endswith(".xlsx"):
return RedirectResponse(url="/patching/import?err=ext", status_code=303)
try:
import openpyxl
except ImportError:
return RedirectResponse(url="/patching/import?err=openpyxl_missing", status_code=303)
content = await file.read()
try:
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
except Exception as e:
print(f"[import_upload] load_workbook failed: {e}")
return RedirectResponse(url="/patching/import?err=parse", status_code=303)
# Détecter l'année (depuis nom de fichier ou colonne 'jour' de la 1ère feuille semaine)
year_match = re.search(r"(20\d{2})", fname)
year = int(year_match.group(1)) if year_match else None
# Insert header
db.execute(text("""
INSERT INTO patch_planning_imports (filename, year, sheet_count, row_count, uploaded_by, note)
VALUES (:fn, :y, 0, 0, :uid, :nt)
"""), {"fn": fname, "y": year, "uid": user.get("uid"), "nt": note or None})
db.commit()
import_id = db.execute(text("SELECT lastval()")).scalar()
sheet_count = 0
row_count = 0
# Pré-charge mapping hostname → server_id pour résolution
hostname_map = {}
for r in db.execute(text("SELECT id, hostname FROM servers")).fetchall():
if r.hostname:
hostname_map[r.hostname.lower().strip()] = r.id
for sheet_name in wb.sheetnames:
m = SHEET_WEEK_RE.match(sheet_name.strip())
if not m:
# On ignore les feuilles "Histo-XXX" et autres non-semaines
continue
week_num = int(m.group(1))
ws = wb[sheet_name]
_, parsed_rows = _parse_sheet(ws, sheet_name)
if not parsed_rows:
continue
sheet_count += 1
for rec in parsed_rows:
asset = rec.get("asset_name")
asset_str = str(asset).strip() if asset else None
if not asset_str:
continue
sid = hostname_map.get(asset_str.lower())
db.execute(text("""
INSERT INTO patch_planning_import_rows (
import_id, sheet_name, week_number, row_index,
asset_name, intervenant, environnement, domaine, os, os_version,
application_name, valideur_ra, description, assistant, commentaire,
duree_coupure, jour, heure, pb_espace_disque, date_patch_realise,
raw_data, server_id
) VALUES (
:imp, :sn, :wn, :ri,
:an, :it, :en, :do, :os, :ov,
:ap, :vr, :de, :as_, :co,
:dc, :jr, :hr, :pb, :dpr,
:raw, :sid
)
"""), {
"imp": import_id, "sn": sheet_name, "wn": week_num, "ri": rec["row_index"],
"an": asset_str,
"it": str(rec.get("intervenant")) if rec.get("intervenant") else None,
"en": str(rec.get("environnement")) if rec.get("environnement") else None,
"do": str(rec.get("domaine")) if rec.get("domaine") else None,
"os": str(rec.get("os")) if rec.get("os") else None,
"ov": str(rec.get("os_version")) if rec.get("os_version") else None,
"ap": str(rec.get("application_name")) if rec.get("application_name") else None,
"vr": str(rec.get("valideur_ra")) if rec.get("valideur_ra") else None,
"de": str(rec.get("description")) if rec.get("description") else None,
"as_": str(rec.get("assistant")) if rec.get("assistant") else None,
"co": str(rec.get("commentaire")) if rec.get("commentaire") else None,
"dc": str(rec.get("duree_coupure")) if rec.get("duree_coupure") else None,
"jr": _coerce_date(rec.get("jour")),
"hr": str(rec.get("heure")) if rec.get("heure") else None,
"pb": _coerce_bool(rec.get("pb_espace_disque")),
"dpr": _coerce_date(rec.get("date_patch_realise")),
"raw": json.dumps(rec.get("_raw") or {}, ensure_ascii=False, default=str),
"sid": sid,
})
row_count += 1
db.execute(text("""
UPDATE patch_planning_imports SET sheet_count=:s, row_count=:r WHERE id=:id
"""), {"s": sheet_count, "r": row_count, "id": import_id})
db.commit()
return RedirectResponse(url=f"/patching/import/{import_id}?msg=ok", status_code=303)
# ────────────────────────────────────────────────────────────────────────
# Suppression
# ────────────────────────────────────────────────────────────────────────
@router.post("/patching/import/{import_id}/delete")
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login", status_code=303)
perms = get_user_perms(db, user)
if not _can_import(perms):
return RedirectResponse(url="/patching/import?err=denied", status_code=303)
db.execute(text("DELETE FROM patch_planning_imports WHERE id=:id"), {"id": import_id})
db.commit()
return RedirectResponse(url="/patching/import?msg=deleted", status_code=303)

View File

@ -84,7 +84,8 @@
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span> <span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
</button> </button>
<div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1"> <div x-show="open === 'patching'" x-cloak class="space-y-1 pl-1">
{% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'planning' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %} {% if p.planning %}<a href="/planning" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if path == '/planning' %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Planning</a>{% endif %}
{% if p.planning or p.campaigns %}<a href="/patching/import" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/patching/import' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Importer planning</a>{% endif %}
{% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %} {% if p.campaigns in ('edit', 'admin') %}<a href="/assignments" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'assignments' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Assignation</a>{% endif %}
{% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %} {% if p.campaigns %}<a href="/campaigns" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'campaigns' in path and 'assignments' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Campagnes</a>{% endif %}
{% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %} {% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}<a href="/patching/config-exclusions" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'config-exclusions' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Config exclusions</a>{% endif %}

View File

@ -0,0 +1,234 @@
{% extends 'base.html' %}
{% block title %}Importer planning patching{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<div>
<h2 class="text-xl font-bold text-cyber-accent">Importer planning de patching</h2>
<p class="text-xs text-gray-500 mt-1">
Upload du fichier Excel "Plan de Patching serveurs YYYY". Une feuille = une semaine (S02..S52).
Les onglets historiques (Histo-XXXX) sont ignorés.
</p>
</div>
{% if current_import %}
<a href="/patching/import" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Liste imports</a>
{% endif %}
</div>
{% if msg == 'ok' %}<div class="bg-cyber-green/20 text-cyber-green p-2 mb-3 text-xs rounded">Import réussi.</div>{% endif %}
{% if msg == 'deleted' %}<div class="bg-cyber-blue/20 text-cyber-blue p-2 mb-3 text-xs rounded">Import supprimé.</div>{% endif %}
{% if err == 'ext' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Le fichier doit être un .xlsx.</div>{% endif %}
{% if err == 'parse' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Impossible de parser le fichier.</div>{% endif %}
{% if err == 'denied' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Permission refusée.</div>{% endif %}
{% if err == 'notfound' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Import introuvable.</div>{% endif %}
{% if err == 'openpyxl_missing' %}<div class="bg-cyber-red/20 text-cyber-red p-2 mb-3 text-xs rounded">Lib openpyxl manquante côté serveur.</div>{% endif %}
{# ──────────── Upload form ──────────── #}
{% if can_import %}
<div class="card p-4 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Nouvel import</h3>
<form method="POST" action="/patching/import/upload" enctype="multipart/form-data" class="flex flex-wrap gap-2 items-center">
<input type="file" name="file" accept=".xlsx" required class="text-xs">
<input type="text" name="note" placeholder="Note (optionnelle)" class="text-xs px-2 py-1 flex-1 min-w-[200px]">
<button type="submit" class="btn-primary px-3 py-1 text-xs">Importer</button>
</form>
</div>
{% endif %}
{# ──────────── Liste des imports ──────────── #}
<div class="card p-3 mb-4">
<h3 class="text-sm font-bold text-cyber-accent mb-2">Imports récents ({{ imports|length }})</h3>
{% if imports %}
<table class="w-full text-xs">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1">ID</th>
<th class="text-left p-1">Fichier</th>
<th class="text-left p-1">Année</th>
<th class="text-right p-1">Feuilles</th>
<th class="text-right p-1">Lignes</th>
<th class="text-left p-1">Date</th>
<th class="text-left p-1">Par</th>
<th class="text-right p-1">Actions</th>
</tr>
</thead>
<tbody>
{% for i in imports %}
<tr class="border-b border-cyber-border/30 {% if current_import and current_import.id == i.id %}bg-cyber-border/30{% endif %}">
<td class="p-1">#{{ i.id }}</td>
<td class="p-1"><a href="/patching/import/{{ i.id }}" class="text-cyber-accent hover:underline">{{ i.filename }}</a></td>
<td class="p-1">{{ i.year or '' }}</td>
<td class="p-1 text-right">{{ i.sheet_count }}</td>
<td class="p-1 text-right">{{ i.row_count }}</td>
<td class="p-1">{{ i.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="p-1">{{ i.uploaded_by_name or '' }}</td>
<td class="p-1 text-right">
<a href="/patching/import/{{ i.id }}" class="text-cyber-blue hover:underline">Voir</a>
{% if can_import %}
· <form method="POST" action="/patching/import/{{ i.id }}/delete" class="inline" onsubmit="return confirm('Supprimer cet import ?')">
<button type="submit" class="text-cyber-red hover:underline">Suppr</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-xs text-gray-500">Aucun import pour le moment.</p>
{% endif %}
</div>
{# ──────────── Détail de l'import courant ──────────── #}
{% if current_import %}
<div class="card p-4 mb-4">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="text-sm font-bold text-cyber-accent">Import #{{ current_import.id }} : {{ current_import.filename }}</h3>
<p class="text-xs text-gray-500 mt-1">
{{ current_import.sheet_count }} feuilles · {{ current_import.row_count }} lignes ·
{{ current_import.uploaded_at.strftime('%Y-%m-%d %H:%M') }}
{% if current_import.note %} · <em>{{ current_import.note }}</em>{% endif %}
</p>
</div>
</div>
{# Sélecteur de semaine #}
<div class="flex gap-2 items-center mb-3 flex-wrap">
<label class="text-xs text-gray-400">Semaine :</label>
<select id="sheet-select" class="text-xs px-2 py-1">
<option value="">— Choisir —</option>
{% for s in sheets %}
<option value="{{ s.sheet_name }}">{{ s.sheet_name }} (S{{ '%02d' % s.week_number }}) — {{ s.nb }} serveur(s)</option>
{% endfor %}
</select>
<span id="sheet-summary" class="text-xs text-gray-500"></span>
</div>
{# Tableau dynamique #}
<div id="sheet-table-wrap" class="overflow-x-auto" style="display:none;">
<div class="flex gap-2 items-center mb-2 flex-wrap">
<label class="text-xs">
<input type="checkbox" id="select-all" class="mr-1"> Tout sélectionner
</label>
<span class="text-xs text-gray-400" id="selection-count">0 sélectionné(s)</span>
<div class="flex-1"></div>
<button id="btn-prepatch" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-3 py-1 text-xs" disabled>
Pré-patching <span class="text-[10px] opacity-60">(étape 2)</span>
</button>
<button id="btn-patch" class="btn-sm bg-cyber-green/20 text-cyber-green px-3 py-1 text-xs" disabled>
Patcher <span class="text-[10px] opacity-60">(étape 3)</span>
</button>
</div>
<table class="w-full text-xs" id="sheet-table">
<thead class="text-cyber-accent border-b border-cyber-border">
<tr>
<th class="text-left p-1 w-6"><input type="checkbox" id="select-all-head"></th>
<th class="text-left p-1">Asset</th>
<th class="text-left p-1">Env</th>
<th class="text-left p-1">Domaine</th>
<th class="text-left p-1">OS</th>
<th class="text-left p-1">Application</th>
<th class="text-left p-1">Intervenant</th>
<th class="text-left p-1">Valideur RA</th>
<th class="text-left p-1">Jour</th>
<th class="text-left p-1">Heure</th>
<th class="text-left p-1">Coupure</th>
<th class="text-left p-1">Pb disque</th>
<th class="text-left p-1">Lien serveur</th>
</tr>
</thead>
<tbody id="sheet-table-body"></tbody>
</table>
</div>
<div id="sheet-empty" class="text-xs text-gray-500" style="display:none;">Aucune ligne pour cette feuille.</div>
</div>
<script>
(function(){
const importId = {{ current_import.id }};
const sel = document.getElementById('sheet-select');
const wrap = document.getElementById('sheet-table-wrap');
const empty = document.getElementById('sheet-empty');
const tbody = document.getElementById('sheet-table-body');
const summary = document.getElementById('sheet-summary');
const selAll = document.getElementById('select-all');
const selAllHead = document.getElementById('select-all-head');
const selCount = document.getElementById('selection-count');
const btnPre = document.getElementById('btn-prepatch');
const btnPatch = document.getElementById('btn-patch');
function escapeHTML(s){
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function refreshSelection(){
const checked = tbody.querySelectorAll('input.row-cb:checked').length;
const total = tbody.querySelectorAll('input.row-cb').length;
selCount.textContent = checked + ' / ' + total + ' sélectionné(s)';
selAll.checked = (checked > 0 && checked === total);
selAllHead.checked = selAll.checked;
// Pré-patching et patch désactivés tant que les étapes 2/3 ne sont pas faites
// mais on prépare la condition pour la suite :
const hasSel = checked > 0;
btnPre.disabled = !hasSel;
btnPatch.disabled = !hasSel;
}
async function loadSheet(name){
if (!name) { wrap.style.display='none'; empty.style.display='none'; return; }
summary.textContent = 'Chargement…';
const r = await fetch('/patching/import/' + importId + '/sheet/' + encodeURIComponent(name));
const j = await r.json();
if (!j.ok) { summary.textContent = j.msg || 'Erreur'; return; }
if (!j.rows.length) {
wrap.style.display='none'; empty.style.display=''; summary.textContent='';
return;
}
empty.style.display='none'; wrap.style.display='';
summary.textContent = j.count + ' lignes';
tbody.innerHTML = j.rows.map(r => {
const linkSrv = r.server_id
? '<a href="/qualys/search?field=hostname&q=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" class="text-cyber-blue hover:underline">' + escapeHTML(r.resolved_hostname || r.asset_name) + '</a>'
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
const pb = r.pb_espace_disque === true ? '<span class="text-cyber-red">⚠ Oui</span>' : (r.pb_espace_disque === false ? 'Non' : '');
return '<tr class="border-b border-cyber-border/20 hover:bg-cyber-border/10">'
+ '<td class="p-1"><input type="checkbox" class="row-cb" data-id="' + r.id + '" data-asset="' + escapeHTML(r.asset_name||'') + '" data-server-id="' + (r.server_id||'') + '"></td>'
+ '<td class="p-1 font-mono">' + escapeHTML(r.asset_name||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.environnement||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.domaine||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.os||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.application_name||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.intervenant||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.valideur_ra||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.jour||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.heure||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.duree_coupure||'') + '</td>'
+ '<td class="p-1">' + pb + '</td>'
+ '<td class="p-1">' + linkSrv + '</td>'
+ '</tr>';
}).join('');
tbody.querySelectorAll('input.row-cb').forEach(cb => cb.addEventListener('change', refreshSelection));
refreshSelection();
}
sel.addEventListener('change', () => loadSheet(sel.value));
function toggleAll(state){
tbody.querySelectorAll('input.row-cb').forEach(cb => cb.checked = state);
refreshSelection();
}
selAll.addEventListener('change', () => toggleAll(selAll.checked));
selAllHead.addEventListener('change', () => toggleAll(selAllHead.checked));
btnPre.addEventListener('click', () => {
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x);
alert('Pré-patching à brancher (étape 2) — ' + ids.length + ' serveur(s) résolu(s) en base.');
});
btnPatch.addEventListener('click', () => {
const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x);
alert('Patching by-step à brancher (étape 3) — ' + ids.length + ' serveur(s) résolu(s) en base.');
});
})();
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,60 @@
-- Migration : import du planning de patching annuel depuis Excel
-- Tables :
-- patch_planning_imports : 1 ligne par fichier xlsx importe
-- patch_planning_import_rows : 1 ligne par serveur d'une feuille (semaine)
-- Idempotent
-- 1) Table parente : entête du fichier importé
CREATE TABLE IF NOT EXISTS public.patch_planning_imports (
id SERIAL PRIMARY KEY,
filename text NOT NULL,
year integer,
sheet_count integer NOT NULL DEFAULT 0,
row_count integer NOT NULL DEFAULT 0,
uploaded_by integer REFERENCES public.users(id) ON DELETE SET NULL,
uploaded_at timestamptz NOT NULL DEFAULT now(),
note text
);
CREATE INDEX IF NOT EXISTS idx_pp_imports_year ON public.patch_planning_imports(year);
CREATE INDEX IF NOT EXISTS idx_pp_imports_uploaded_at ON public.patch_planning_imports(uploaded_at DESC);
-- 2) Table fille : 1 ligne = 1 serveur dans une feuille (S02, S03...)
-- On stocke les colonnes connues + raw_data JSONB pour rester souple
CREATE TABLE IF NOT EXISTS public.patch_planning_import_rows (
id SERIAL PRIMARY KEY,
import_id integer NOT NULL REFERENCES public.patch_planning_imports(id) ON DELETE CASCADE,
sheet_name text NOT NULL, -- 'S02', 'S03'...
week_number integer, -- extrait de sheet_name (ex 2, 3...)
row_index integer NOT NULL, -- position dans la feuille (1 = 1ere ligne data)
asset_name text,
intervenant text,
environnement text,
domaine text,
os text,
os_version text,
application_name text,
valideur_ra text,
description text,
assistant text,
commentaire text,
duree_coupure text,
jour date,
heure text,
pb_espace_disque boolean,
date_patch_realise date,
raw_data jsonb, -- toutes les colonnes brutes (filet de sécurité)
server_id integer REFERENCES public.servers(id) ON DELETE SET NULL, -- résolu si match hostname
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_pp_import_rows_import ON public.patch_planning_import_rows(import_id);
CREATE INDEX IF NOT EXISTS idx_pp_import_rows_sheet ON public.patch_planning_import_rows(import_id, sheet_name);
CREATE INDEX IF NOT EXISTS idx_pp_import_rows_asset ON public.patch_planning_import_rows(asset_name);
CREATE INDEX IF NOT EXISTS idx_pp_import_rows_week ON public.patch_planning_import_rows(week_number);
-- 3) GRANT pour le user applicatif
GRANT SELECT, INSERT, UPDATE, DELETE ON public.patch_planning_imports TO patchcenter;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.patch_planning_import_rows TO patchcenter;
GRANT USAGE, SELECT ON SEQUENCE public.patch_planning_imports_id_seq TO patchcenter;
GRANT USAGE, SELECT ON SEQUENCE public.patch_planning_import_rows_id_seq TO patchcenter;