feat(patching): import planning xlsx (etape 1) - tables patch_planning_imports + rows, page upload + selecteur semaine + tableau
This commit is contained in:
parent
e79678b640
commit
557015325b
@ -12,7 +12,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, 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):
|
||||
@ -62,6 +62,7 @@ app.include_router(settings.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(campaigns.router)
|
||||
app.include_router(planning.router)
|
||||
app.include_router(planning_import.router)
|
||||
app.include_router(specifics.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(contacts.router)
|
||||
|
||||
395
app/routers/planning_import.py
Normal file
395
app/routers/planning_import.py
Normal 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)
|
||||
@ -84,7 +84,8 @@
|
||||
<span x-text="open === 'patching' ? '▾' : '▸'" class="text-xs"></span>
|
||||
</button>
|
||||
<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 %}<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 %}
|
||||
|
||||
234
app/templates/patching_import.html
Normal file
234
app/templates/patching_import.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
60
migrate_planning_imports.sql
Normal file
60
migrate_planning_imports.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user