feat(inspections): restructurer le formulaire d'inspection GIEP par ouvrage

- Isoler la météo au niveau de la zone GIEP globale
- Structurer chaque ouvrage : Conformité, Photos dédiées, Formulaire technique, Remarques
- Lier les photos aux ouvrages via field_key et auto-consolider statut et remarques
- Enrichir l'affichage de consultation et ajouter la suite de tests unitaires
This commit is contained in:
kdeterme 2026-09-25 10:03:27 +02:00
parent 5e9fced68f
commit c6ef935e0b
11 changed files with 1111 additions and 312 deletions

View file

@ -153,7 +153,7 @@
{% endif %} {% endif %}
</ul> </ul>
{% if insp.notes %} {% if insp.notes and not insp.is_giep %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label> <label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label>
<div class="p-2 bg-light rounded border text-secondary small"> <div class="p-2 bg-light rounded border text-secondary small">
@ -162,7 +162,7 @@
</div> </div>
{% endif %} {% endif %}
{% if insp.documents.all %} {% if insp.documents.all and not insp.is_giep %}
<div> <div>
<label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label> <label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label>
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center">

View file

@ -245,6 +245,14 @@ class Inspection(models.Model):
def is_scheduled(self): def is_scheduled(self):
return self.status == 'scheduled' return self.status == 'scheduled'
@property
def is_giep(self):
return (
(self.asset_content_type and self.asset_content_type.model in ('naturerwiz', 'giepinspection')) or
hasattr(self, 'giepinspection') or
isinstance(self, GiepInspection)
)
@property @property
def timeliness(self): def timeliness(self):
""" """
@ -791,29 +799,27 @@ class GiepInspection(Inspection):
verbose_name = _("Inspection GIEP / Eaux pluviales") verbose_name = _("Inspection GIEP / Eaux pluviales")
verbose_name_plural = _("Inspections GIEP / Eaux pluviales") verbose_name_plural = _("Inspections GIEP / Eaux pluviales")
@property
def is_giep(self):
return True
@classmethod @classmethod
def compute_status_from_data(cls, data): def compute_status_for_single_ouvrage(cls, ouv):
""" """
Calcule le résultat global ('damaged', 'non_compliant', 'compliant') Calcule le résultat ('damaged', 'non_compliant', 'compliant')
sur base des réponses aux questions de chaque ouvrage d'une inspection GIEP. sur base des réponses aux questions d'un ouvrage GIEP.
Priorité : damaged > non_compliant > compliant. Priorité : damaged > non_compliant > compliant.
""" """
if not data or not isinstance(data, dict): if not ouv or not isinstance(ouv, dict):
return 'compliant'
ouvrages = data.get('ouvrages', [])
if not isinstance(ouvrages, list) or not ouvrages:
return 'compliant' return 'compliant'
explicit_status = ouv.get('result_status')
if explicit_status in ('damaged', 'non_compliant', 'compliant') and ouv.get('is_result_manual'):
return explicit_status
has_damaged = False
has_non_compliant = False
for ouv in ouvrages:
if not isinstance(ouv, dict):
continue
device_type = (ouv.get('device_type') or 'evc').lower() device_type = (ouv.get('device_type') or 'evc').lower()
q = ouv.get('questions') or {} q = ouv.get('questions') or {}
if not isinstance(q, dict): if not isinstance(q, dict):
continue return explicit_status if explicit_status in ('damaged', 'non_compliant', 'compliant') else 'compliant'
def is_val(key, *expected): def is_val(key, *expected):
v = q.get(key) v = q.get(key)
@ -825,6 +831,9 @@ class GiepInspection(Inspection):
v_str = str(v).strip().lower() v_str = str(v).strip().lower()
return v_str in [str(e).strip().lower() for e in expected] return v_str in [str(e).strip().lower() for e in expected]
has_damaged = False
has_non_compliant = False
if device_type == 'evc': if device_type == 'evc':
# 1. Point d'entrée # 1. Point d'entrée
if is_val('evc_pe_ok', 'false', 'non'): if is_val('evc_pe_ok', 'false', 'non'):
@ -931,7 +940,39 @@ class GiepInspection(Inspection):
return 'damaged' return 'damaged'
if has_non_compliant: if has_non_compliant:
return 'non_compliant' return 'non_compliant'
return explicit_status if explicit_status in ('damaged', 'non_compliant', 'compliant') else 'compliant'
@classmethod
def compute_status_from_data(cls, data):
"""
Calcule le résultat global ('damaged', 'non_compliant', 'compliant')
sur base des réponses aux questions de chaque ouvrage d'une inspection GIEP.
Priorité : damaged > non_compliant > compliant.
"""
if not data or not isinstance(data, dict):
return 'compliant' return 'compliant'
ouvrages = data.get('ouvrages', [])
if not isinstance(ouvrages, list) or not ouvrages:
return 'compliant'
has_damaged = False
has_non_compliant = False
for ouv in ouvrages:
if not isinstance(ouv, dict):
continue
st = cls.compute_status_for_single_ouvrage(ouv)
if st == 'damaged':
has_damaged = True
elif st == 'non_compliant':
has_non_compliant = True
if has_damaged:
return 'damaged'
if has_non_compliant:
return 'non_compliant'
return 'compliant'
class SignPanelInspection(Inspection): class SignPanelInspection(Inspection):

View file

@ -73,11 +73,45 @@
<span class="badge bg-light text-dark border px-2 py-1">{{ ouv.name }}</span> <span class="badge bg-light text-dark border px-2 py-1">{{ ouv.name }}</span>
{% endif %} {% endif %}
</div> </div>
<!-- 1. Conformité badge -->
<div class="d-flex align-items-center gap-1.5 ms-auto">
<span class="badge {{ ouv|ouvrage_result_badge }} px-2.5 py-1.5 fs-6 shadow-sm">
<i class="bi {{ ouv|ouvrage_result_icon }} me-1"></i>{{ ouv|ouvrage_result_label }}
</span>
</div>
</div> </div>
<div class="card-body p-3 bg-light-subtle"> <div class="card-body p-3 bg-light-subtle">
<div class="row g-3"> <div class="row g-3">
<!-- 2. Photos dédiées à cet ouvrage -->
{% with ouv_docs=insp.documents.all|filter_ouvrage_docs:ouv %}
{% if ouv_docs %}
<div class="col-12">
<div class="border rounded-3 p-3 bg-white shadow-sm border-success-subtle">
<h6 class="fw-bold mb-2 text-success small text-uppercase border-bottom pb-1">
<i class="bi bi-camera me-1"></i>{% translate "Photos de l'ouvrage" %} ({{ ouv_docs|length }})
</h6>
<div class="d-flex flex-wrap gap-2 pt-1">
{% for doc in ouv_docs %}
<a href="{{ doc.file_url }}" target="_blank" class="border rounded overflow-hidden shadow-sm d-inline-block position-relative" style="width: 100px; height: 80px;" title="{{ doc.filename }}">
{% if doc.is_image %}
<img src="{{ doc.file_url }}" class="w-100 h-100" style="object-fit: cover;" alt="{{ doc.filename }}">
{% else %}
<div class="d-flex flex-column align-items-center justify-content-center w-100 h-100 bg-light text-muted p-1">
<i class="bi bi-file-earmark-text fs-3 text-secondary"></i>
<span class="extra-small text-truncate" style="max-width: 90px;">{{ doc.filename }}</span>
</div>
{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% endwith %}
<!-- ── 1. EVC (Espace Vert Creux) BLOCS ── --> <!-- ── 1. EVC (Espace Vert Creux) BLOCS ── -->
{% if ouv.device_type == 'evc' %} {% if ouv.device_type == 'evc' %}
<!-- Bloc EVC: Point d'entrée --> <!-- Bloc EVC: Point d'entrée -->
@ -357,6 +391,17 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- 4. Remarques sur cet ouvrage -->
{% if ouv.notes %}
<div class="col-12">
<div class="border rounded-3 p-3 bg-white shadow-sm border-secondary-subtle">
<h6 class="fw-bold mb-1.5 text-secondary small text-uppercase border-bottom pb-1">
<i class="bi bi-chat-left-text me-1 text-primary"></i>{% translate "Remarques sur cet ouvrage" %}
</h6>
<p class="small text-dark mb-0" style="white-space: pre-wrap;">{{ ouv.notes }}</p>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
@ -366,6 +411,31 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Photos générales / zone GIEP -->
{% with gen_docs=insp.documents.all|filter_general_docs %}
{% if gen_docs %}
<div class="card border border-secondary-subtle bg-white p-3 mb-3 shadow-sm rounded-3">
<h6 class="fw-bold mb-2 text-secondary small text-uppercase border-bottom pb-1">
<i class="bi bi-images me-1 text-primary"></i>{% translate "Photos générales de la zone" %} ({{ gen_docs|length }})
</h6>
<div class="d-flex flex-wrap gap-2 pt-1">
{% for doc in gen_docs %}
<a href="{{ doc.file_url }}" target="_blank" class="border rounded overflow-hidden shadow-sm d-inline-block position-relative" style="width: 100px; height: 80px;" title="{{ doc.filename }}">
{% if doc.is_image %}
<img src="{{ doc.file_url }}" class="w-100 h-100" style="object-fit: cover;" alt="{{ doc.filename }}">
{% else %}
<div class="d-flex flex-column align-items-center justify-content-center w-100 h-100 bg-light text-muted p-1">
<i class="bi bi-file-earmark-text fs-3 text-secondary"></i>
<span class="extra-small text-truncate" style="max-width: 90px;">{{ doc.filename }}</span>
</div>
{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endwith %}
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}

