diff --git a/app/main.py b/app/main.py index 43ca935..9c539b6 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py new file mode 100644 index 0000000..76f2b52 --- /dev/null +++ b/app/routers/planning_import.py @@ -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) diff --git a/app/templates/base.html b/app/templates/base.html index 3610103..5a26327 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -84,7 +84,8 @@
- {% if p.planning %}Planning{% endif %} + {% if p.planning %}Planning{% endif %} + {% if p.planning or p.campaigns %}Importer planning{% endif %} {% if p.campaigns in ('edit', 'admin') %}Assignation{% endif %} {% if p.campaigns %}Campagnes{% endif %} {% if p.servers in ('edit','admin') or p.campaigns in ('edit','admin') or p.quickwin in ('edit','admin') %}Config exclusions{% endif %} diff --git a/app/templates/patching_import.html b/app/templates/patching_import.html new file mode 100644 index 0000000..4ffac96 --- /dev/null +++ b/app/templates/patching_import.html @@ -0,0 +1,234 @@ +{% extends 'base.html' %} +{% block title %}Importer planning patching{% endblock %} +{% block content %} +
+
+

Importer planning de patching

+

+ Upload du fichier Excel "Plan de Patching serveurs YYYY". Une feuille = une semaine (S02..S52). + Les onglets historiques (Histo-XXXX) sont ignorés. +

+
+ {% if current_import %} + ← Liste imports + {% endif %} +
+ +{% if msg == 'ok' %}
Import réussi.
{% endif %} +{% if msg == 'deleted' %}
Import supprimé.
{% endif %} +{% if err == 'ext' %}
Le fichier doit être un .xlsx.
{% endif %} +{% if err == 'parse' %}
Impossible de parser le fichier.
{% endif %} +{% if err == 'denied' %}
Permission refusée.
{% endif %} +{% if err == 'notfound' %}
Import introuvable.
{% endif %} +{% if err == 'openpyxl_missing' %}
Lib openpyxl manquante côté serveur.
{% endif %} + +{# ──────────── Upload form ──────────── #} +{% if can_import %} +
+

Nouvel import

+
+ + + +
+
+{% endif %} + +{# ──────────── Liste des imports ──────────── #} +
+

Imports récents ({{ imports|length }})

+ {% if imports %} + + + + + + + + + + + + + + + {% for i in imports %} + + + + + + + + + + + {% endfor %} + +
IDFichierAnnéeFeuillesLignesDateParActions
#{{ i.id }}{{ i.filename }}{{ i.year or '–' }}{{ i.sheet_count }}{{ i.row_count }}{{ i.uploaded_at.strftime('%Y-%m-%d %H:%M') }}{{ i.uploaded_by_name or '–' }} + Voir + {% if can_import %} + ·
+ +
+ {% endif %} +
+ {% else %} +

Aucun import pour le moment.

+ {% endif %} +
+ +{# ──────────── Détail de l'import courant ──────────── #} +{% if current_import %} +
+
+
+

Import #{{ current_import.id }} : {{ current_import.filename }}

+

+ {{ current_import.sheet_count }} feuilles · {{ current_import.row_count }} lignes · + {{ current_import.uploaded_at.strftime('%Y-%m-%d %H:%M') }} + {% if current_import.note %} · {{ current_import.note }}{% endif %} +

+
+
+ + {# Sélecteur de semaine #} +
+ + + +
+ + {# Tableau dynamique #} + + +
+ + +{% endif %} +{% endblock %} diff --git a/migrate_planning_imports.sql b/migrate_planning_imports.sql new file mode 100644 index 0000000..1257368 --- /dev/null +++ b/migrate_planning_imports.sql @@ -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;