From 630297f98e279655727594c36d51a04c818d3534 Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Mon, 4 May 2026 13:57:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(patching/import):=20stockage=20date/heure?= =?UTF-8?q?=20typ=C3=A9s=20(DATE+TIME)=20+=20jour=5Ftext=20fallback=20text?= =?UTF-8?q?e=20libre=20+=20tri=20colonne=20Date=20par=20date+heure=20combi?= =?UTF-8?q?n=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/planning_import.py | 93 +++++++++++++++++++++++++++--- app/templates/patching_import.html | 54 ++++++++++++----- migrate_planning_imports_v3.sql | 30 ++++++++++ 3 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 migrate_planning_imports_v3.sql diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 7883ef2..e754afd 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -11,7 +11,7 @@ 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 datetime import date, datetime, time from fastapi import APIRouter, Request, Depends, UploadFile, File, Form from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.templating import Jinja2Templates @@ -76,6 +76,73 @@ def _coerce_date(v): return None +def _coerce_date_or_text(v): + """Renvoie (date|None, fallback_text|None). + Si v est un datetime/date → (date, None). + Si v est une string parseable dd/mm/yyyy → (date, None). + Si v est une string non parseable (ex: "A partir du 14/01") → (None, str). + """ + if v is None or v == "": + return None, None + if isinstance(v, datetime): + return v.date(), None + if isinstance(v, date): + return v, None + if isinstance(v, str): + s = v.strip() + if not s: + return None, None + m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{2,4})$", s) + if m: + try: + d = int(m.group(1)); mo = int(m.group(2)); y = int(m.group(3)) + if y < 100: + y += 2000 + return date(y, mo, d), None + except ValueError: + pass + return None, s + return None, str(v) + + +def _coerce_time(v): + """Renvoie un datetime.time ou None. + Accepte: time, datetime, str '9H00', '12h30', '9:00', '14:00:00'. + """ + if v is None or v == "": + return None + if isinstance(v, time): + return v + if isinstance(v, datetime): + return v.time() + if isinstance(v, str): + s = v.strip() + m = re.match(r"^(\d{1,2})[Hh:](\d{2})(?::(\d{2}))?$", s) + if m: + try: + hh = int(m.group(1)); mm = int(m.group(2)); ss = int(m.group(3) or 0) + return time(hh, mm, ss) + except ValueError: + return None + return None + + +def _format_heure(v): + """Renvoie une chaîne 'HHhMM' style SANEF pour affichage. + Si v est string, on garde tel quel (gère les cas '9H00 et 14H00'). + Si v est time/datetime, on synthétise '9H00'. + """ + if v is None or v == "": + return None + if isinstance(v, str): + return v.strip() + if isinstance(v, time): + return f"{v.hour}H{v.minute:02d}" + if isinstance(v, datetime): + return f"{v.hour}H{v.minute:02d}" + return str(v) + + def _coerce_bool(v): if v is None or v == "": return None @@ -239,8 +306,10 @@ async def import_sheet_json(request: Request, import_id: int, sheet_name: str, r.valideur_ra, r.responsable_domaine_dts, r.description, r.assistant, r.referent_technique, r.mode_operatoire, r.impacts, r.commentaire, r.base_de_donnees, - r.duree_coupure, r.jour, r.heure, r.pb_espace_disque, r.date_patch_realise, - r.server_id, s.hostname as resolved_hostname + r.duree_coupure, r.jour, r.jour_text, r.heure, r.heure_t, + r.pb_espace_disque, r.date_patch_realise, + r.server_id, s.hostname as resolved_hostname, + (r.jour + COALESCE(r.heure_t, TIME '00:00:00')) AS start_at 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 @@ -270,7 +339,10 @@ async def import_sheet_json(request: Request, import_id: int, sheet_name: str, "base_de_donnees": r.base_de_donnees, "duree_coupure": r.duree_coupure, "jour": r.jour.isoformat() if r.jour else None, + "jour_text": r.jour_text, "heure": r.heure, + "heure_t": r.heure_t.strftime("%H:%M:%S") if r.heure_t else None, + "start_iso": r.start_at.isoformat() if r.start_at else None, "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, @@ -348,6 +420,9 @@ async def import_upload(request: Request, db=Depends(get_db), if not asset_str: continue sid = hostname_map.get(asset_str.lower()) + jour_d, jour_t = _coerce_date_or_text(rec.get("jour")) + heure_t = _coerce_time(rec.get("heure")) + heure_disp = _format_heure(rec.get("heure")) db.execute(text(""" INSERT INTO patch_planning_import_rows ( import_id, sheet_name, week_number, row_index, @@ -355,7 +430,8 @@ async def import_upload(request: Request, db=Depends(get_db), application_name, valideur_ra, responsable_domaine_dts, description, assistant, referent_technique, mode_operatoire, impacts, commentaire, base_de_donnees, - duree_coupure, jour, heure, pb_espace_disque, date_patch_realise, + duree_coupure, jour, jour_text, heure, heure_t, + pb_espace_disque, date_patch_realise, raw_data, server_id ) VALUES ( :imp, :sn, :wn, :ri, @@ -363,7 +439,8 @@ async def import_upload(request: Request, db=Depends(get_db), :ap, :vr, :rd, :de, :as_, :rt, :mo, :im, :co, :bdd, - :dc, :jr, :hr, :pb, :dpr, + :dc, :jr, :jt, :hr, :ht, + :pb, :dpr, :raw, :sid ) """), { @@ -385,8 +462,10 @@ async def import_upload(request: Request, db=Depends(get_db), "co": str(rec.get("commentaire")) if rec.get("commentaire") else None, "bdd": str(rec.get("base_de_donnees")) if rec.get("base_de_donnees") 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, + "jr": jour_d, + "jt": jour_t, + "hr": heure_disp, + "ht": heure_t, "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), diff --git a/app/templates/patching_import.html b/app/templates/patching_import.html index dff1e81..be67c82 100644 --- a/app/templates/patching_import.html +++ b/app/templates/patching_import.html @@ -149,7 +149,9 @@ Mode op. Impacts BDD - Date + + Date + Heure Coupure Pb disque @@ -181,9 +183,13 @@ const fCount = document.getElementById('filter-count'); const thAsset = document.getElementById('th-asset'); const thAssetArrow = document.getElementById('th-asset-arrow'); + const thDate = document.getElementById('th-date'); + const thDateArrow = document.getElementById('th-date-arrow'); let currentRows = []; - let sortAsset = 0; // 0 = ordre fichier, 1 = asc, -1 = desc + // sortKey ∈ {null, 'asset', 'date'} ; sortDir ∈ {1 asc, -1 desc} + let sortKey = null; + let sortDir = 1; function escapeHTML(s){ if (s === null || s === undefined) return ''; @@ -226,8 +232,17 @@ } function updateSortArrow(){ - thAssetArrow.textContent = sortAsset === 1 ? '▲' : (sortAsset === -1 ? '▼' : '↕'); - thAssetArrow.classList.toggle('opacity-50', sortAsset === 0); + const arrow = sortDir === 1 ? '▲' : '▼'; + thAssetArrow.textContent = sortKey === 'asset' ? arrow : '↕'; + thAssetArrow.classList.toggle('opacity-50', sortKey !== 'asset'); + thDateArrow.textContent = sortKey === 'date' ? arrow : '↕'; + thDateArrow.classList.toggle('opacity-50', sortKey !== 'date'); + } + + function cycleSort(key){ + if (sortKey !== key) { sortKey = key; sortDir = 1; } + else if (sortDir === 1) { sortDir = -1; } + else { sortKey = null; sortDir = 1; } } async function loadSheet(name){ @@ -245,19 +260,31 @@ currentRows = j.rows; rebuildSelectOptions(fInter, currentRows.map(r => r.intervenant), '— Tous intervenants —'); rebuildSelectOptions(fEnv, currentRows.map(r => r.environnement), '— Tous environnements —'); - sortAsset = 0; + sortKey = null; sortDir = 1; updateSortArrow(); renderTable(); } function renderTable(){ let rows = currentRows.slice(); - if (sortAsset !== 0) { + if (sortKey === 'asset') { rows.sort((a, b) => { const av = (a.asset_name || '').toLowerCase(); const bv = (b.asset_name || '').toLowerCase(); - if (av < bv) return -sortAsset; - if (av > bv) return sortAsset; + if (av < bv) return -sortDir; + if (av > bv) return sortDir; + return 0; + }); + } else if (sortKey === 'date') { + rows.sort((a, b) => { + // Lignes sans start_iso (date texte libre) en fin + const av = a.start_iso || ''; + const bv = b.start_iso || ''; + if (!av && !bv) return 0; + if (!av) return 1; + if (!bv) return -1; + if (av < bv) return -sortDir; + if (av > bv) return sortDir; return 0; }); } @@ -284,7 +311,7 @@ + '' + escapeHTML(r.mode_operatoire||'') + '' + '' + escapeHTML(r.impacts||'') + '' + '' + escapeHTML(r.base_de_donnees||'') + '' - + '' + escapeHTML(r.jour||'') + '' + + '' + (r.jour ? escapeHTML(r.jour) : (r.jour_text ? '' + escapeHTML(r.jour_text) + '' : '')) + '' + '' + escapeHTML(r.heure||'') + '' + '' + escapeHTML(r.duree_coupure||'') + '' + '' + pb + '' @@ -306,15 +333,12 @@ [fInter, fEnv].forEach(el => el.addEventListener('change', applyFilters)); fReset.addEventListener('click', () => { fInter.value = ''; fEnv.value = ''; - sortAsset = 0; - updateSortArrow(); - renderTable(); - }); - thAsset.addEventListener('click', () => { - sortAsset = sortAsset === 1 ? -1 : (sortAsset === -1 ? 0 : 1); + sortKey = null; sortDir = 1; updateSortArrow(); renderTable(); }); + thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); }); + thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); }); btnPre.addEventListener('click', () => { const ids = Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => cb.dataset.serverId).filter(x => x); diff --git a/migrate_planning_imports_v3.sql b/migrate_planning_imports_v3.sql new file mode 100644 index 0000000..13367c6 --- /dev/null +++ b/migrate_planning_imports_v3.sql @@ -0,0 +1,30 @@ +-- Migration v3 : stockage date/heure dans le bon format pour permettre le tri +-- Ajoute : +-- jour_text text -- fallback quand la cellule "Date" contient du texte libre +-- (ex: "A partir du 14/01", "Mercredi / Jeudi", "15/04 - 16/04") +-- heure_t time -- heure typée pour tri SQL/JS +-- Backfill heure_t depuis la colonne heure existante (formats "9H00", "12h30", "9:00"). +-- Idempotent. + +ALTER TABLE public.patch_planning_import_rows + ADD COLUMN IF NOT EXISTS jour_text text, + ADD COLUMN IF NOT EXISTS heure_t time; + +-- Backfill heure_t depuis l'existant (uniquement si pas déjà rempli) +UPDATE public.patch_planning_import_rows +SET heure_t = CASE + -- "9H00", "12h30", "14H00" + WHEN heure ~* '^[0-9]{1,2}[Hh][0-9]{2}$' THEN + ((regexp_replace(heure, '[Hh]', ':')) || ':00')::time + -- "9:00", "14:30" + WHEN heure ~ '^[0-9]{1,2}:[0-9]{2}$' THEN + (heure || ':00')::time + -- "9:00:00" + WHEN heure ~ '^[0-9]{1,2}:[0-9]{2}:[0-9]{2}$' THEN + heure::time + ELSE NULL +END +WHERE heure IS NOT NULL AND heure_t IS NULL; + +CREATE INDEX IF NOT EXISTS idx_pp_rows_jour_heure + ON public.patch_planning_import_rows(jour, heure_t);