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:
parent
8b6057aef2
commit
630297f98e
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
30
migrate_planning_imports_v3.sql
Normal file
30
migrate_planning_imports_v3.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user