- Service mail_service.py: send_html_mail via SMTP standard (host/port/user/pass/from/use_tls
depuis Settings > SMTP). Gere SSL_465 et STARTTLS_587. Mode dry_run pour preview.
- Settings: nouvelle section 'smtp' avec smtp_host/port/user/pass/from/use_tls/pct_recipient
(a configurer pour O365 SMTP submission)
- Router planning_import.py:
* _build_pct_email(): construit subject + HTML pro/colore (header bleu degrade SANEF,
cards avec border-left bleu/orange, tableau serveurs, footer)
* Subject: 'Intervention sur <app>' si app uniforme, sinon liste des serveurs
* Plage horaire = 20 min × N serveurs (formattee Hh MM)
* 'Moyen d'exploitation prevu : Rollback en cas de probleme' ajoute en bas
* _fetch_pct_cc_emails(): query distinct contacts depuis responsable_domaine_contact_id
+ referent_technique_contact_id + server_additional_referents
* Endpoint POST /patching/import/pct-prevenance/preview retourne {subject, html, to, cc,
smtp_configured, row_count} sans envoyer
* Endpoint POST /patching/import/pct-prevenance/send envoie reellement, audit log,
update pct_mail_sent_at sur les rows
- Template patching_import.html:
* Bouton 'Prevenance PCT' (violet) a cote des autres actions
* Modal preview avec iframe sandboxe pour le rendu HTML mail
* Affiche destinataires, CC, objet, count serveurs
* Warning rouge si SMTP non configure (envoi desactive, preview seulement)
* 2 boutons: Annuler / Envoyer (avec confirmation)
611 lines
33 KiB
HTML
611 lines
33 KiB
HTML
{% 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;">
|
||
{# Filtres client-side #}
|
||
<div class="flex gap-2 items-center mb-2 flex-wrap">
|
||
<select id="filter-intervenant" class="text-xs px-2 py-1">
|
||
<option value="">— Tous intervenants —</option>
|
||
</select>
|
||
<select id="filter-env" class="text-xs px-2 py-1">
|
||
<option value="">— Tous environnements —</option>
|
||
</select>
|
||
<button id="filter-reset" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</button>
|
||
<span class="text-xs text-gray-400" id="filter-count"></span>
|
||
</div>
|
||
<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 (visibles)
|
||
</label>
|
||
<span class="text-xs text-gray-400" id="selection-count">0 sélectionné(s)</span>
|
||
<div class="flex-1"></div>
|
||
<style>
|
||
.btn-action {
|
||
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
|
||
border-radius: 6px; cursor: pointer; border: 1px solid currentColor;
|
||
box-shadow: 0 0 8px currentColor;
|
||
transition: all .15s;
|
||
text-transform: uppercase; letter-spacing: 0.03em;
|
||
}
|
||
.btn-action:hover { transform: translateY(-1px); box-shadow: 0 0 14px currentColor; }
|
||
.btn-action.btn-add { background: rgba(34,197,94,.15); color: #22c55e; }
|
||
.btn-action.btn-add:hover { background: rgba(34,197,94,.30); }
|
||
.btn-action.btn-rep { background: rgba(59,130,246,.15); color: #3b82f6; }
|
||
.btn-action.btn-rep:hover { background: rgba(59,130,246,.30); }
|
||
.btn-action.btn-cancel { background: rgba(156,163,175,.15); color: #9ca3af; }
|
||
.btn-action.btn-cancel:hover { background: rgba(156,163,175,.30); color: #f3f4f6; }
|
||
.btn-action.btn-pre { background: rgba(245,158,11,.18); color: #f59e0b; }
|
||
.btn-action.btn-pre:hover { background: rgba(245,158,11,.35); }
|
||
</style>
|
||
{% if can_import %}
|
||
<button id="btn-add-eligible" class="btn-action btn-add" title="Marque les lignes sélectionnées comme éligibles au patching">
|
||
+ Ajouter au patching
|
||
</button>
|
||
<button id="btn-report" class="btn-action btn-rep" title="Reporter les lignes sélectionnées à une autre semaine">
|
||
⤳ Reporter
|
||
</button>
|
||
<button id="btn-unset" class="btn-action btn-cancel" title="Annuler éligibilité / report sur les lignes sélectionnées">
|
||
✕ Annuler
|
||
</button>
|
||
{% endif %}
|
||
<button id="btn-prepatch" class="btn-action btn-pre" title="Lancer le pré-patching (rows éligibles uniquement)">
|
||
▶ Pré-patching
|
||
</button>
|
||
{% if can_import %}
|
||
<button id="btn-pct-mail" type="button" style="
|
||
padding: 6px 14px; font-size: 0.8rem; font-weight: 700;
|
||
border-radius: 6px; cursor: pointer; border: 1px solid #a78bfa;
|
||
background: rgba(167,139,250,.15); color: #a78bfa;
|
||
box-shadow: 0 0 8px #a78bfa; text-transform: uppercase;
|
||
letter-spacing: 0.03em;"
|
||
title="Envoie un mail à PCT.reims@sanef.com pour annoncer l'intervention sur les serveurs sélectionnés (preview avant envoi)">
|
||
📧 Prévenance PCT
|
||
</button>
|
||
{% endif %}
|
||
</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">État</th>
|
||
<th class="text-left p-1 cursor-pointer select-none hover:text-cyber-accent" id="th-asset" title="Cliquer pour trier">
|
||
Asset <span id="th-asset-arrow" class="text-[10px] opacity-50">↕</span>
|
||
</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">Version 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">Resp. Domaine DTS</th>
|
||
<th class="text-left p-1">Référent tech.</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>
|
||
</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 btnAddElig = document.getElementById('btn-add-eligible');
|
||
const btnReport = document.getElementById('btn-report');
|
||
const btnUnset = document.getElementById('btn-unset');
|
||
const fInter = document.getElementById('filter-intervenant');
|
||
const fEnv = document.getElementById('filter-env');
|
||
const fReset = document.getElementById('filter-reset');
|
||
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 = [];
|
||
// sortKey ∈ {null, 'asset', 'date'} ; sortDir ∈ {1 asc, -1 desc}
|
||
let sortKey = null;
|
||
let sortDir = 1;
|
||
|
||
function escapeHTML(s){
|
||
if (s === null || s === undefined) return '';
|
||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
function dateFR(iso){
|
||
// 'YYYY-MM-DD' -> 'DD/MM/YYYY'
|
||
if (!iso) return '';
|
||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : iso;
|
||
}
|
||
|
||
function shortOSVersion(v){
|
||
if (!v) return '';
|
||
const s = String(v);
|
||
let m = s.match(/red\s*hat[^0-9]+(\d+)/i);
|
||
if (m) return 'RedHat ' + m[1];
|
||
m = s.match(/centos[^0-9]+(\d+)/i);
|
||
if (m) return 'CentOS ' + m[1];
|
||
m = s.match(/oracle\s*linux[^0-9]+(\d+)/i);
|
||
if (m) return 'Oracle ' + m[1];
|
||
m = s.match(/ubuntu[^0-9]+(\d+(?:\.\d+)?)/i);
|
||
if (m) return 'Ubuntu ' + m[1];
|
||
m = s.match(/debian[^0-9]+(\d+)/i);
|
||
if (m) return 'Debian ' + m[1];
|
||
m = s.match(/windows\s*server\s*(\d{4})/i);
|
||
if (m) return 'Win ' + m[1];
|
||
m = s.match(/windows\s*(\d+)/i);
|
||
if (m) return 'Win ' + m[1];
|
||
// Fallback : 30 premiers chars
|
||
return s.length > 30 ? s.slice(0, 30) + '…' : s;
|
||
}
|
||
|
||
function refreshSelection(){
|
||
const visible = tbody.querySelectorAll('tr:not(.row-hidden)');
|
||
const visibleCb = tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb');
|
||
const checkedCb = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
|
||
const checked = checkedCb.length;
|
||
const eligibleSelected = checkedCb.filter(cb => cb.dataset.eligible === '1').length;
|
||
selCount.textContent = checked + ' sélectionné(s) · ' + visible.length + ' visible(s)' + (eligibleSelected ? ' · ' + eligibleSelected + ' éligible(s)' : '');
|
||
const allVisibleChecked = visibleCb.length > 0 && Array.from(visibleCb).every(cb => cb.checked);
|
||
selAll.checked = allVisibleChecked;
|
||
selAllHead.checked = allVisibleChecked;
|
||
// Boutons toujours cliquables : si rien de sélectionné on alerte au clic
|
||
// (au lieu de griser un bouton qu'on ne peut pas atteindre).
|
||
// Atténuation visuelle quand pas de sélection pour donner un feedback :
|
||
const hasSel = checked > 0;
|
||
const dimIfEmpty = (btn, active) => {
|
||
if (!btn) return;
|
||
btn.classList.toggle('opacity-50', !active);
|
||
};
|
||
dimIfEmpty(btnAddElig, hasSel);
|
||
dimIfEmpty(btnReport, hasSel);
|
||
dimIfEmpty(btnUnset, hasSel);
|
||
dimIfEmpty(btnPre, eligibleSelected > 0);
|
||
}
|
||
|
||
function getSelectedRowIds(){
|
||
return Array.from(tbody.querySelectorAll('input.row-cb:checked')).map(cb => parseInt(cb.dataset.id, 10)).filter(x => x);
|
||
}
|
||
|
||
async function postAction(payload){
|
||
const r = await fetch('/patching/import/' + importId + '/rows/action', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Erreur : ' + (j.msg || 'inconnue')); return false; }
|
||
return true;
|
||
}
|
||
|
||
async function reloadCurrentSheet(){
|
||
if (sel.value) await loadSheet(sel.value);
|
||
}
|
||
|
||
function applyFilters(){
|
||
// Comparaisons case-insensitive (Production == production, etc.)
|
||
const fi = (fInter.value || '').trim().toLowerCase();
|
||
const fe = (fEnv.value || '').trim().toLowerCase();
|
||
let visibleCount = 0;
|
||
tbody.querySelectorAll('tr').forEach(tr => {
|
||
const i = (tr.dataset.intervenant || '').toLowerCase();
|
||
const e = (tr.dataset.env || '').toLowerCase();
|
||
const ok = (!fi || i === fi) && (!fe || e === fe);
|
||
if (ok) { tr.classList.remove('row-hidden'); tr.style.display=''; visibleCount++; }
|
||
else { tr.classList.add('row-hidden'); tr.style.display='none'; }
|
||
});
|
||
fCount.textContent = visibleCount + ' / ' + currentRows.length + ' affichées';
|
||
refreshSelection();
|
||
}
|
||
|
||
function rebuildSelectOptions(sel, values, placeholder){
|
||
// Dedup case-insensitive : on garde la 1re forme rencontrée comme canonique
|
||
// (généralement la majuscule "Production" si elle apparaît avant "production")
|
||
const cur = sel.value;
|
||
const seen = new Map(); // lowercase -> canonical form
|
||
for (const v of values) {
|
||
if (!v) continue;
|
||
const k = v.toLowerCase();
|
||
if (!seen.has(k)) seen.set(k, v);
|
||
}
|
||
const opts = Array.from(seen.values()).sort((a,b) =>
|
||
a.localeCompare(b, 'fr', {sensitivity:'base'}));
|
||
sel.innerHTML = '<option value="">' + placeholder + '</option>'
|
||
+ opts.map(v => '<option value="' + escapeHTML(v) + '"' + (cur === v ? ' selected' : '') + '>' + escapeHTML(v) + '</option>').join('');
|
||
}
|
||
|
||
function updateSortArrow(){
|
||
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){
|
||
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';
|
||
currentRows = j.rows;
|
||
rebuildSelectOptions(fInter, currentRows.map(r => r.intervenant), '— Tous intervenants —');
|
||
rebuildSelectOptions(fEnv, currentRows.map(r => r.environnement), '— Tous environnements —');
|
||
sortKey = null; sortDir = 1;
|
||
updateSortArrow();
|
||
renderTable();
|
||
}
|
||
|
||
function renderTable(){
|
||
let rows = currentRows.slice();
|
||
if (sortKey === 'asset') {
|
||
rows.sort((a, b) => {
|
||
const av = (a.asset_name || '').toLowerCase();
|
||
const bv = (b.asset_name || '').toLowerCase();
|
||
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;
|
||
});
|
||
}
|
||
tbody.innerHTML = rows.map(r => {
|
||
const display = escapeHTML(r.resolved_hostname || r.asset_name || '');
|
||
const assetCell = r.server_id
|
||
? '<a href="/servers?search=' + encodeURIComponent(r.resolved_hostname || r.asset_name) + '" target="_blank" rel="noopener" class="text-cyber-blue hover:underline">' + display + '</a>'
|
||
: '<span class="text-cyber-yellow" title="Pas matché en base PatchCenter">' + escapeHTML(r.asset_name || '') + ' ⚠</span>';
|
||
let badge = '';
|
||
if (r.is_eligible) {
|
||
badge = '<span class="text-cyber-green" title="Éligible au patching">✓ ÉLIG.</span>';
|
||
} else if (r.reported_to_sheet) {
|
||
const t = r.report_reason ? ('Reporté → ' + r.reported_to_sheet + ' : ' + r.report_reason) : ('Reporté → ' + r.reported_to_sheet);
|
||
badge = '<span class="text-cyber-blue" title="' + escapeHTML(t) + '">⤳ ' + escapeHTML(r.reported_to_sheet) + '</span>';
|
||
}
|
||
return '<tr class="border-b border-cyber-border/20 hover:bg-cyber-border/10"'
|
||
+ ' data-asset="' + escapeHTML(r.asset_name||'') + '"'
|
||
+ ' data-intervenant="' + escapeHTML(r.intervenant||'') + '"'
|
||
+ ' data-env="' + escapeHTML(r.environnement||'') + '">'
|
||
+ '<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||'') + '" data-eligible="' + (r.is_eligible ? '1':'0') + '"></td>'
|
||
+ '<td class="p-1 text-[10px]">' + badge + '</td>'
|
||
+ '<td class="p-1 font-mono">' + assetCell + '</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" title="' + escapeHTML(r.os_version||'') + '">' + escapeHTML(shortOSVersion(r.os_version)) + '</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.responsable_domaine_dts||'') + '</td>'
|
||
+ '<td class="p-1">' + escapeHTML(r.referent_technique||'') + '</td>'
|
||
+ '<td class="p-1">' + (r.jour ? escapeHTML(dateFR(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>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
tbody.querySelectorAll('input.row-cb').forEach(cb => cb.addEventListener('change', refreshSelection));
|
||
applyFilters();
|
||
}
|
||
|
||
sel.addEventListener('change', () => loadSheet(sel.value));
|
||
function toggleAll(state){
|
||
tbody.querySelectorAll('tr:not(.row-hidden) input.row-cb').forEach(cb => cb.checked = state);
|
||
refreshSelection();
|
||
}
|
||
selAll.addEventListener('change', () => toggleAll(selAll.checked));
|
||
selAllHead.addEventListener('change', () => toggleAll(selAllHead.checked));
|
||
|
||
[fInter, fEnv].forEach(el => el.addEventListener('change', applyFilters));
|
||
fReset.addEventListener('click', () => {
|
||
fInter.value = ''; fEnv.value = '';
|
||
sortKey = null; sortDir = 1;
|
||
updateSortArrow();
|
||
renderTable();
|
||
});
|
||
thAsset.addEventListener('click', () => { cycleSort('asset'); updateSortArrow(); renderTable(); });
|
||
thDate.addEventListener('click', () => { cycleSort('date'); updateSortArrow(); renderTable(); });
|
||
|
||
if (btnAddElig) btnAddElig.addEventListener('click', async () => {
|
||
const ids = getSelectedRowIds();
|
||
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
|
||
if (!confirm('Marquer ' + ids.length + ' ligne(s) comme éligibles au patching ?')) return;
|
||
if (await postAction({row_ids: ids, action: 'eligible'})) await reloadCurrentSheet();
|
||
});
|
||
if (btnReport) btnReport.addEventListener('click', async () => {
|
||
const ids = getSelectedRowIds();
|
||
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
|
||
const target = (prompt('Reporter vers quelle semaine ? (ex: S23)') || '').trim();
|
||
if (!target) return;
|
||
if (!/^S\d{1,2}$/i.test(target)) { alert('Format attendu : Sxx (ex S23)'); return; }
|
||
const reason = (prompt('Raison du report (optionnel) :') || '').trim();
|
||
if (await postAction({row_ids: ids, action: 'report', target_sheet: target.toUpperCase(), reason})) await reloadCurrentSheet();
|
||
});
|
||
if (btnUnset) btnUnset.addEventListener('click', async () => {
|
||
const ids = getSelectedRowIds();
|
||
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
|
||
if (!confirm('Annuler éligibilité ET report sur ' + ids.length + ' ligne(s) ?')) return;
|
||
if (await postAction({row_ids: ids, action: 'unset_eligible'})) {
|
||
await postAction({row_ids: ids, action: 'unset_report'});
|
||
await reloadCurrentSheet();
|
||
}
|
||
});
|
||
btnPre.addEventListener('click', () => {
|
||
const checkedAny = Array.from(tbody.querySelectorAll('input.row-cb:checked'));
|
||
if (!checkedAny.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
|
||
const ids = checkedAny
|
||
.filter(cb => cb.dataset.eligible === '1')
|
||
.map(cb => cb.dataset.id);
|
||
if (!ids.length) {
|
||
alert('Aucun serveur sélectionné n\'est éligible. Marque-les d\'abord avec "+ Ajouter au patching".');
|
||
return;
|
||
}
|
||
window.location.href = '/patching/iexec?row_ids=' + ids.join(',');
|
||
});
|
||
|
||
// ─── Prévenance PCT — preview puis envoi ─────────────────────────
|
||
const btnPctMail = document.getElementById('btn-pct-mail');
|
||
if (btnPctMail) {
|
||
btnPctMail.addEventListener('click', async () => {
|
||
const ids = getSelectedRowIds();
|
||
if (!ids.length) { alert('Veuillez sélectionner au moins un serveur.'); return; }
|
||
// 1) preview
|
||
try {
|
||
const r = await fetch('/patching/import/pct-prevenance/preview', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({row_ids: ids}),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) { alert('Erreur preview : ' + (j.msg || '')); return; }
|
||
showPctPreview(j, ids);
|
||
} catch (e) { alert('Erreur réseau : ' + e); }
|
||
});
|
||
}
|
||
|
||
function showPctPreview(data, ids) {
|
||
const modal = document.getElementById('pct-modal');
|
||
document.getElementById('pct-subject').textContent = data.subject;
|
||
document.getElementById('pct-to').textContent = data.to;
|
||
const ccList = (data.cc || []);
|
||
const ccHtml = ccList.length
|
||
? ccList.map(c => '<span class="badge badge-blue">' + escapeHTML(c.name) + ' <' + escapeHTML(c.email) + '></span>').join(' ')
|
||
: '<span class="text-gray-500">(aucun contact en CC — vérifie les responsables/référents en BDD)</span>';
|
||
document.getElementById('pct-cc').innerHTML = ccHtml;
|
||
document.getElementById('pct-iframe').srcdoc = data.html;
|
||
document.getElementById('pct-smtp-warn').style.display = data.smtp_configured ? 'none' : '';
|
||
document.getElementById('pct-rowcount').textContent = data.row_count;
|
||
modal.classList.add('active');
|
||
const btnSend = document.getElementById('pct-btn-send');
|
||
btnSend.disabled = !data.smtp_configured;
|
||
btnSend.onclick = async () => {
|
||
if (!confirm('Envoyer ce mail à ' + data.to + ' (+ ' + ccList.length + ' en CC) ?')) return;
|
||
btnSend.disabled = true; btnSend.textContent = '⏳ Envoi…';
|
||
try {
|
||
const r = await fetch('/patching/import/pct-prevenance/send', {
|
||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({row_ids: ids}),
|
||
});
|
||
const j = await r.json();
|
||
if (j.ok) {
|
||
alert('✅ Mail envoyé.\n' + (j.msg || ''));
|
||
modal.classList.remove('active');
|
||
} else {
|
||
alert('❌ Échec envoi : ' + (j.msg || 'inconnu'));
|
||
}
|
||
} catch (e) { alert('Erreur réseau : ' + e); }
|
||
finally { btnSend.disabled = false; btnSend.textContent = '📤 Envoyer'; }
|
||
};
|
||
}
|
||
function closePctModal() {
|
||
document.getElementById('pct-modal').classList.remove('active');
|
||
}
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closePctModal();
|
||
});
|
||
document.getElementById('pct-btn-cancel')?.addEventListener('click', closePctModal);
|
||
document.getElementById('pct-modal-bg')?.addEventListener('click', closePctModal);
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
|
||
{# Modal preview Prévenance PCT #}
|
||
<style>
|
||
#pct-modal { position:fixed; inset:0; z-index:9999; display:none; align-items:center; justify-content:center; }
|
||
#pct-modal.active { display:flex; }
|
||
#pct-modal-bg { position:absolute; inset:0; background:rgba(10,14,23,0.85); backdrop-filter:blur(2px); }
|
||
.pct-dialog {
|
||
position:relative; max-width:920px; width:95%; max-height:90vh;
|
||
background:#0f172a; border:1px solid #334155; border-radius:8px;
|
||
box-shadow: 0 0 40px rgba(167,139,250,0.4);
|
||
display:flex; flex-direction:column;
|
||
}
|
||
.pct-header { padding:14px 20px; border-bottom:1px solid #334155; display:flex; justify-content:space-between; align-items:center; }
|
||
.pct-body { padding:14px 20px; overflow-y:auto; flex:1; }
|
||
.pct-footer { padding:12px 20px; border-top:1px solid #334155; display:flex; gap:10px; justify-content:flex-end; }
|
||
.pct-meta-row { display:flex; gap:10px; padding:6px 0; font-size:0.85rem; }
|
||
.pct-meta-row .lbl { color:#9ca3af; min-width:90px; }
|
||
.pct-meta-row .val { color:#e5e7eb; flex:1; word-break:break-all; }
|
||
.pct-meta-row .val .badge { display:inline-block; margin:2px 4px 2px 0; padding:2px 8px; border-radius:4px; font-size:11px; }
|
||
.pct-meta-row .val .badge-blue { background:rgba(59,130,246,0.15); color:#60a5fa; border:1px solid #3b82f6; }
|
||
#pct-iframe { width:100%; height:60vh; border:1px solid #334155; border-radius:4px; background:#fff; }
|
||
.pct-btn {
|
||
padding:8px 16px; font-size:0.85rem; font-weight:700; border-radius:6px;
|
||
cursor:pointer; border:1px solid; text-transform:uppercase; letter-spacing:0.03em;
|
||
}
|
||
.pct-btn-cancel { color:#9ca3af; border-color:#9ca3af; background:rgba(156,163,175,0.1); }
|
||
.pct-btn-cancel:hover { background:rgba(156,163,175,0.25); }
|
||
.pct-btn-send { color:#22c55e; border-color:#22c55e; background:rgba(34,197,94,0.15); }
|
||
.pct-btn-send:hover { background:rgba(34,197,94,0.30); }
|
||
.pct-btn-send:disabled { opacity:0.4; cursor:not-allowed; }
|
||
</style>
|
||
|
||
<div id="pct-modal">
|
||
<div id="pct-modal-bg"></div>
|
||
<div class="pct-dialog">
|
||
<div class="pct-header">
|
||
<div>
|
||
<div style="font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#a78bfa;">Prévenance PCT</div>
|
||
<h3 style="margin:4px 0 0;color:#e5e7eb;font-size:1.1rem;">Aperçu du mail avant envoi</h3>
|
||
</div>
|
||
<button onclick="document.getElementById('pct-modal').classList.remove('active')"
|
||
style="background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:1.4rem;">✕</button>
|
||
</div>
|
||
<div class="pct-body">
|
||
<div class="pct-meta-row"><span class="lbl">Destinataire :</span><span class="val" id="pct-to"></span></div>
|
||
<div class="pct-meta-row"><span class="lbl">CC :</span><span class="val" id="pct-cc"></span></div>
|
||
<div class="pct-meta-row"><span class="lbl">Objet :</span><span class="val" id="pct-subject" style="font-weight:600;"></span></div>
|
||
<div class="pct-meta-row"><span class="lbl">Serveurs :</span><span class="val"><span id="pct-rowcount"></span> ligne(s) sélectionnée(s)</span></div>
|
||
<div id="pct-smtp-warn" style="margin:8px 0;padding:8px 12px;background:rgba(239,68,68,0.15);border-left:3px solid #ef4444;border-radius:4px;color:#fca5a5;font-size:0.85rem;display:none;">
|
||
⚠ <strong>SMTP non configuré</strong> dans Settings > SMTP. L'envoi est désactivé. Tu peux relire la preview ; configure SMTP plus tard pour activer l'envoi.
|
||
</div>
|
||
<h4 style="margin:14px 0 8px;color:#a78bfa;font-size:0.85rem;letter-spacing:0.1em;text-transform:uppercase;">Aperçu HTML du mail</h4>
|
||
<iframe id="pct-iframe" sandbox=""></iframe>
|
||
</div>
|
||
<div class="pct-footer">
|
||
<button id="pct-btn-cancel" class="pct-btn pct-btn-cancel">Annuler</button>
|
||
<button id="pct-btn-send" class="pct-btn pct-btn-send">📤 Envoyer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|