feat(patching/import): stockage date/heure typés (DATE+TIME) + jour_text fallback texte libre + tri colonne Date par date+heure combinés

This commit is contained in:
Pierre & Lumière 2026-05-04 13:57:24 +02:00
parent 8b6057aef2
commit 630297f98e
3 changed files with 155 additions and 22 deletions

View File

@ -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),

View File

@ -149,7 +149,9 @@
<th class="text-left p-1">Mode op.</th>
<th class="text-left p-1">Impacts</th>
<th class="text-left p-1">BDD</th>
<th class="text-left p-1">Date</th>
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-date" title="Cliquer pour trier par date+heure">
Date <span id="th-date-arrow" class="text-[10px] opacity-50"></span>
</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>
@ -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 @@
+ '<td class="p-1">' + escapeHTML(r.mode_operatoire||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.impacts||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.base_de_donnees||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.jour||'') + '</td>'
+ '<td class="p-1">' + (r.jour ? escapeHTML(r.jour) : (r.jour_text ? '<span class="text-cyber-yellow" title="Texte libre">' + escapeHTML(r.jour_text) + '</span>' : '')) + '</td>'
+ '<td class="p-1">' + escapeHTML(r.heure||'') + '</td>'
+ '<td class="p-1">' + escapeHTML(r.duree_coupure||'') + '</td>'
+ '<td class="p-1">' + pb + '</td>'
@ -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);

View File

@ -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);