View file

@ -14,6 +14,7 @@
<!-- Hidden Field pour la sérialisation JSON --> <!-- Hidden Field pour la sérialisation JSON -->
<input type="hidden" name="giep_data" id="giep_data_serialized" value="{}"> <input type="hidden" name="giep_data" id="giep_data_serialized" value="{}">
<div id="giep_hidden_file_inputs" class="d-none"></div>
<!-- Bandeau de statut automatique GIEP --> <!-- Bandeau de statut automatique GIEP -->
<div id="giep_status_auto_banner" class="giep-status-auto-banner alert alert-info border border-info-subtle rounded-3 p-2.5 mb-3 shadow-sm d-none"></div> <div id="giep_status_auto_banner" class="giep-status-auto-banner alert alert-info border border-info-subtle rounded-3 p-2.5 mb-3 shadow-sm d-none"></div>
@ -178,12 +179,17 @@
function createEmptyOuvrage(idx, deviceType = "evc", asset = null) { function createEmptyOuvrage(idx, deviceType = "evc", asset = null) {
return { return {
local_id: String(idx), local_id: 'ouv_' + Date.now() + '_' + Math.random().toString(36).substr(2, 4),
device_type: deviceType, device_type: deviceType,
name: asset ? (asset.code + (asset.structure_type ? ' (' + asset.structure_type + ')' : '')) : `Ouvrage #${idx + 1}`, name: asset ? (asset.code + (asset.structure_type ? ' (' + asset.structure_type + ')' : '')) : `Ouvrage #${idx + 1}`,
asset_id: asset ? asset.id : null, asset_id: asset ? asset.id : null,
asset_code: asset ? asset.code : null, asset_code: asset ? asset.code : null,
asset_name: asset ? (asset.structure_type || asset.name || '') : null, asset_name: asset ? (asset.structure_type || asset.name || '') : null,
result_status: 'compliant',
is_result_manual: false,
notes: '',
newFiles: [],
existingDocs: [],
questions: { questions: {
// EVC // EVC
evc_pe_ok: null, evc_pe_ok: null,
@ -510,20 +516,105 @@
renderOuvrages(); renderOuvrages();
} }
function syncHiddenFileInputs() {
const hiddenContainer = rootModal.querySelector('#giep_hidden_file_inputs') || document.getElementById('giep_hidden_file_inputs');
if (!hiddenContainer) return;
hiddenContainer.innerHTML = '';
giepOuvrages.forEach((ouv, idx) => {
const localId = ouv.local_id || String(idx);
(ouv.newFiles || []).forEach(file => {
try {
const dt = new DataTransfer();
dt.items.add(file);
const fileInp = document.createElement('input');
fileInp.type = 'file';
fileInp.name = 'photos';
fileInp.files = dt.files;
hiddenContainer.appendChild(fileInp);
const keyInp = document.createElement('input');
keyInp.type = 'hidden';
keyInp.name = 'field_keys';
keyInp.value = 'ouvrage_' + localId;
hiddenContainer.appendChild(keyInp);
} catch (err) {
console.warn('DataTransfer error:', err);
}
});
});
}
function toggleParentGlobalSections(isGiep) {
const sectionsToToggle = [
rootModal.querySelector('#quickInspResultSection'),
rootModal.querySelector('#editInspResultSection'),
rootModal.querySelector('#quickInspPhotosSection'),
rootModal.querySelector('#editInspPhotosSection'),
rootModal.querySelector('#quickInspNotesSection'),
rootModal.querySelector('#editInspNotesSection')
];
sectionsToToggle.forEach(sec => {
if (sec) {
if (isGiep) sec.classList.add('d-none');
else sec.classList.remove('d-none');
}
});
const editExistingDocs = rootModal.querySelector('#editInspectionExistingDocsContainer');
if (editExistingDocs && isGiep) {
editExistingDocs.classList.add('d-none');
}
}
function updateSerializedGiepData() { function updateSerializedGiepData() {
const dateRainEl = rootModal.querySelector('#giep_date_derniere_pluie, [name="date_derniere_pluie"]'); const dateRainEl = rootModal.querySelector('#giep_date_derniere_pluie, [name="date_derniere_pluie"]');
const stormEl = rootModal.querySelector('#giep_orage_moins_une_heure, [name="orage_moins_une_heure"]'); const stormEl = rootModal.querySelector('#giep_orage_moins_une_heure, [name="orage_moins_une_heure"]');
const pluieVal = dateRainEl?.value || 'null'; const pluieVal = dateRainEl?.value || 'null';
const orageVal = stormEl?.value || 'null'; const orageVal = stormEl?.value || 'null';
// Auto-calcul pour chaque ouvrage si non manuel
giepOuvrages.forEach(ouv => {
if (!ouv.is_result_manual) {
ouv.result_status = evaluateSingleOuvrageStatus(ouv);
}
});
// Consolidation des remarques par ouvrage dans le champ notes global de la modale
const notesEl = rootModal.querySelector('textarea[name="notes"]');
if (notesEl) {
const consolidatedNotes = giepOuvrages
.map((o, i) => {
const title = o.asset_code || `Ouvrage #${i + 1}`;
return o.notes && o.notes.trim() ? `[${title}] : ${o.notes.trim()}` : '';
})
.filter(Boolean)
.join('\n\n');
notesEl.value = consolidatedNotes;
}
const payload = { const payload = {
ouvrages: giepOuvrages, ouvrages: giepOuvrages.map((o, i) => ({
local_id: o.local_id || String(i),
device_type: o.device_type,
name: o.name,
asset_id: o.asset_id,
asset_code: o.asset_code,
asset_name: o.asset_name,
result_status: o.result_status || 'compliant',
is_result_manual: Boolean(o.is_result_manual),
notes: o.notes || '',
questions: o.questions || {},
actions: o.actions || {}
})),
date_derniere_pluie: pluieVal, date_derniere_pluie: pluieVal,
orage_moins_une_heure: orageVal === 'true' ? true : (orageVal === 'false' ? false : null) orage_moins_une_heure: orageVal === 'true' ? true : (orageVal === 'false' ? false : null)
}; };
if (serializedInput) { if (serializedInput) {
serializedInput.value = JSON.stringify(payload); serializedInput.value = JSON.stringify(payload);
} }
syncHiddenFileInputs();
} }
// Liste des clés de questions où "Oui" représente une anomalie / dysfonctionnement // Liste des clés de questions où "Oui" représente une anomalie / dysfonctionnement
@ -623,19 +714,12 @@
return false; return false;
} }
function evaluateGiepInspectionStatus(ouvrages) { function evaluateSingleOuvrageStatus(ouv) {
if (!Array.isArray(ouvrages) || ouvrages.length === 0) { if (!ouv) return 'compliant';
return null; if (ouv.is_result_manual && ouv.result_status) {
} return ouv.result_status;
if (!hasAnyGiepAnswer(ouvrages)) {
return null;
} }
let hasDamaged = false;
let hasNonCompliant = false;
for (const ouv of ouvrages) {
if (!ouv) continue;
const deviceType = (ouv.device_type || 'evc').toLowerCase(); const deviceType = (ouv.device_type || 'evc').toLowerCase();
const q = ouv.questions || {}; const q = ouv.questions || {};
@ -646,6 +730,9 @@
return expected.map(e => String(e).trim().toLowerCase()).includes(vStr); return expected.map(e => String(e).trim().toLowerCase()).includes(vStr);
}; };
let hasDamaged = false;
let hasNonCompliant = false;
if (deviceType === 'evc') { if (deviceType === 'evc') {
// 1. Point d'entrée // 1. Point d'entrée
if (isVal('evc_pe_ok', 'false', 'non')) { if (isVal('evc_pe_ok', 'false', 'non')) {
@ -710,6 +797,9 @@
// 1. Revêtement // 1. Revêtement
if (isVal('oed_revetement_ok', 'false', 'non')) { if (isVal('oed_revetement_ok', 'false', 'non')) {
hasNonCompliant = true; hasNonCompliant = true;
if (isVal('oed_revetement_ornieres', true, 'true') || isVal('oed_revetement_fissures', true, 'true') || isVal('oed_revetement_trous', true, 'true')) {
hasDamaged = true;
}
} }
// 2. Bouche d'injection // 2. Bouche d'injection
@ -732,6 +822,9 @@
if (isVal('oed_rhm_presente', true, 'true')) { if (isVal('oed_rhm_presente', true, 'true')) {
if (isVal('oed_rhm_stagne', true, 'true', 'oui')) { if (isVal('oed_rhm_stagne', true, 'true', 'oui')) {
hasNonCompliant = true; hasNonCompliant = true;
if (isVal('oed_rhm_stagne_plus_2h', true, 'true', 'oui')) {
hasDamaged = true;
}
} }
if (isVal('oed_rhm_joints_ok', 'false', 'non')) { if (isVal('oed_rhm_joints_ok', 'false', 'non')) {
hasNonCompliant = true; hasNonCompliant = true;
@ -748,12 +841,31 @@
if (isVal('oed_rp_presente', true, 'true')) { if (isVal('oed_rp_presente', true, 'true')) {
if (isVal('oed_rp_stagne', true, 'true', 'oui')) { if (isVal('oed_rp_stagne', true, 'true', 'oui')) {
hasNonCompliant = true; hasNonCompliant = true;
if (isVal('oed_rp_stagne_plus_2h', true, 'true', 'oui')) {
hasDamaged = true;
}
} }
if (isVal('oed_rp_depots', true, 'true', 'oui')) { if (isVal('oed_rp_depots', true, 'true', 'oui')) {
hasNonCompliant = true; hasNonCompliant = true;
} }
} }
// 5. Matériaux non agrégés
if (isVal('oed_rna_presente', true, 'true')) {
if (isVal('oed_rna_stagne', true, 'true', 'oui')) {
hasNonCompliant = true;
if (isVal('oed_rna_stagne_plus_2h', true, 'true', 'oui')) {
hasDamaged = true;
}
}
if (isVal('oed_rna_matiere_ok', 'false', 'non')) {
hasNonCompliant = true;
}
if (isVal('oed_rna_geotextile_visible', true, 'true', 'oui')) {
hasDamaged = true;
}
}
} else if (deviceType === 'ta') { } else if (deviceType === 'ta') {
// 1. Point d'entrée // 1. Point d'entrée
if (isVal('ta_pe_ok', 'false', 'non')) { if (isVal('ta_pe_ok', 'false', 'non')) {
@ -786,6 +898,28 @@
hasNonCompliant = true; hasNonCompliant = true;
} }
} }
if (hasDamaged) return 'damaged';
if (hasNonCompliant) return 'non_compliant';
return 'compliant';
}
function evaluateGiepInspectionStatus(ouvrages) {
if (!Array.isArray(ouvrages) || ouvrages.length === 0) {
return null;
}
if (!hasAnyGiepAnswer(ouvrages)) {
return null;
}
let hasDamaged = false;
let hasNonCompliant = false;
for (const ouv of ouvrages) {
if (!ouv) continue;
const st = evaluateSingleOuvrageStatus(ouv);
if (st === 'damaged') hasDamaged = true;
else if (st === 'non_compliant') hasNonCompliant = true;
} }
if (hasDamaged) return 'damaged'; if (hasDamaged) return 'damaged';
@ -1405,19 +1539,74 @@
`; `;
} }
const titleHeader = ouv.asset_code ? `${ouv.asset_code} — ${ouv.asset_name || 'Ouvrage #' + (idx + 1)}` : `Ouvrage #${idx + 1}`; const titleHeader = ouv.asset_code ? `${ouv.asset_code} — ${ouv.asset_name || 'Ouvrage #' + (idx + 1)}` : (ouv.name || `Ouvrage #${idx + 1}`);
const currentStatus = ouv.is_result_manual ? (ouv.result_status || 'compliant') : evaluateSingleOuvrageStatus(ouv);
ouv.result_status = currentStatus;
// Photos previews HTML
const existingDocs = ouv.existingDocs || [];
const newFiles = ouv.newFiles || [];
const totalOuvPhotos = existingDocs.length + newFiles.length;
let photosPreviewsHtml = '';
existingDocs.forEach(doc => {
let docPreview = '';
if (doc.is_image) {
docPreview = `<img src="${doc.url}" class="rounded w-100 h-100" style="object-fit: cover;" alt="${doc.filename || ''}">`;
} else {
docPreview = `<div class="d-flex flex-column align-items-center justify-content-center w-100 h-100 bg-light text-muted p-1"><i class="bi bi-file-earmark-text fs-4"></i><span class="extra-small text-truncate" style="max-width: 65px;">${doc.filename || ''}</span></div>`;
}
photosPreviewsHtml += `
<div class="position-relative border rounded p-0.5 bg-white shadow-sm" style="width: 75px; height: 65px;">
<a href="${doc.url}" target="_blank" class="d-block w-100 h-100">${docPreview}</a>
<button type="button" class="btn btn-danger btn-sm p-0 position-absolute top-0 end-0 rounded-circle delete-existing-doc-btn"
style="width: 20px; height: 20px; line-height: 1; transform: translate(30%, -30%);"
data-idx="${idx}" data-doc-id="${doc.id}" title="{% translate 'Supprimer ce document' %}">
<i class="bi bi-x"></i>
</button>
</div>
`;
});
newFiles.forEach((file, fileIdx) => {
const isImg = file.type && file.type.startsWith('image/');
const fileUrl = isImg ? URL.createObjectURL(file) : '';
let filePreview = '';
if (isImg) {
filePreview = `<img src="${fileUrl}" class="rounded w-100 h-100" style="object-fit: cover;" alt="${file.name}">`;
} else {
filePreview = `<div class="d-flex flex-column align-items-center justify-content-center w-100 h-100 bg-light text-muted p-1"><i class="bi bi-file-earmark fs-4"></i><span class="extra-small text-truncate" style="max-width: 65px;">${file.name}</span></div>`;
}
photosPreviewsHtml += `
<div class="position-relative border rounded p-0.5 bg-white shadow-sm" style="width: 75px; height: 65px;">
<div class="w-100 h-100">${filePreview}</div>
<button type="button" class="btn btn-danger btn-sm p-0 position-absolute top-0 end-0 rounded-circle remove-new-file-btn"
style="width: 20px; height: 20px; line-height: 1; transform: translate(30%, -30%);"
data-idx="${idx}" data-file-idx="${fileIdx}" title="{% translate 'Supprimer cette photo' %}">
<i class="bi bi-x"></i>
</button>
</div>
`;
});
if (totalOuvPhotos === 0) {
photosPreviewsHtml = '<div class="extra-small text-muted fst-italic py-1">{% translate "Aucune photo pour cet ouvrage" %}</div>';
}
card.innerHTML = ` card.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-3 border-bottom pb-2"> <div class="d-flex align-items-center justify-content-between mb-3 border-bottom pb-2">
<span class="fw-bold text-dark"><i class="bi bi-gear-fill me-1.5 text-primary"></i>${titleHeader}</span> <div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary-subtle text-secondary px-2 py-1 fs-6">#${idx + 1}</span>
<span class="fw-bold text-dark fs-6">${titleHeader}</span>
</div>
<button type="button" class="btn btn-outline-danger btn-sm py-0.5 px-2.5 rounded-pill remove-ouv-btn" data-idx="${idx}"> <button type="button" class="btn btn-outline-danger btn-sm py-0.5 px-2.5 rounded-pill remove-ouv-btn" data-idx="${idx}">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer" %} <i class="bi bi-trash me-1"></i>{% translate "Supprimer" %}
</button> </button>
</div> </div>
<!-- Liaison Équipement GIEP / Ouvrage --> <!-- Liaison Équipement GIEP / Ouvrage -->
<div class="border rounded-3 p-3 mb-3 bg-white border-primary-subtle shadow-sm"> <div class="border rounded-3 p-2.5 mb-3 bg-white border-primary-subtle shadow-sm">
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-1.5">
<span class="extra-small fw-bold text-primary text-uppercase"> <span class="extra-small fw-bold text-primary text-uppercase">
<i class="bi bi-link-45deg me-1"></i>{% translate "Équipement GIEP associé (Optionnel)" %} <i class="bi bi-link-45deg me-1"></i>{% translate "Équipement GIEP associé (Optionnel)" %}
</span> </span>
@ -1471,11 +1660,80 @@
</div> </div>
</div> </div>
<!-- Dynamic Questions Block --> <!-- 1. Conformité de l'ouvrage -->
<div class="border rounded-3 p-3 mb-3 bg-white shadow-sm border-info-subtle">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label small fw-bold text-dark mb-0">
<i class="bi bi-shield-check me-1 text-primary"></i>{% translate "1. Conformité de l'ouvrage" %}
</label>
${ouv.is_result_manual ? `
<button type="button" class="btn btn-link text-decoration-none text-muted p-0 extra-small reset-ouv-status-btn" data-idx="${idx}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir auto" %}
</button>
` : `
<span class="badge bg-secondary-subtle text-secondary border extra-small">
<i class="bi bi-magic me-1"></i>{% translate "Calcul auto" %}
</span>
`}
</div>
<div class="row g-2">
<div class="col-4">
<button type="button" class="btn w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 ouv-status-btn ${currentStatus === 'compliant' ? 'btn-success text-white shadow-sm' : 'btn-outline-success'}" data-idx="${idx}" data-status="compliant">
<i class="bi bi-check-circle-fill fs-5"></i>
<span class="extra-small fw-bold">{% translate "Conforme" %}</span>
</button>
</div>
<div class="col-4">
<button type="button" class="btn w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 ouv-status-btn ${currentStatus === 'non_compliant' ? 'btn-warning text-dark shadow-sm' : 'btn-outline-warning'}" data-idx="${idx}" data-status="non_compliant">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
<span class="extra-small fw-bold">{% translate "Non conforme" %}</span>
</button>
</div>
<div class="col-4">
<button type="button" class="btn w-100 py-2 d-flex flex-column align-items-center justify-content-center gap-1 ouv-status-btn ${currentStatus === 'damaged' ? 'btn-danger text-white shadow-sm' : 'btn-outline-danger'}" data-idx="${idx}" data-status="damaged">
<i class="bi bi-x-octagon-fill fs-5"></i>
<span class="extra-small fw-bold">{% translate "Endommagé" %}</span>
</button>
</div>
</div>
</div>
<!-- 2. Photos de l'ouvrage -->
<div class="border rounded-3 p-3 mb-3 bg-white shadow-sm border-success-subtle" style="background-color: #fafffc;">
<div class="d-flex align-items-center justify-content-between mb-2">
<label class="form-label small fw-bold text-dark mb-0">
<i class="bi bi-camera me-1 text-success"></i>{% translate "2. Photos de l'ouvrage" %}
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-pill ms-1">${totalOuvPhotos}</span>
</label>
</div>
<div class="d-flex gap-2 mb-2">
<button type="button" class="btn btn-outline-success btn-sm flex-fill py-1.5 rounded-pill ouv-camera-btn" data-idx="${idx}">
<i class="bi bi-camera-fill me-1"></i>{% translate "Prendre une photo" %}
</button>
<button type="button" class="btn btn-outline-secondary btn-sm flex-fill py-1.5 rounded-pill ouv-gallery-btn" data-idx="${idx}">
<i class="bi bi-folder2-open me-1"></i>{% translate "Fichiers / Galerie" %}
</button>
</div>
<input type="file" accept="image/*" capture="environment" class="d-none ouv-camera-input" data-idx="${idx}">
<input type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" multiple class="d-none ouv-gallery-input" data-idx="${idx}">
<!-- Previews list -->
<div class="d-flex flex-wrap gap-2 ouv-photos-preview-list pt-1" data-idx="${idx}">
${photosPreviewsHtml}
</div>
</div>
<!-- 3. Formulaire d'inspection -->
<div class="mb-3">
<label class="form-label small fw-bold text-dark mb-2 d-block">
<i class="bi bi-ui-checks me-1 text-primary"></i>{% translate "3. Formulaire d'inspection" %}
</label>
${dynamicQuestionsHtml} ${dynamicQuestionsHtml}
<!-- Recommandations --> <!-- Recommandations -->
<div class="border-top pt-2"> <div class="border rounded-3 p-3 bg-white shadow-sm mt-3">
<label class="form-label extra-small text-muted fw-bold mb-2 d-block"><i class="bi bi-bookmark-star me-1"></i>{% translate "Recommandations" %}</label> <label class="form-label extra-small text-muted fw-bold mb-2 d-block"><i class="bi bi-bookmark-star me-1"></i>{% translate "Recommandations" %}</label>
<div class="d-flex flex-column gap-1.5"> <div class="d-flex flex-column gap-1.5">
<div class="form-check"> <div class="form-check">
@ -1500,6 +1758,15 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 4. Remarques sur cet ouvrage -->
<div class="border rounded-3 p-3 bg-white shadow-sm border-secondary-subtle">
<label class="form-label small fw-bold text-dark mb-1 d-block">
<i class="bi bi-chat-left-text me-1 text-secondary"></i>{% translate "4. Remarques sur cet ouvrage" %}
</label>
<textarea class="form-control form-control-sm rounded-3 ouv-notes-input" data-idx="${idx}" rows="2" placeholder="{% filter escapejs %}{% translate "Observations ou remarques spécifiques à cet ouvrage..." %}{% endfilter %}">${ouv.notes || ''}</textarea>
</div>
`; `;
container.appendChild(card); container.appendChild(card);
}); });
@ -1574,6 +1841,100 @@
}); });
}); });
// Event listeners pour la conformité de l'ouvrage
container.querySelectorAll('.ouv-status-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.getAttribute('data-idx'));
const st = e.currentTarget.getAttribute('data-status');
if (!giepOuvrages[idx]) return;
giepOuvrages[idx].result_status = st;
giepOuvrages[idx].is_result_manual = true;
updateSerializedGiepData();
syncGiepCalculatedStatus();
renderOuvrages();
});
});
container.querySelectorAll('.reset-ouv-status-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.getAttribute('data-idx'));
if (!giepOuvrages[idx]) return;
giepOuvrages[idx].is_result_manual = false;
giepOuvrages[idx].result_status = evaluateSingleOuvrageStatus(giepOuvrages[idx]);
updateSerializedGiepData();
syncGiepCalculatedStatus();
renderOuvrages();
});
});
// Event listeners pour les photos de l'ouvrage
container.querySelectorAll('.ouv-camera-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.getAttribute('data-idx'));
const inp = container.querySelector(`.ouv-camera-input[data-idx="${idx}"]`);
if (inp) inp.click();
});
});
container.querySelectorAll('.ouv-gallery-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.getAttribute('data-idx'));
const inp = container.querySelector(`.ouv-gallery-input[data-idx="${idx}"]`);
if (inp) inp.click();
});
});
container.querySelectorAll('.ouv-camera-input, .ouv-gallery-input').forEach(inp => {
inp.addEventListener('change', (e) => {
const idx = parseInt(e.target.getAttribute('data-idx'));
if (!giepOuvrages[idx]) return;
if (!giepOuvrages[idx].newFiles) giepOuvrages[idx].newFiles = [];
Array.from(e.target.files).forEach(f => {
giepOuvrages[idx].newFiles.push(f);
});
updateSerializedGiepData();
renderOuvrages();
});
});
container.querySelectorAll('.remove-new-file-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.getAttribute('data-idx'));
const fileIdx = parseInt(e.currentTarget.getAttribute('data-file-idx'));
if (giepOuvrages[idx] && giepOuvrages[idx].newFiles) {
giepOuvrages[idx].newFiles.splice(fileIdx, 1);
updateSerializedGiepData();
renderOuvrages();
}
});
});
container.querySelectorAll('.delete-existing-doc-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.currentTarget.getAttribute('data-idx'));
const docId = parseInt(e.currentTarget.getAttribute('data-doc-id'));
if (confirm("{% filter escapejs %}{% translate "Supprimer définitivement ce document ?" %}{% endfilter %}")) {
if (typeof window.deleteInspectionDoc === 'function') {
window.deleteInspectionDoc(docId);
}
if (giepOuvrages[idx] && giepOuvrages[idx].existingDocs) {
giepOuvrages[idx].existingDocs = giepOuvrages[idx].existingDocs.filter(d => d.id !== docId);
renderOuvrages();
}
}
});
});
// Event listeners pour les remarques sur cet ouvrage
container.querySelectorAll('.ouv-notes-input').forEach(textarea => {
textarea.addEventListener('input', (e) => {
const idx = parseInt(e.target.getAttribute('data-idx'));
if (!giepOuvrages[idx]) return;
giepOuvrages[idx].notes = e.target.value;
updateSerializedGiepData();
});
});
// Event listeners pour les boutons segmentés (Tactiles Oui/Non) // Event listeners pour les boutons segmentés (Tactiles Oui/Non)
container.querySelectorAll('.btn-seg').forEach(btn => { container.querySelectorAll('.btn-seg').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@ -1614,6 +1975,7 @@
}); });
}); });
toggleParentGlobalSections(true);
updateSerializedGiepData(); updateSerializedGiepData();
syncGiepCalculatedStatus(); syncGiepCalculatedStatus();
} }
@ -1674,6 +2036,18 @@
giepOuvrages = []; giepOuvrages = [];
} }
// Compléter chaque ouvrage avec local_id, result_status, notes, existingDocs
giepOuvrages.forEach((ouv, idx) => {
if (!ouv.local_id) ouv.local_id = String(idx);
if (!ouv.notes) ouv.notes = '';
ouv.newFiles = [];
ouv.existingDocs = (data.documents || []).filter(d => (d.field_key === 'ouvrage_' + ouv.local_id || d.field_key === 'ouvrage_' + idx));
if (ouv.result_status === undefined) {
ouv.result_status = evaluateSingleOuvrageStatus(ouv);
}
ouv.is_result_manual = Boolean(ouv.is_result_manual);
});
// Si le statut existant diffère du statut calculé automatiquement, marquer comme surchargé // Si le statut existant diffère du statut calculé automatiquement, marquer comme surchargé
const autoStatus = evaluateGiepInspectionStatus(giepOuvrages); const autoStatus = evaluateGiepInspectionStatus(giepOuvrages);
if (data.result_status && autoStatus && data.result_status !== autoStatus) { if (data.result_status && autoStatus && data.result_status !== autoStatus) {
@ -1684,15 +2058,20 @@
isGiepStatusManuallyOverridden = false; isGiepStatusManuallyOverridden = false;
} }
toggleParentGlobalSections(true);
renderOuvrages(); renderOuvrages();
updateSerializedGiepData(); updateSerializedGiepData();
}; };
if (rootModal) { if (rootModal) {
rootModal.setGiepFormData = window.setGiepFormData; rootModal.setGiepFormData = window.setGiepFormData;
rootModal.addEventListener('hidden.bs.modal', () => {
toggleParentGlobalSections(false);
});
} }
initStatusRadioListeners(); initStatusRadioListeners();
toggleParentGlobalSections(true);
renderOuvrages(); renderOuvrages();
})(); })();
</script> </script>

View file

@ -35,7 +35,7 @@
<div id="editInspectionSuccessAlert" class="alert alert-success d-none mb-3"></div> <div id="editInspectionSuccessAlert" class="alert alert-success d-none mb-3"></div>
<!-- Résultat de contrôle --> <!-- Résultat de contrôle -->
<div class="mb-3"> <div class="mb-3" id="editInspResultSection">
<label class="form-label fw-semibold d-block">{% translate "Résultat de l'inspection" %} <span class="text-danger">*</span></label> <label class="form-label fw-semibold d-block">{% translate "Résultat de l'inspection" %} <span class="text-danger">*</span></label>
<div class="row g-2"> <div class="row g-2">
{% for choice in INSPECTION_RESULT_CHOICES_CONFIG %} {% for choice in INSPECTION_RESULT_CHOICES_CONFIG %}
@ -280,7 +280,7 @@
</div> </div>
<!-- Remarques --> <!-- Remarques -->
<div class="mb-3"> <div class="mb-3" id="editInspNotesSection">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label> <label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="edit_insp_notes" class="form-control" rows="3" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea> <textarea name="notes" id="edit_insp_notes" class="form-control" rows="3" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div> </div>
@ -306,7 +306,7 @@
</div> </div>
<!-- Section Ajout de Photos/Documents (Mode continu & Fichiers) --> <!-- Section Ajout de Photos/Documents (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;"> <div class="card border border-success-subtle mb-3" id="editInspPhotosSection" style="background-color: #e8f6f3;">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3"> <div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers complémentaires" %}</span> <span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers complémentaires" %}</span>
</div> </div>

View file

@ -37,7 +37,7 @@
<div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div> <div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div>
<!-- Résultat de contrôle avec boutons colorés --> <!-- Résultat de contrôle avec boutons colorés -->
<div class="mb-3"> <div class="mb-3" id="quickInspResultSection">
<label class="form-label fw-semibold d-block">{% translate "Résultat de l'inspection" %} <span class="text-danger">*</span></label> <label class="form-label fw-semibold d-block">{% translate "Résultat de l'inspection" %} <span class="text-danger">*</span></label>
<div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}"> <div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}">
<div class="row g-2"> <div class="row g-2">
@ -57,7 +57,7 @@
</div> </div>
<!-- Section Photos (Mode continu & Fichiers) --> <!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;"> <div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;" id="quickInspPhotosSection">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3"> <div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span> <span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div> </div>
@ -100,7 +100,7 @@
{% endif %} {% endif %}
<!-- Remarques --> <!-- Remarques -->
<div class="mb-3"> <div class="mb-3" id="quickInspNotesSection">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label> <label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea> <textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div> </div>

View file

@ -263,7 +263,7 @@
{% endif %} {% endif %}
{% if insp.notes %} {% if insp.notes and not insp.is_giep %}
<div class="card bg-light border-0 p-3 mb-3"> <div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6> <h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small">{{ insp.notes }}</div> <div class="text-dark small">{{ insp.notes }}</div>
@ -277,7 +277,7 @@
</div> </div>
{% endif %} {% endif %}
{% if insp.documents.exists %} {% if insp.documents.exists and not insp.is_giep %}
<div class="card bg-light border-0 p-3 mb-3"> <div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"> <h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}) <i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})

View file

@ -349,7 +349,7 @@
</div> </div>
{% endif %} {% endif %}
{% if insp.notes %} {% if insp.notes and not insp.is_giep %}
<div class="card bg-light border-0 p-3 mb-3"> <div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6> <h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small">{{ insp.notes }}</div> <div class="text-dark small">{{ insp.notes }}</div>
@ -363,7 +363,7 @@
</div> </div>
{% endif %} {% endif %}
{% if insp.documents.exists %} {% if insp.documents.exists and not insp.is_giep %}
<div class="card bg-light border-0 p-3 mb-3"> <div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"> <h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}) <i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})

View file

@ -130,3 +130,63 @@ def get_q_val(questions_dict, key):
if val is not None and val != '' and val != 'null': if val is not None and val != '' and val != 'null':
return val return val
return 'null' return 'null'
@register.filter
def filter_ouvrage_docs(documents, ouv):
"""
Filtre les documents/photos d'une inspection pour un ouvrage donné.
Correspondance sur field_key == 'ouvrage_{local_id}' ou 'ouvrage_{idx}' ou 'asset_{asset_id}'.
"""
if not documents:
return []
target_keys = set()
if isinstance(ouv, dict):
local_id = ouv.get('local_id')
if local_id is not None:
target_keys.add(f"ouvrage_{local_id}")
target_keys.add(str(local_id))
ouv_id = ouv.get('id')
if ouv_id is not None:
target_keys.add(f"ouvrage_{ouv_id}")
target_keys.add(str(ouv_id))
asset_id = ouv.get('asset_id')
if asset_id is not None:
target_keys.add(f"asset_{asset_id}")
elif isinstance(ouv, (int, str)):
target_keys.add(f"ouvrage_{ouv}")
target_keys.add(str(ouv))
matching = []
docs_list = documents.all() if hasattr(documents, 'all') else list(documents)
for doc in docs_list:
f_key = getattr(doc, 'field_key', None) or (doc.get('field_key') if isinstance(doc, dict) else '')
if f_key and str(f_key).strip() in target_keys:
matching.append(doc)
return matching
@register.filter
def filter_general_docs(documents, ouvrages=None):
"""
Retourne les documents/photos qui ne sont rattachés à aucun ouvrage spécifique (field_key vide ou non lié).
"""
if not documents:
return []
docs_list = documents.all() if hasattr(documents, 'all') else list(documents)
general = []
for doc in docs_list:
f_key = (getattr(doc, 'field_key', None) or (doc.get('field_key') if isinstance(doc, dict) else '') or '').strip()
if not f_key or not (f_key.startswith('ouvrage_') or f_key.startswith('asset_')):
general.append(doc)
return general
@register.filter
def ouvrage_result_badge(status_code):
return INSPECTION_RESULT_CONFIG.get(status_code, {}).get('badge_class', 'bg-secondary')
@register.filter
def ouvrage_result_label(status_code):
return INSPECTION_RESULT_CONFIG.get(status_code, {}).get('label', status_code or 'Non déterminé')
@register.filter
def ouvrage_result_icon(status_code):
return INSPECTION_RESULT_CONFIG.get(status_code, {}).get('icon', 'bi-question-circle')

View file

@ -2154,4 +2154,234 @@ class PostInterventionDurabilityInspectionTestCase(TestCase):
self.assertTrue(base_insp.is_hotbox_related) self.assertTrue(base_insp.is_hotbox_related)
class GiepInspectionRestructuringTestCase(TestCase):
def setUp(self):
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("CREATE EXTENSION IF NOT EXISTS unaccent;")
cursor.execute("""
CREATE OR REPLACE FUNCTION public.unaccent_immutable(text)
RETURNS text
LANGUAGE sql
IMMUTABLE PARALLEL SAFE
AS $function$
SELECT public.unaccent('public.unaccent', $1)
$function$;
CREATE OR REPLACE FUNCTION public.unaccent_immutable(character varying)
RETURNS text
LANGUAGE sql
IMMUTABLE PARALLEL SAFE
AS $function$
SELECT public.unaccent('public.unaccent', $1)
$function$;
""")
self.client = Client()
self.user = User.objects.create_user(
username='giep_inspector',
password='password123',
first_name='Giep',
last_name='Tester'
)
from common.models import UserConfig, Role, UserThematics
admin_role, _ = Role.objects.get_or_create(name='admin')
config = UserConfig.objects.create(user=self.user)
config.roles.add(admin_role)
self.water_thematic, _ = Thematic.objects.get_or_create(
code='water',
defaults={'name_fr': "Gestion de l'Eau / GIEP"}
)
UserThematics.objects.create(
user_config=config,
thematic=self.water_thematic,
can_view_assets=True,
can_edit_assets=True
)
self.rwiz = NatureRWIZ.objects.create(name_fr="Zone GIEP Parc")
self.rwiz_ct = ContentType.objects.get_for_model(NatureRWIZ)
self.client.login(username='giep_inspector', password='password123')
def test_compute_status_for_single_ouvrage(self):
# 1. Manual status takes precedence if valid
ouv_manual_damaged = {'result_status': 'damaged', 'is_result_manual': True}
self.assertEqual(GiepInspection.compute_status_for_single_ouvrage(ouv_manual_damaged), 'damaged')
ouv_manual_nc = {'result_status': 'non_compliant'}
self.assertEqual(GiepInspection.compute_status_for_single_ouvrage(ouv_manual_nc), 'non_compliant')
ouv_manual_compliant = {'result_status': 'compliant'}
self.assertEqual(GiepInspection.compute_status_for_single_ouvrage(ouv_manual_compliant), 'compliant')
# 2. Auto status computation for EVC
# Bad vegetation -> non_compliant
ouv_evc_nc = {
'device_type': 'evc',
'questions': {'evc_ev_veg_etat': 'mauvais'}
}
self.assertEqual(GiepInspection.compute_status_for_single_ouvrage(ouv_evc_nc), 'non_compliant')
# Crust sediment > 5cm -> damaged
ouv_evc_damaged = {
'device_type': 'evc',
'questions': {'evc_ev_sediments': True, 'evc_ev_croute_sediments': True}
}
self.assertEqual(GiepInspection.compute_status_for_single_ouvrage(ouv_evc_damaged), 'damaged')
# Good state -> compliant
ouv_evc_good = {
'device_type': 'evc',
'questions': {'evc_ev_veg_etat': 'bon', 'evc_ev_sediments': False}
}
self.assertEqual(GiepInspection.compute_status_for_single_ouvrage(ouv_evc_good), 'compliant')
def test_compute_status_from_data_consolidation(self):
# If any ouvrage is damaged -> overall damaged
data_with_damaged = {
'ouvrages': [
{'result_status': 'compliant'},
{'result_status': 'damaged'},
]
}
self.assertEqual(GiepInspection.compute_status_from_data(data_with_damaged), 'damaged')
# If any is non_compliant and none damaged -> non_compliant
data_with_nc = {
'ouvrages': [
{'result_status': 'compliant'},
{'result_status': 'non_compliant'},
]
}
self.assertEqual(GiepInspection.compute_status_from_data(data_with_nc), 'non_compliant')
# If all compliant -> compliant
data_all_compliant = {
'ouvrages': [
{'result_status': 'compliant'},
{'result_status': 'compliant'},
]
}
self.assertEqual(GiepInspection.compute_status_from_data(data_all_compliant), 'compliant')
def test_templatetags_filters(self):
from inspections.models import INSPECTION_RESULT_CONFIG
from inspections.templatetags.inspections_tags import (
filter_ouvrage_docs,
filter_general_docs,
ouvrage_result_badge,
ouvrage_result_label,
ouvrage_result_icon,
)
insp = Inspection.objects.create(
asset_content_type=self.rwiz_ct,
asset_object_id=self.rwiz.id,
inspector=self.user,
result_status='compliant'
)
doc1 = InspectionDocument.objects.create(
inspection=insp,
field_key='ouvrage_1',
file='test1.jpg'
)
doc2 = InspectionDocument.objects.create(
inspection=insp,
field_key='ouvrage_ouv_abc',
file='test2.jpg'
)
doc_gen = InspectionDocument.objects.create(
inspection=insp,
field_key='',
file='general.jpg'
)
all_docs = [doc1, doc2, doc_gen]
# Test filter_ouvrage_docs with dict ouvrage
ouv1 = {'id': 1, 'local_id': 'local_1'}
res1 = filter_ouvrage_docs(all_docs, ouv1)
self.assertEqual(len(res1), 1)
self.assertEqual(res1[0].id, doc1.id)
ouv2 = {'id': 99, 'local_id': 'ouv_abc'}
res2 = filter_ouvrage_docs(all_docs, ouv2)
self.assertEqual(len(res2), 1)
self.assertEqual(res2[0].id, doc2.id)
# Test filter_general_docs
gen_res = filter_general_docs(all_docs)
self.assertEqual(len(gen_res), 1)
self.assertEqual(gen_res[0].id, doc_gen.id)
# Badges and labels
self.assertIn('bg-success', ouvrage_result_badge('compliant'))
self.assertIn('bg-danger', ouvrage_result_badge('damaged'))
self.assertIn('bg-warning', ouvrage_result_badge('non_compliant'))
self.assertEqual(str(ouvrage_result_label('compliant')), str(INSPECTION_RESULT_CONFIG['compliant']['label']))
self.assertEqual(str(ouvrage_result_label('damaged')), str(INSPECTION_RESULT_CONFIG['damaged']['label']))
self.assertEqual(str(ouvrage_result_label('non_compliant')), str(INSPECTION_RESULT_CONFIG['non_compliant']['label']))
def test_create_inspection_api_with_giep_data_and_photos(self):
import json
from django.core.files.uploadedfile import SimpleUploadedFile
giep_dict = {
'meteo': {'pluie_recente': True, 'sol_sec': False},
'ouvrages': [
{
'local_id': 'ouv_1',
'name': 'Ouvrage 1',
'device_type': 'evc',
'result_status': 'damaged',
'is_result_manual': True,
'notes': 'Bordure affaissée',
'questions': {}
},
{
'local_id': 'ouv_2',
'name': 'Ouvrage 2',
'device_type': 'oed',
'result_status': 'compliant',
'is_result_manual': True,
'notes': 'Tout est propre',
'questions': {}
}
]
}
photo_ouv1 = SimpleUploadedFile("ouv1.jpg", b"fake_jpeg_ouv1", content_type="image/jpeg")
photo_gen = SimpleUploadedFile("zone.jpg", b"fake_jpeg_zone", content_type="image/jpeg")
url = reverse('inspections:create_inspection_api')
post_data = {
'asset_content_type_id': self.rwiz_ct.id,
'asset_object_id': self.rwiz.id,
'nature': 'periodic',
'status': 'completed',
'giep_data': json.dumps(giep_dict),
'field_keys': ['ouvrage_ouv_1', 'general'],
'photos': [photo_ouv1, photo_gen]
}
response = self.client.post(url, post_data)
self.assertEqual(response.status_code, 200)
resp_json = response.json()
self.assertTrue(resp_json['success'])
insp = Inspection.objects.get(id=resp_json['inspection_id'])
self.assertTrue(insp.is_giep)
# Consolidated status should be damaged because ouv_1 is damaged
self.assertEqual(insp.result_status, 'damaged')
# Notes should be auto-consolidated from ouv_1 and ouv_2
self.assertIn('Ouvrage 1: Bordure affaissée', insp.notes)
self.assertIn('Ouvrage 2: Tout est propre', insp.notes)
# Check attached documents
self.assertEqual(insp.documents.count(), 2)
doc_keys = list(insp.documents.values_list('field_key', flat=True))
self.assertIn('ouvrage_ouv_1', doc_keys)
self.assertIn('general', doc_keys)

View file

@ -552,11 +552,9 @@ def create_inspection_api(request):
""" """
API endpoint pour l'enregistrement d'une nouvelle inspection (avec prise en compte optionnelle de mission_intervention et checked_intervention). API endpoint pour l'enregistrement d'une nouvelle inspection (avec prise en compte optionnelle de mission_intervention et checked_intervention).
""" """
content_type_id = request.POST.get('content_type_id') content_type_id = request.POST.get('content_type_id') or request.POST.get('asset_content_type_id')
object_id = request.POST.get('object_id') object_id = request.POST.get('object_id') or request.POST.get('asset_object_id')
result_status = request.POST.get('result_status') result_status = request.POST.get('result_status')
if not result_status:
return JsonResponse({'success': False, 'error': _("Veuillez sélectionner le résultat de l'inspection.")}, status=400)
nature = request.POST.get('nature', 'visual') nature = request.POST.get('nature', 'visual')
inspector_role = request.POST.get('inspector_role', 'observer') inspector_role = request.POST.get('inspector_role', 'observer')
notes = request.POST.get('notes', '').strip() notes = request.POST.get('notes', '').strip()
@ -578,6 +576,9 @@ def create_inspection_api(request):
is_parking_spot = (content_type.model == 'parkingspot') is_parking_spot = (content_type.model == 'parkingspot')
is_giep = (content_type.model == 'naturerwiz') is_giep = (content_type.model == 'naturerwiz')
if not is_giep and not result_status:
return JsonResponse({'success': False, 'error': _("Veuillez sélectionner le résultat de l'inspection.")}, status=400)
is_signpanel = (content_type.model == 'signpanel') is_signpanel = (content_type.model == 'signpanel')
is_hotbox = ( is_hotbox = (
request.POST.get('is_hotbox') == 'true' or request.POST.get('is_hotbox') == 'true' or
@ -665,6 +666,21 @@ def create_inspection_api(request):
except Exception: except Exception:
parsed_giep_data = {} parsed_giep_data = {}
if not result_status or result_status == 'pending':
result_status = GiepInspection.compute_status_from_data(parsed_giep_data)
if not result_status:
result_status = 'compliant'
if not notes and parsed_giep_data and isinstance(parsed_giep_data.get('ouvrages'), list):
collected_notes = []
for idx_o, ouv in enumerate(parsed_giep_data['ouvrages']):
o_notes = (ouv.get('notes') or '').strip()
if o_notes:
o_name = ouv.get('asset_code') or ouv.get('name') or f"Ouvrage #{idx_o + 1}"
collected_notes.append(f"{o_name}: {o_notes}")
if collected_notes:
notes = "\n".join(collected_notes)
inspection = GiepInspection( inspection = GiepInspection(
asset_content_type=content_type, asset_content_type=content_type,
asset_object_id=target_obj.pk, asset_object_id=target_obj.pk,
@ -876,6 +892,7 @@ def get_inspection_detail_api(request, inspection_id):
'filename': doc.filename, 'filename': doc.filename,
'url': doc.file_url, 'url': doc.file_url,
'is_image': doc.is_image, 'is_image': doc.is_image,
'field_key': doc.field_key or '',
}) })
from django.template.loader import render_to_string from django.template.loader import render_to_string
@ -1099,6 +1116,8 @@ def update_inspection_api(request, inspection_id):
inspection.data = json.loads(request.POST['giep_data']) inspection.data = json.loads(request.POST['giep_data'])
except Exception: except Exception:
pass pass
if 'result_status' not in request.POST or not request.POST.get('result_status'):
inspection.result_status = GiepInspection.compute_status_from_data(inspection.data)
elif isinstance(inspection, SignPanelInspection): elif isinstance(inspection, SignPanelInspection):
from assets.models import SignPanelType from assets.models import SignPanelType
if 'verified_signpanel_type_id' in request.POST: if 'verified_signpanel_type_id' in request.POST: