feat: implement dynamic sign panel rendering with compliance status and icon support on inspection maps
This commit is contained in:
parent
e561a77fc0
commit
814a04f1ac
16 changed files with 1517 additions and 17 deletions
|
|
@ -298,6 +298,18 @@ class AbstractAsset(models.Model):
|
|||
related_query_name='%(app_label)s_%(class)s'
|
||||
)
|
||||
|
||||
@property
|
||||
def is_compliant(self):
|
||||
return self.inspection_status == 'compliant'
|
||||
|
||||
@is_compliant.setter
|
||||
def is_compliant(self, value):
|
||||
if value is True:
|
||||
self.inspection_status = 'compliant'
|
||||
elif value is False:
|
||||
self.inspection_status = 'non_compliant'
|
||||
|
||||
|
||||
|
||||
|
||||
def get_replacement_chain(self):
|
||||
|
|
|
|||
|
|
@ -96,7 +96,103 @@
|
|||
map.addControl(searchCtrl, 'top-left');
|
||||
}
|
||||
|
||||
// Chargeur dynamique d'images SVG / PNG de signalisation routière pour MapLibre
|
||||
function initRoadSignImagesLoader(mapInstance) {
|
||||
if (!mapInstance.hasImage('road-sign-default')) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 48;
|
||||
canvas.height = 48;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#0d6efd';
|
||||
ctx.beginPath();
|
||||
ctx.arc(24, 24, 20, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
mapInstance.addImage('road-sign-default', {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
data: imgData.data
|
||||
});
|
||||
}
|
||||
|
||||
const loadingRoadSignImages = new Set();
|
||||
mapInstance.on('styleimagemissing', function(e) {
|
||||
const missingId = e.id;
|
||||
if (!missingId || !missingId.startsWith('road-sign-') || missingId === 'road-sign-default') return;
|
||||
if (mapInstance.hasImage(missingId) || loadingRoadSignImages.has(missingId)) return;
|
||||
|
||||
const signCode = missingId.replace('road-sign-', '').replace(/-x12$/, '');
|
||||
const baseUrl = "/static/assets/road_signs/2025/";
|
||||
const targetSize = 64;
|
||||
|
||||
const greyCanvas = document.createElement('canvas');
|
||||
greyCanvas.width = targetSize;
|
||||
greyCanvas.height = targetSize;
|
||||
const greyCtx = greyCanvas.getContext('2d');
|
||||
greyCtx.fillStyle = '#ced4da';
|
||||
greyCtx.fillRect(8, 8, targetSize - 16, targetSize - 16);
|
||||
const greyData = greyCtx.getImageData(0, 0, targetSize, targetSize).data;
|
||||
if (!mapInstance.hasImage(missingId)) {
|
||||
mapInstance.addImage(missingId, { width: targetSize, height: targetSize, data: greyData });
|
||||
}
|
||||
|
||||
loadingRoadSignImages.add(missingId);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
const variations = [
|
||||
signCode.toUpperCase(),
|
||||
signCode.charAt(0).toUpperCase() + signCode.slice(1),
|
||||
signCode.toLowerCase()
|
||||
];
|
||||
let attempt = 0;
|
||||
|
||||
function tryNext() {
|
||||
if (attempt >= variations.length * 2) {
|
||||
loadingRoadSignImages.delete(missingId);
|
||||
return;
|
||||
}
|
||||
const v = variations[Math.floor(attempt / 2)];
|
||||
const ext = (attempt % 2 === 0) ? 'svg' : 'png';
|
||||
img.src = `${baseUrl}${v}.${ext}`;
|
||||
attempt++;
|
||||
}
|
||||
|
||||
img.onload = function() {
|
||||
try {
|
||||
const aspect = img.height / img.width;
|
||||
const w = targetSize;
|
||||
const h = Math.round(targetSize * aspect);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const d = ctx.getImageData(0, 0, w, h);
|
||||
if (mapInstance.hasImage(missingId)) {
|
||||
mapInstance.removeImage(missingId);
|
||||
}
|
||||
mapInstance.addImage(missingId, { width: w, height: h, data: d.data });
|
||||
mapInstance.triggerRepaint();
|
||||
} catch(err) {
|
||||
console.warn('Error loading sign sprite:', missingId, err);
|
||||
} finally {
|
||||
loadingRoadSignImages.delete(missingId);
|
||||
}
|
||||
};
|
||||
img.onerror = function() {
|
||||
tryNext();
|
||||
};
|
||||
tryNext();
|
||||
});
|
||||
}
|
||||
|
||||
map.on('load', function() {
|
||||
initRoadSignImagesLoader(map);
|
||||
|
||||
// Source GeoJSON pour les assets et localisations
|
||||
map.addSource('assets-source', {
|
||||
type: 'geojson',
|
||||
|
|
@ -219,7 +315,7 @@
|
|||
}
|
||||
});
|
||||
|
||||
// 6. Couche Points d'Assets généraux (Poteaux, Avaloirs, Regards, etc.)
|
||||
// 6. Couche Points d'Assets généraux & Halo d'état de signalisation
|
||||
map.addLayer({
|
||||
id: 'assets-points',
|
||||
type: 'circle',
|
||||
|
|
@ -231,21 +327,85 @@
|
|||
['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false]
|
||||
],
|
||||
paint: {
|
||||
'circle-radius': 7,
|
||||
'circle-radius': [
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'], 13,
|
||||
7
|
||||
],
|
||||
'circle-color': [
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'],
|
||||
[
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'is_compliant'], true], false], '#dc3545',
|
||||
['==', ['coalesce', ['get', 'status'], ''], 'inactive'], '#343a40',
|
||||
'#198754'
|
||||
],
|
||||
[
|
||||
'match', ['coalesce', ['get', 'structure_type'], ''],
|
||||
['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63',
|
||||
['jardin de pluie', 'noue', 'evc'], '#009688',
|
||||
['bassin orage', 'citerne', 'ta'], '#0277bd',
|
||||
'#0288d1'
|
||||
]
|
||||
],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
'circle-opacity': [
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'], 0.25,
|
||||
1.0
|
||||
],
|
||||
'circle-stroke-width': [
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'], 2,
|
||||
2
|
||||
],
|
||||
'circle-stroke-color': [
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'],
|
||||
[
|
||||
'case',
|
||||
['==', ['coalesce', ['get', 'is_compliant'], true], false], '#dc3545',
|
||||
['==', ['coalesce', ['get', 'status'], ''], 'inactive'], '#343a40',
|
||||
'#198754'
|
||||
],
|
||||
'#ffffff'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Couche Symboles Signalisation Routière (Vrais pictogrammes SVGs)
|
||||
map.addLayer({
|
||||
id: 'assets-sign-symbols',
|
||||
type: 'symbol',
|
||||
source: 'assets-source',
|
||||
filter: [
|
||||
'all',
|
||||
['!', ['coalesce', ['get', 'is_location'], false]],
|
||||
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'],
|
||||
['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false]
|
||||
],
|
||||
layout: {
|
||||
'icon-image': [
|
||||
'coalesce',
|
||||
['get', 'panel_icon_image'],
|
||||
'road-sign-default'
|
||||
],
|
||||
'icon-size': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
13, 0.35,
|
||||
16, 0.65,
|
||||
19, 1.0,
|
||||
21, 1.35
|
||||
],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true
|
||||
}
|
||||
});
|
||||
|
||||
const interactiveAssetLayers = [
|
||||
'assets-points', 'parking-points', 'assets-lines', 'assets-polygons', 'parking-polygons', 'locations-polygons'
|
||||
'assets-sign-symbols', 'assets-points', 'parking-points', 'assets-lines', 'assets-polygons', 'parking-polygons', 'locations-polygons'
|
||||
];
|
||||
|
||||
// Clic Priorisé sur un Asset / Localisation
|
||||
|
|
@ -362,6 +522,28 @@
|
|||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
let signHeaderHtml = '';
|
||||
if (props.model_name === 'signpanel' || props.panel_type) {
|
||||
const signCode = props.panel_type || props.code || '';
|
||||
const signText = props.panel_text || '';
|
||||
const upperCode = signCode.toUpperCase();
|
||||
const svgSrc = props.svg_url || `/static/assets/road_signs/2025/${upperCode}.svg`;
|
||||
const cleanBadge = (props.is_compliant === false) ? '<span class="badge bg-danger ms-auto">Non-conforme</span>' : '<span class="badge bg-success ms-auto">Conforme</span>';
|
||||
|
||||
signHeaderHtml = `
|
||||
<div class="d-flex align-items-center gap-2 p-2 bg-light rounded border mb-2">
|
||||
<div style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: white; border-radius: 6px; border: 1px solid #dee2e6;">
|
||||
<img src="${svgSrc}" alt="${upperCode}" style="max-width: 34px; max-height: 34px; object-fit: contain;" onerror="this.onerror=null; this.src='/static/assets/road_signs/2025/' + this.alt + '.png';">
|
||||
</div>
|
||||
<div class="overflow-hidden flex-grow-1">
|
||||
<div class="fw-bold text-dark text-truncate">${escapeHtml(signCode)}</div>
|
||||
${signText ? `<div class="extra-small text-muted text-truncate" style="font-size:0.75rem;">« ${escapeHtml(signText)} »</div>` : ''}
|
||||
</div>
|
||||
${cleanBadge}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div class="p-1" style="width: 300px; max-width: 310px; font-size: 0.85rem;">
|
||||
<div class="fw-bold text-dark border-bottom pb-1 mb-2 d-flex align-items-center justify-content-between">
|
||||
|
|
@ -369,6 +551,8 @@
|
|||
<span class="badge bg-secondary">${props.inspections_count || inspectionsList.length}</span>
|
||||
</div>
|
||||
|
||||
${signHeaderHtml}
|
||||
|
||||
<button type="button" class="btn btn-sm btn-primary w-100 mb-2 py-1.5 fw-semibold d-flex align-items-center justify-content-center gap-1 shadow-sm" id="btn-popup-create-inspection">
|
||||
<i class="bi bi-plus-circle-fill"></i>
|
||||
<span>Nouvelle inspection</span>
|
||||
|
|
@ -658,6 +842,17 @@
|
|||
|
||||
const catNameDisp = document.getElementById('category-name-display');
|
||||
if (catNameDisp) catNameDisp.textContent = selectedCategoryName;
|
||||
|
||||
const btnAddQuickSign = document.getElementById('btn-add-quick-sign');
|
||||
if (btnAddQuickSign) {
|
||||
if (thematicCode === 'sign') {
|
||||
btnAddQuickSign.classList.remove('d-none');
|
||||
btnAddQuickSign.classList.add('d-flex');
|
||||
} else {
|
||||
btnAddQuickSign.classList.add('d-none');
|
||||
btnAddQuickSign.classList.remove('d-flex');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initFilterStorage() {
|
||||
|
|
@ -1422,8 +1617,264 @@
|
|||
}
|
||||
}
|
||||
|
||||
let newSignPhotos = [];
|
||||
|
||||
function initQuickAddSignHandler() {
|
||||
const btnAdd = document.getElementById('btn-add-quick-sign');
|
||||
const modalEl = document.getElementById('modalQuickAddSign');
|
||||
if (!btnAdd || !modalEl) return;
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
|
||||
btnAdd.addEventListener('click', function() {
|
||||
// Déterminer la position GPS
|
||||
let lat = null, lon = null;
|
||||
if (typeof userMarker !== 'undefined' && userMarker) {
|
||||
const lngLat = userMarker.getLngLat();
|
||||
lat = lngLat.lat;
|
||||
lon = lngLat.lng;
|
||||
} else if (typeof map !== 'undefined' && map) {
|
||||
const center = map.getCenter();
|
||||
lat = center.lat;
|
||||
lon = center.lng;
|
||||
}
|
||||
|
||||
const inputLat = document.getElementById('new_sign_lat');
|
||||
const inputLon = document.getElementById('new_sign_lon');
|
||||
const coordsDisplay = document.getElementById('new_sign_gps_coords');
|
||||
|
||||
if (inputLat && inputLon && lat !== null && lon !== null) {
|
||||
inputLat.value = lat.toFixed(6);
|
||||
inputLon.value = lon.toFixed(6);
|
||||
if (coordsDisplay) {
|
||||
coordsDisplay.textContent = `Lat: ${lat.toFixed(5)}, Lon: ${lon.toFixed(5)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Réinitialiser la liste des panneaux et l'aperçu photo
|
||||
newSignPhotos = [];
|
||||
const previewBox = document.getElementById('new_sign_photo_preview_box');
|
||||
if (previewBox) previewBox.classList.add('d-none');
|
||||
const listContainer = document.getElementById('new_sign_panels_list');
|
||||
if (listContainer) listContainer.innerHTML = '';
|
||||
|
||||
// Ajouter 1 ligne par défaut
|
||||
addNewPanelRow('C1', '', 'clean', 1);
|
||||
|
||||
modal.show();
|
||||
});
|
||||
|
||||
// Prise de photo caméra / galerie
|
||||
const btnCam = document.getElementById('btn_new_sign_camera');
|
||||
const btnGal = document.getElementById('btn_new_sign_gallery');
|
||||
const inputCam = document.getElementById('new_sign_camera_input');
|
||||
const inputGal = document.getElementById('new_sign_gallery_input');
|
||||
|
||||
if (btnCam && inputCam) {
|
||||
btnCam.addEventListener('click', () => inputCam.click());
|
||||
inputCam.addEventListener('change', handleSignPhotoUpload);
|
||||
}
|
||||
if (btnGal && inputGal) {
|
||||
btnGal.addEventListener('click', () => inputGal.click());
|
||||
inputGal.addEventListener('change', handleSignPhotoUpload);
|
||||
}
|
||||
|
||||
async function handleSignPhotoUpload(e) {
|
||||
if (!e.target.files || !e.target.files.length) return;
|
||||
const file = e.target.files[0];
|
||||
newSignPhotos = [file];
|
||||
|
||||
const previewBox = document.getElementById('new_sign_photo_preview_box');
|
||||
const previewImg = document.getElementById('new_sign_photo_preview');
|
||||
const loading = document.getElementById('new_sign_ai_loading');
|
||||
|
||||
if (previewImg && previewBox) {
|
||||
previewImg.src = URL.createObjectURL(file);
|
||||
previewBox.classList.remove('d-none');
|
||||
}
|
||||
if (loading) loading.classList.remove('d-none');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const csrfToken = getCsrfToken();
|
||||
const res = await fetch('/sign/api/detect/', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: formData
|
||||
});
|
||||
if (loading) loading.classList.add('d-none');
|
||||
|
||||
const data = await res.json();
|
||||
const listContainer = document.getElementById('new_sign_panels_list');
|
||||
if (listContainer) listContainer.innerHTML = '';
|
||||
|
||||
if ((data.status === 'success' || data.success) && data.panels && data.panels.length > 0) {
|
||||
if (data.annotated_image && previewImg) {
|
||||
previewImg.src = data.annotated_image;
|
||||
}
|
||||
data.panels.forEach((p, idx) => {
|
||||
const panelCode = p.code || 'C1';
|
||||
const panelText = p.ocr_text || p.signpanel_text || p.text || '';
|
||||
const panelOrder = p.vertical_order || (idx + 1);
|
||||
const panelLabel = p.name_fr || p.label_fr || p.name_nl || 'Panneau';
|
||||
addNewPanelRow(panelCode, panelText, 'clean', panelOrder, p.confidence, panelLabel);
|
||||
});
|
||||
} else {
|
||||
addNewPanelRow('C1', '', 'clean', 1);
|
||||
}
|
||||
} catch (err) {
|
||||
if (loading) loading.classList.add('d-none');
|
||||
console.error('AI detect error:', err);
|
||||
addNewPanelRow('C1', '', 'clean', 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter ligne manuelle
|
||||
const btnAddManual = document.getElementById('btn_add_manual_panel_row');
|
||||
if (btnAddManual) {
|
||||
btnAddManual.addEventListener('click', function() {
|
||||
const currentRows = document.querySelectorAll('.new-sign-panel-row').length;
|
||||
addNewPanelRow('C1', '', 'clean', currentRows + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function addNewPanelRow(code, text, cleanliness, order, confidence, labelFr) {
|
||||
const listContainer = document.getElementById('new_sign_panels_list');
|
||||
if (!listContainer) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'new-sign-panel-row border rounded-3 p-2 bg-white position-relative shadow-sm';
|
||||
const upperCode = (code || 'C1').toUpperCase();
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || 'Panneau'})</span>
|
||||
${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''}
|
||||
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="Supprimer ce panneau">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
<div style="width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: #f8f9fa; border-radius: 6px; border: 1px solid #dee2e6;">
|
||||
<img class="panel-row-preview-img" src="/static/assets/road_signs/2025/${upperCode}.svg" alt="${upperCode}" style="max-width: 38px; max-height: 38px; object-fit: contain;" onerror="this.onerror=null; this.src='/static/assets/road_signs/2025/' + this.alt + '.png';">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<span class="input-group-text fw-bold">Code</span>
|
||||
<input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2...">
|
||||
</div>
|
||||
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="Mention (ex: Sauf riverains)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between">
|
||||
<small class="text-muted extra-small">Propreté :</small>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}>
|
||||
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">Propre</label>
|
||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}>
|
||||
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">Sale</label>
|
||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}>
|
||||
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">Végétation</label>
|
||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}>
|
||||
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">Dégradé</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const inputCode = row.querySelector('.panel-row-code');
|
||||
const imgPreview = row.querySelector('.panel-row-preview-img');
|
||||
if (inputCode && imgPreview) {
|
||||
inputCode.addEventListener('input', function() {
|
||||
const c = (this.value || '').trim().toUpperCase();
|
||||
imgPreview.src = `/static/assets/road_signs/2025/${c}.svg`;
|
||||
imgPreview.alt = c;
|
||||
});
|
||||
}
|
||||
|
||||
const btnRemove = row.querySelector('.btn-remove-panel-row');
|
||||
if (btnRemove) {
|
||||
btnRemove.addEventListener('click', function() {
|
||||
row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
listContainer.appendChild(row);
|
||||
}
|
||||
|
||||
// Soumission de la création rapide
|
||||
const btnSubmit = document.getElementById('btn_submit_quick_add_sign');
|
||||
if (btnSubmit) {
|
||||
btnSubmit.addEventListener('click', async function() {
|
||||
const lat = document.getElementById('new_sign_lat')?.value;
|
||||
const lon = document.getElementById('new_sign_lon')?.value;
|
||||
|
||||
if (!lat || !lon) {
|
||||
alert('Coordonnées GPS manquantes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = document.querySelectorAll('.new-sign-panel-row');
|
||||
if (!rows.length) {
|
||||
alert('Veuillez ajouter au moins un panneau.');
|
||||
return;
|
||||
}
|
||||
|
||||
const panelsData = [];
|
||||
rows.forEach((r, idx) => {
|
||||
const code = r.querySelector('.panel-row-code')?.value || 'C1';
|
||||
const text = r.querySelector('.panel-row-text')?.value || '';
|
||||
const cleanChecked = r.querySelector('.panel-row-clean:checked')?.value || 'clean';
|
||||
|
||||
panelsData.push({
|
||||
signpanel_type_code: code.trim().toUpperCase(),
|
||||
signpanel_text: text.trim(),
|
||||
cleanliness: cleanChecked,
|
||||
vertical_order: idx + 1
|
||||
});
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('lat', lat);
|
||||
formData.append('lon', lon);
|
||||
formData.append('panels', JSON.stringify(panelsData));
|
||||
newSignPhotos.forEach(p => formData.append('photos', p));
|
||||
|
||||
btnSubmit.disabled = true;
|
||||
btnSubmit.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Création en cours...';
|
||||
|
||||
try {
|
||||
const csrfToken = getCsrfToken();
|
||||
const res = await fetch('/sign/api/quick-create/', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
modal.hide();
|
||||
alert(data.message || 'Panneaux créés avec succès !');
|
||||
loadAssets();
|
||||
} else {
|
||||
alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
|
||||
}
|
||||
} catch(err) {
|
||||
alert('Erreur réseau : ' + err.message);
|
||||
} finally {
|
||||
btnSubmit.disabled = false;
|
||||
btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initFormHandler() {
|
||||
initInspectionPhotoHandler();
|
||||
initQuickAddSignHandler();
|
||||
|
||||
const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm');
|
||||
if (!form) return;
|
||||
|
|
|
|||
|
|
@ -514,6 +514,13 @@ def sign_assets_geojson(request):
|
|||
panel_icon_image = f"road-sign-{icon_code.lower()}" if icon_code else None
|
||||
panel_icon_image_scaled = f"{panel_icon_image}-x12" if panel_icon_image else None
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
ct_id = None
|
||||
try:
|
||||
ct_id = ContentType.objects.get_for_model(obj).id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"id": obj.id,
|
||||
|
|
@ -529,11 +536,17 @@ def sign_assets_geojson(request):
|
|||
"orientation": obj.orientation if hasattr(obj, 'orientation') else None,
|
||||
"status": obj.status,
|
||||
"validation_status": obj.validation_status,
|
||||
"is_compliant": getattr(obj, 'is_compliant', True),
|
||||
"inspection_status": getattr(obj, 'inspection_status', None),
|
||||
"last_inspection_date": obj.last_inspection_date.strftime('%d/%m/%Y') if getattr(obj, 'last_inspection_date', None) else None,
|
||||
"lon": lon,
|
||||
"lat": lat,
|
||||
"asset_type": asset_type,
|
||||
"model_name": asset_type,
|
||||
"content_type_id": ct_id,
|
||||
"panel_icon_image": panel_icon_image,
|
||||
"panel_icon_image_scaled": panel_icon_image_scaled,
|
||||
"svg_url": f"/static/assets/road_signs/2025/{panel_type_code.upper()}.svg" if panel_type_code else None,
|
||||
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||
"element_type": get_element_type_label(asset_type or ''),
|
||||
"thematic": "sign",
|
||||
|
|
|
|||
32
loko/inspections/migrations/0008_signpanelinspection.py
Normal file
32
loko/inspections/migrations/0008_signpanelinspection.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Generated by Django 6.0.7 on 2026-08-22 13:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0024_structureexceptionaltransport_and_more'),
|
||||
('inspections', '0007_alter_inspection_result_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SignPanelInspection',
|
||||
fields=[
|
||||
('inspection_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='inspections.inspection')),
|
||||
('verified_signpanel_text', models.CharField(blank=True, max_length=1000, null=True, verbose_name='Texte / Mention constatée')),
|
||||
('verified_cleanliness', models.CharField(blank=True, choices=[('clean', 'Propre / Bon état'), ('dirty', 'Sale / Mousse / Nettoyage requis'), ('vegetation', 'Masqué par la végétation (Élagage requis)'), ('vandalized', 'Tagué / Vandalisé / Déformé'), ('missing', 'Panneau absent / Support vide')], default='clean', max_length=50, null=True, verbose_name='Niveau de propreté / Visibilité')),
|
||||
('ai_detected_code', models.CharField(blank=True, max_length=50, null=True, verbose_name='Code détecté par IA')),
|
||||
('ai_confidence', models.FloatField(blank=True, null=True, verbose_name='Confiance de détection IA')),
|
||||
('apply_sign_updates_to_asset', models.BooleanField(default=True, verbose_name="Mettre à jour le type et le texte de l'asset")),
|
||||
('verified_signpanel_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.signpaneltype', verbose_name='Type de panneau constaté')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inspection de panneau de signalisation',
|
||||
'verbose_name_plural': 'Inspections de panneaux de signalisation',
|
||||
},
|
||||
bases=('inspections.inspection',),
|
||||
),
|
||||
]
|
||||
|
|
@ -324,11 +324,13 @@ class Inspection(models.Model):
|
|||
|
||||
@property
|
||||
def detailed_inspection(self):
|
||||
"""Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection)."""
|
||||
"""Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection, GiepInspection, SignPanelInspection)."""
|
||||
if hasattr(self, 'parkingspotinspection'):
|
||||
return self.parkingspotinspection
|
||||
if hasattr(self, 'giepinspection'):
|
||||
return self.giepinspection
|
||||
if hasattr(self, 'signpanelinspection'):
|
||||
return self.signpanelinspection
|
||||
return self
|
||||
|
||||
@property
|
||||
|
|
@ -354,6 +356,13 @@ class Inspection(models.Model):
|
|||
val = marking_labels.get(leaf.verified_marking_quality, leaf.verified_marking_quality)
|
||||
parts.append(str(val))
|
||||
|
||||
if hasattr(leaf, 'verified_cleanliness') and leaf.verified_cleanliness:
|
||||
parts.append(str(leaf.get_verified_cleanliness_display()))
|
||||
if hasattr(leaf, 'verified_signpanel_type') and leaf.verified_signpanel_type:
|
||||
parts.append(str(leaf.verified_signpanel_type.code))
|
||||
if hasattr(leaf, 'verified_signpanel_text') and leaf.verified_signpanel_text:
|
||||
parts.append(f"« {leaf.verified_signpanel_text[:30]} »")
|
||||
|
||||
if hasattr(leaf, 'data') and isinstance(leaf.data, dict) and leaf.data:
|
||||
ouvrages = leaf.data.get('ouvrages', [])
|
||||
if ouvrages:
|
||||
|
|
@ -537,6 +546,83 @@ class GiepInspection(Inspection):
|
|||
verbose_name_plural = _("Inspections GIEP / Eaux pluviales")
|
||||
|
||||
|
||||
class SignPanelInspection(Inspection):
|
||||
"""
|
||||
Spécialisation de l'inspection pour les panneaux de signalisation routière.
|
||||
"""
|
||||
CLEANLINESS_CHOICES = (
|
||||
('clean', _("Propre / Bon état")),
|
||||
('dirty', _("Sale / Mousse / Nettoyage requis")),
|
||||
('vegetation', _("Masqué par la végétation (Élagage requis)")),
|
||||
('vandalized', _("Tagué / Vandalisé / Déformé")),
|
||||
('missing', _("Panneau absent / Support vide")),
|
||||
)
|
||||
verified_signpanel_type = models.ForeignKey(
|
||||
'assets.SignPanelType',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Type de panneau constaté")
|
||||
)
|
||||
verified_signpanel_text = models.CharField(
|
||||
max_length=1000,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Texte / Mention constatée")
|
||||
)
|
||||
verified_cleanliness = models.CharField(
|
||||
max_length=50,
|
||||
choices=CLEANLINESS_CHOICES,
|
||||
default='clean',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Niveau de propreté / Visibilité")
|
||||
)
|
||||
ai_detected_code = models.CharField(
|
||||
max_length=50,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Code détecté par IA")
|
||||
)
|
||||
ai_confidence = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Confiance de détection IA")
|
||||
)
|
||||
apply_sign_updates_to_asset = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Mettre à jour le type et le texte de l'asset")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Inspection de panneau de signalisation")
|
||||
verbose_name_plural = _("Inspections de panneaux de signalisation")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
if self.apply_sign_updates_to_asset and self.asset:
|
||||
target_asset = self.asset
|
||||
modified = False
|
||||
if not self.is_cancelled:
|
||||
if self.verified_signpanel_type:
|
||||
target_asset.signpanel_type = self.verified_signpanel_type
|
||||
modified = True
|
||||
if self.verified_signpanel_text is not None:
|
||||
target_asset.signpanel_text = self.verified_signpanel_text
|
||||
modified = True
|
||||
if self.verified_cleanliness in ('dirty', 'vegetation', 'vandalized', 'missing') or self.result_status in ('non_compliant', 'damaged', 'missing'):
|
||||
target_asset.is_compliant = False
|
||||
modified = True
|
||||
elif self.verified_cleanliness == 'clean' and self.result_status == 'compliant':
|
||||
target_asset.is_compliant = True
|
||||
modified = True
|
||||
if self.verified_cleanliness == 'missing' or self.result_status == 'missing':
|
||||
target_asset.status = 'inactive'
|
||||
modified = True
|
||||
if modified:
|
||||
target_asset.save()
|
||||
|
||||
|
||||
def inspection_document_upload_path(instance, filename):
|
||||
now = timezone.now()
|
||||
year = now.strftime("%Y")
|
||||
|
|
|
|||
|
|
@ -47,8 +47,10 @@ def get_inspection_detail_partial_template(insp_or_model_or_name):
|
|||
|
||||
if model_name in ('naturerwiz', 'giepinspection'):
|
||||
model_name = 'naturerwiz'
|
||||
elif model_name == 'parkingspotinspection':
|
||||
elif model_name in ('parkingspot', 'parkingspotinspection'):
|
||||
model_name = 'parkingspot'
|
||||
elif model_name in ('signpanel', 'signpanelinspection'):
|
||||
model_name = 'signpanel'
|
||||
|
||||
template_path = f"inspections/components/asset_details/_{model_name}.html"
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
{% load i18n static %}
|
||||
|
||||
{% with leaf=insp.detailed_inspection %}
|
||||
{% if leaf %}
|
||||
<div class="card border-0 bg-light p-3 mb-3 rounded-3 shadow-sm">
|
||||
<h6 class="fw-bold mb-3 text-primary border-bottom pb-2 small text-uppercase d-flex align-items-center">
|
||||
<i class="bi bi-signpost-2-fill me-2"></i>{% translate "Constats spécifiques signalisation" %}
|
||||
</h6>
|
||||
|
||||
<!-- Panneau & SVG -->
|
||||
<div class="d-flex align-items-center gap-3 p-2 bg-white rounded-3 border mb-3">
|
||||
<div style="width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef;">
|
||||
<img src="/static/assets/road_signs/2025/{% if leaf.verified_signpanel_type %}{{ leaf.verified_signpanel_type.code|upper }}{% elif insp.asset.signpanel_type %}{{ insp.asset.signpanel_type.code|upper }}{% else %}C1{% endif %}.svg"
|
||||
alt="{% if leaf.verified_signpanel_type %}{{ leaf.verified_signpanel_type.code }}{% elif insp.asset.signpanel_type %}{{ insp.asset.signpanel_type.code }}{% else %}Panneau{% endif %}"
|
||||
style="max-width: 44px; max-height: 44px; object-fit: contain;"
|
||||
onerror="this.onerror=null; this.src='/static/assets/road_signs/2025/' + (this.alt || 'default') + '.png';">
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<span class="small text-muted d-block">{% translate "Type de panneau" %}</span>
|
||||
<span class="fw-bold text-dark fs-6">
|
||||
{% if leaf.verified_signpanel_type %}
|
||||
{{ leaf.verified_signpanel_type.code }} - {{ leaf.verified_signpanel_type.get_name }}
|
||||
{% elif insp.asset.signpanel_type %}
|
||||
{{ insp.asset.signpanel_type.code }} - {{ insp.asset.signpanel_type.get_name }}
|
||||
{% else %}
|
||||
{% translate "Non spécifié" %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-group list-group-flush bg-transparent">
|
||||
<!-- État de propreté & Visibilité -->
|
||||
{% if leaf.verified_cleanliness %}
|
||||
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
|
||||
<span class="text-secondary fw-medium">
|
||||
<i class="bi bi-droplet-half me-2 text-info fs-6"></i>{% translate "Propreté & Visibilité" %}
|
||||
</span>
|
||||
<span>
|
||||
{% if leaf.verified_cleanliness == 'clean' %}
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-2.5 py-1.5 rounded-pill">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>{% translate "Propre / Bon état" %}
|
||||
</span>
|
||||
{% elif leaf.verified_cleanliness == 'dirty' %}
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1.5 rounded-pill">
|
||||
<i class="bi bi-brush-fill me-1"></i>{% translate "Sale / Mousse (À nettoyer)" %}
|
||||
</span>
|
||||
{% elif leaf.verified_cleanliness == 'vegetation' %}
|
||||
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle px-2.5 py-1.5 rounded-pill" style="color: #fd7e14 !important; border-color: #fd7e14 !important;">
|
||||
<i class="bi bi-tree-fill me-1"></i>{% translate "Masqué par végétation" %}
|
||||
</span>
|
||||
{% elif leaf.verified_cleanliness == 'vandalized' %}
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle px-2.5 py-1.5 rounded-pill">
|
||||
<i class="bi bi-exclamation-octagon-fill me-1"></i>{% translate "Tagué / Dégradé" %}
|
||||
</span>
|
||||
{% elif leaf.verified_cleanliness == 'missing' %}
|
||||
<span class="badge bg-dark-subtle text-dark border border-dark-subtle px-2.5 py-1.5 rounded-pill">
|
||||
<i class="bi bi-x-circle-fill me-1"></i>{% translate "Panneau absent / Manquant" %}
|
||||
</span>
|
||||
{% else %}
|
||||
{{ leaf.get_verified_cleanliness_display|default:leaf.verified_cleanliness }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- Texte additionnel / Panonceau -->
|
||||
{% if leaf.verified_signpanel_text %}
|
||||
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
|
||||
<span class="text-secondary fw-medium">
|
||||
<i class="bi bi-chat-left-text me-2 text-secondary fs-6"></i>{% translate "Mention / Panonceau" %}
|
||||
</span>
|
||||
<span class="badge bg-light text-dark border px-3 py-1.5 fs-6 shadow-sm">
|
||||
« {{ leaf.verified_signpanel_text }} »
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- IA Validation -->
|
||||
{% if leaf.ai_detected_code %}
|
||||
<li class="list-group-item bg-transparent d-flex justify-content-between align-items-center px-0 py-2.5 border-bottom-0">
|
||||
<span class="text-secondary fw-medium">
|
||||
<i class="bi bi-stars me-2 text-warning fs-6"></i>{% translate "Détection IA" %}
|
||||
</span>
|
||||
<span class="badge bg-info-subtle text-info-emphasis border border-info-subtle px-2.5 py-1.5 rounded-pill">
|
||||
{{ leaf.ai_detected_code }} {% if leaf.ai_confidence %}({{ leaf.ai_confidence|floatformat:0 }}%){% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
{% load i18n static %}
|
||||
|
||||
<!-- Champs spécifiques SignPanel (Panneau de signalisation) -->
|
||||
<div id="signPanelSpecificFields" class="border rounded-3 p-3 mb-3 bg-white shadow-sm">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 border-bottom pb-2">
|
||||
<span class="fw-bold text-dark d-flex align-items-center gap-2">
|
||||
<i class="bi bi-signpost-2-fill text-primary"></i>
|
||||
{% translate "Contrôle de signalisation routière" %}
|
||||
</span>
|
||||
<span class="badge bg-light text-dark border">
|
||||
{% if asset.code %}{{ asset.code }}{% elif asset.signpanel_type %}{{ asset.signpanel_type.code }}{% else %}Panneau{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 1. Assistance IA : Vérification instantanée par photo -->
|
||||
<div class="card border border-primary-subtle bg-primary-subtle bg-opacity-10 mb-3 rounded-3">
|
||||
<div class="card-body p-2.5">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<span class="fw-bold text-primary small d-flex align-items-center gap-1">
|
||||
<i class="bi bi-stars text-warning"></i>{% translate "Assistant IA Signalisation" %}
|
||||
</span>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">
|
||||
{% translate "Photographiez le panneau pour valider ou corriger automatiquement le type & texte" %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="btn_sign_ai_detect" class="btn btn-sm btn-primary d-flex align-items-center gap-1 px-2.5 py-1.5 shadow-sm">
|
||||
<i class="bi bi-camera-fill"></i>
|
||||
<span>{% translate "Détecter par IA" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input caché pour la caméra IA -->
|
||||
<input type="file" id="sign_ai_file_input" accept="image/*" capture="environment" class="d-none">
|
||||
|
||||
<!-- Résultat de détection IA -->
|
||||
<div id="sign_ai_result_container" class="mt-2 d-none">
|
||||
<div id="sign_ai_spinner" class="text-center py-2 d-none">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span class="small text-muted ms-2">{% translate "Analyse visuelle & OCR en cours..." %}</span>
|
||||
</div>
|
||||
<div id="sign_ai_badge_container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="ai_detected_code" id="insp_ai_detected_code" value="">
|
||||
<input type="hidden" name="ai_confidence" id="insp_ai_confidence" value="">
|
||||
|
||||
<!-- 2. État de Propreté & Visibilité -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-dark mb-1 d-flex align-items-center justify-content-between">
|
||||
<span><i class="bi bi-droplet-half text-info me-1"></i>{% translate "État de propreté & Visibilité" %} <span class="text-danger">*</span></span>
|
||||
</label>
|
||||
<div class="row g-2" id="cleanliness_button_group">
|
||||
<div class="col-6 col-sm-4">
|
||||
<input type="radio" class="btn-check" name="verified_cleanliness" id="clean_clean" value="clean" checked>
|
||||
<label class="btn btn-outline-success btn-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center text-center rounded-3" for="clean_clean">
|
||||
<i class="bi bi-check-circle-fill fs-6 mb-1"></i>
|
||||
<span class="fw-semibold small">{% translate "Propre / Bon" %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4">
|
||||
<input type="radio" class="btn-check" name="verified_cleanliness" id="clean_dirty" value="dirty">
|
||||
<label class="btn btn-outline-warning btn-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center text-center rounded-3" for="clean_dirty">
|
||||
<i class="bi bi-brush-fill fs-6 mb-1"></i>
|
||||
<span class="fw-semibold small">{% translate "Sale / Mousse" %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4">
|
||||
<input type="radio" class="btn-check" name="verified_cleanliness" id="clean_vegetation" value="vegetation">
|
||||
<label class="btn btn-outline-warning btn-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center text-center rounded-3" for="clean_vegetation" style="border-color: #fd7e14; color: #fd7e14;">
|
||||
<i class="bi bi-tree-fill fs-6 mb-1"></i>
|
||||
<span class="fw-semibold small">{% translate "Végétation" %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-sm-6">
|
||||
<input type="radio" class="btn-check" name="verified_cleanliness" id="clean_vandalized" value="vandalized">
|
||||
<label class="btn btn-outline-danger btn-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center text-center rounded-3" for="clean_vandalized">
|
||||
<i class="bi bi-exclamation-octagon-fill fs-6 mb-1"></i>
|
||||
<span class="fw-semibold small">{% translate "Tagué / Dégradé" %}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<input type="radio" class="btn-check" name="verified_cleanliness" id="clean_missing" value="missing">
|
||||
<label class="btn btn-outline-dark btn-sm w-100 py-2 d-flex flex-column align-items-center justify-content-center text-center rounded-3" for="clean_missing">
|
||||
<i class="bi bi-x-circle-fill fs-6 mb-1"></i>
|
||||
<span class="fw-semibold small">{% translate "Absent / Disparu" %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Type de Panneau (Sélecteur avec aperçu visuel SVG) -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-dark mb-1">
|
||||
<i class="bi bi-image text-secondary me-1"></i>{% translate "Type de panneau constaté" %}
|
||||
</label>
|
||||
|
||||
<!-- Aperçu du panneau sélectionné -->
|
||||
<div class="d-flex align-items-center gap-3 p-2 bg-light rounded-3 border mb-2" id="sign_selected_preview">
|
||||
<div style="width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: white; border-radius: 8px; border: 1px solid #dee2e6;">
|
||||
<img id="sign_preview_img"
|
||||
src="{% if asset.signpanel_type and asset.signpanel_type.code %}/static/assets/road_signs/2025/{{ asset.signpanel_type.code|upper }}.svg{% else %}/static/assets/road_signs/2025/C1.svg{% endif %}"
|
||||
alt="Panneau"
|
||||
style="max-width: 42px; max-height: 42px; object-fit: contain;"
|
||||
onerror="this.onerror=null; this.src='/static/assets/road_signs/2025/' + (this.alt || 'default') + '.png';">
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="fw-bold text-dark text-truncate" id="sign_preview_code">
|
||||
{% if asset.signpanel_type %}{{ asset.signpanel_type.code }} - {{ asset.signpanel_type.name_fr|default:asset.signpanel_type.name_nl }}{% else %}{% translate "Non défini" %}{% endif %}
|
||||
</div>
|
||||
<small class="text-muted d-block text-truncate" id="sign_preview_name">
|
||||
{% if asset.signpanel_type %}{{ asset.signpanel_type.name_nl|default:'' }}{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select name="verified_signpanel_type_id" id="insp_verified_signpanel_type" class="form-select form-select-sm">
|
||||
<option value="">-- {% translate "Conserver le type actuel" %} --</option>
|
||||
{% for pt in panel_types %}
|
||||
<option value="{{ pt.id }}" data-code="{{ pt.code }}" data-name="{{ pt.name_fr|default:pt.name_nl }}" {% if asset.signpanel_type_id == pt.id %}selected{% endif %}>
|
||||
{{ pt.code }} - {{ pt.name_fr|default:pt.name_nl|truncatechars:45 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 4. Texte additionnel / Panonceau -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-dark mb-1" for="insp_verified_signpanel_text">
|
||||
<i class="bi bi-chat-left-text text-secondary me-1"></i>{% translate "Texte / Mention additionnelle constatée" %}
|
||||
</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
name="verified_signpanel_text"
|
||||
id="insp_verified_signpanel_text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="{% translate 'Ex: Sauf riverains, 300 m, Max 3.5t...' %}"
|
||||
value="{{ asset.signpanel_text|default:'' }}">
|
||||
<button class="btn btn-outline-secondary" type="button" id="btn_clear_sign_text" title="{% translate 'Effacer le texte' %}">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Options d'application -->
|
||||
<div class="form-check form-switch pt-1">
|
||||
<input class="form-check-input" type="checkbox" name="apply_sign_updates_to_asset" value="true" id="insp_apply_sign_updates" checked>
|
||||
<label class="form-check-label small text-muted" for="insp_apply_sign_updates">
|
||||
{% translate "Mettre à jour directement la fiche du panneau (type, texte, conformité)" %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// 1. Mise à jour de l'aperçu SVG lors du changement du select
|
||||
const selectType = document.getElementById('insp_verified_signpanel_type');
|
||||
const previewImg = document.getElementById('sign_preview_img');
|
||||
const previewCode = document.getElementById('sign_preview_code');
|
||||
const previewName = document.getElementById('sign_preview_name');
|
||||
|
||||
if (selectType && previewImg) {
|
||||
selectType.addEventListener('change', function() {
|
||||
const selectedOpt = this.options[this.selectedIndex];
|
||||
if (selectedOpt && selectedOpt.value) {
|
||||
const code = selectedOpt.getAttribute('data-code') || '';
|
||||
const name = selectedOpt.getAttribute('data-name') || '';
|
||||
const upperCode = code.toUpperCase();
|
||||
previewImg.src = `/static/assets/road_signs/2025/${upperCode}.svg`;
|
||||
previewImg.alt = upperCode;
|
||||
previewCode.textContent = `${code} - ${name}`;
|
||||
previewName.textContent = name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Synchronisation bouton manquant avec le statut général de l'inspection
|
||||
const missingRadio = document.getElementById('clean_missing');
|
||||
const dirtyRadio = document.getElementById('clean_dirty');
|
||||
const vegRadio = document.getElementById('clean_vegetation');
|
||||
const vandRadio = document.getElementById('clean_vandalized');
|
||||
const cleanRadio = document.getElementById('clean_clean');
|
||||
|
||||
if (missingRadio) {
|
||||
missingRadio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
const resMissing = document.getElementById('res_missing');
|
||||
if (resMissing) {
|
||||
resMissing.checked = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[dirtyRadio, vegRadio, vandRadio].forEach(function(r) {
|
||||
if (r) {
|
||||
r.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
const resDamaged = document.getElementById('res_damaged') || document.getElementById('res_non_compliant');
|
||||
if (resDamaged) {
|
||||
resDamaged.checked = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanRadio) {
|
||||
cleanRadio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
const resComp = document.getElementById('res_compliant');
|
||||
if (resComp) {
|
||||
resComp.checked = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Bouton clear text
|
||||
const btnClearText = document.getElementById('btn_clear_sign_text');
|
||||
const inputText = document.getElementById('insp_verified_signpanel_text');
|
||||
if (btnClearText && inputText) {
|
||||
btnClearText.addEventListener('click', function() {
|
||||
inputText.value = '';
|
||||
inputText.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Assistance IA (Bouton Détecter par IA)
|
||||
const btnAIDetect = document.getElementById('btn_sign_ai_detect');
|
||||
const aiFileInput = document.getElementById('sign_ai_file_input');
|
||||
const aiResultContainer = document.getElementById('sign_ai_result_container');
|
||||
const aiSpinner = document.getElementById('sign_ai_spinner');
|
||||
const aiBadgeContainer = document.getElementById('sign_ai_badge_container');
|
||||
|
||||
if (btnAIDetect && aiFileInput) {
|
||||
btnAIDetect.addEventListener('click', function() {
|
||||
aiFileInput.click();
|
||||
});
|
||||
|
||||
aiFileInput.addEventListener('change', async function() {
|
||||
if (!this.files || !this.files.length) return;
|
||||
const file = this.files[0];
|
||||
|
||||
if (aiResultContainer) aiResultContainer.classList.remove('d-none');
|
||||
if (aiSpinner) aiSpinner.classList.remove('d-none');
|
||||
if (aiBadgeContainer) aiBadgeContainer.innerHTML = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('input[name="csrfmiddlewaretoken"]')?.value || '';
|
||||
const response = await fetch('/sign/api/detect/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (aiSpinner) aiSpinner.classList.add('d-none');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur réseau (' + response.status + ')');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if ((data.status === 'success' || data.success) && data.panels && data.panels.length > 0) {
|
||||
// Panneau principal détecté
|
||||
const topPanel = data.panels[0];
|
||||
const detectedCode = topPanel.code;
|
||||
const confidence = Math.round((topPanel.confidence || 0) * 100);
|
||||
const detectedText = topPanel.ocr_text || topPanel.signpanel_text || topPanel.text || '';
|
||||
const panelLabel = topPanel.name_fr || topPanel.label_fr || topPanel.name_nl || '';
|
||||
|
||||
// Remplissage des inputs cachés
|
||||
const inputAiCode = document.getElementById('insp_ai_detected_code');
|
||||
const inputAiConf = document.getElementById('insp_ai_confidence');
|
||||
if (inputAiCode) inputAiCode.value = detectedCode;
|
||||
if (inputAiConf) inputAiConf.value = topPanel.confidence || 0;
|
||||
|
||||
// Comparaison avec le type actuel
|
||||
const currentSelectedOpt = selectType?.options[selectType.selectedIndex];
|
||||
const currentCode = currentSelectedOpt?.getAttribute('data-code') || '{{ asset.signpanel_type.code|default:"" }}';
|
||||
const isMatch = (currentCode.toUpperCase() === detectedCode.toUpperCase());
|
||||
|
||||
let badgeHtml = '';
|
||||
if (isMatch) {
|
||||
badgeHtml = `
|
||||
<div class="alert alert-success d-flex align-items-center justify-content-between p-2 mb-1 rounded-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 28px; height: 28px; object-fit: contain;">
|
||||
<div>
|
||||
<div class="fw-bold small text-success">✓ Conforme : ${detectedCode} (${confidence}%)</div>
|
||||
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge bg-success">Validé</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
badgeHtml = `
|
||||
<div class="alert alert-warning d-flex align-items-center justify-content-between p-2 mb-1 rounded-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="/static/assets/road_signs/2025/${detectedCode.toUpperCase()}.svg" style="width: 28px; height: 28px; object-fit: contain;">
|
||||
<div>
|
||||
<div class="fw-bold small text-dark">Détecté : <span class="text-primary">${detectedCode}</span> (${confidence}%)</div>
|
||||
<div style="font-size: 0.75rem;" class="text-muted">${panelLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary py-1 px-2" id="btn_apply_ai_code" data-code="${detectedCode}">
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (detectedText) {
|
||||
badgeHtml += `
|
||||
<div class="d-flex align-items-center justify-content-between p-2 bg-light rounded border mt-1">
|
||||
<small class="text-muted"><i class="bi bi-fonts me-1"></i>Texte OCR : <strong>« ${detectedText} »</strong></small>
|
||||
<button type="button" class="btn btn-xs btn-outline-secondary py-0 px-2" id="btn_apply_ai_text" data-text="${detectedText}">
|
||||
Insérer
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
aiBadgeContainer.innerHTML = badgeHtml;
|
||||
|
||||
// Bouton Appliquer le type détecté
|
||||
const btnApplyCode = document.getElementById('btn_apply_ai_code');
|
||||
if (btnApplyCode) {
|
||||
btnApplyCode.addEventListener('click', function() {
|
||||
const codeToApply = this.getAttribute('data-code');
|
||||
if (selectType) {
|
||||
for (let i = 0; i < selectType.options.length; i++) {
|
||||
if ((selectType.options[i].getAttribute('data-code') || '').toUpperCase() === codeToApply.toUpperCase()) {
|
||||
selectType.selectedIndex = i;
|
||||
selectType.dispatchEvent(new Event('change'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bouton Insérer le texte
|
||||
const btnApplyText = document.getElementById('btn_apply_ai_text');
|
||||
if (btnApplyText) {
|
||||
btnApplyText.addEventListener('click', function() {
|
||||
const textToApply = this.getAttribute('data-text');
|
||||
if (inputText) {
|
||||
inputText.value = textToApply;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ajoute aussi automatiquement la photo à la liste des photos de l'inspection !
|
||||
if (window.inspAccumulatedFiles) {
|
||||
window.inspAccumulatedFiles.push(file);
|
||||
if (typeof window.renderInspPhotosPreview === 'function') {
|
||||
window.renderInspPhotosPreview();
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
aiBadgeContainer.innerHTML = `
|
||||
<div class="alert alert-secondary p-2 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>Aucun panneau reconnu avec certitude sur cette image.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (aiSpinner) aiSpinner.classList.add('d-none');
|
||||
aiBadgeContainer.innerHTML = `
|
||||
<div class="alert alert-danger p-2 mb-0 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Erreur lors de l'analyse : ${err.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
{% load i18n static %}
|
||||
|
||||
<!-- Modale de Création Rapide de Signalisation Assistée par IA -->
|
||||
<div class="modal fade" id="modalQuickAddSign" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="modalQuickAddSignLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content shadow-lg border-0 rounded-4">
|
||||
<div class="modal-header bg-primary text-white py-2.5 px-3 rounded-top-4">
|
||||
<h5 class="modal-title fw-bold fs-6 d-flex align-items-center gap-2" id="modalQuickAddSignLabel">
|
||||
<i class="bi bi-signpost-split-fill"></i>
|
||||
<span>{% translate "Ajouter une signalisation (Assisté par IA)" %}</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body p-3">
|
||||
<form id="formQuickAddSign" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Coordonnées GPS -->
|
||||
<div class="card bg-light border-0 rounded-3 p-2.5 mb-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="small fw-semibold text-secondary d-flex align-items-center gap-1">
|
||||
<i class="bi bi-geo-alt-fill text-danger"></i>{% translate "Position d'implantation" %}
|
||||
</span>
|
||||
<span class="badge bg-white text-dark border extra-small" id="new_sign_gps_coords">Lat: -, Lon: -</span>
|
||||
</div>
|
||||
<input type="hidden" name="lat" id="new_sign_lat" value="">
|
||||
<input type="hidden" name="lon" id="new_sign_lon" value="">
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Prise de vue du mât complet -->
|
||||
<div class="card border border-primary-subtle bg-primary-subtle bg-opacity-10 rounded-3 p-3 mb-3 text-center">
|
||||
<div class="mb-2">
|
||||
<i class="bi bi-camera-fill text-primary fs-3"></i>
|
||||
<h6 class="fw-bold text-dark mt-1 mb-0">{% translate "Photographiez le mât / support complet" %}</h6>
|
||||
<small class="text-muted" style="font-size: 0.8rem;">
|
||||
{% translate "L'IA détectera automatiquement tous les panneaux présents de haut en bas" %}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center gap-2 mt-2">
|
||||
<button type="button" id="btn_new_sign_camera" class="btn btn-primary d-flex align-items-center gap-2 px-3 py-2 shadow-sm">
|
||||
<i class="bi bi-camera"></i>
|
||||
<span class="fw-semibold">{% translate "Prendre une photo" %}</span>
|
||||
</button>
|
||||
<button type="button" id="btn_new_sign_gallery" class="btn btn-outline-secondary bg-white d-flex align-items-center gap-2 px-3 py-2 shadow-sm">
|
||||
<i class="bi bi-image"></i>
|
||||
<span>{% translate "Galerie" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inputs caméras / galeries masqués -->
|
||||
<input type="file" id="new_sign_camera_input" accept="image/*" capture="environment" class="d-none">
|
||||
<input type="file" id="new_sign_gallery_input" accept="image/*" class="d-none">
|
||||
|
||||
<!-- Loader IA -->
|
||||
<div id="new_sign_ai_loading" class="mt-3 d-none">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span class="small text-primary fw-semibold ms-2">{% translate "Analyse visuelle multi-panneaux en cours..." %}</span>
|
||||
</div>
|
||||
|
||||
<!-- Aperçu de la photo prise -->
|
||||
<div id="new_sign_photo_preview_box" class="mt-2 d-none">
|
||||
<img id="new_sign_photo_preview" src="" alt="Aperçu mât" class="img-fluid rounded border shadow-sm" style="max-height: 180px; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Liste des panneaux détectés / à créer -->
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<label class="form-label fw-bold small text-dark mb-0 d-flex align-items-center gap-1">
|
||||
<i class="bi bi-stack text-primary"></i>
|
||||
<span>{% translate "Panneaux sur le mât (du haut vers le bas)" %}</span>
|
||||
</label>
|
||||
<button type="button" id="btn_add_manual_panel_row" class="btn btn-outline-primary btn-sm py-0 px-2" style="font-size: 0.75rem;">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% translate "Ajouter un panneau" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="new_sign_panels_list" class="d-flex flex-column gap-2">
|
||||
<!-- Les lignes de panneaux seront injectées dynamiquement ici -->
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer bg-light py-2 px-3 rounded-bottom-4 d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">
|
||||
{% translate "Annuler" %}
|
||||
</button>
|
||||
<button type="button" id="btn_submit_quick_add_sign" class="btn btn-primary btn-sm fw-bold px-3 py-1.5 shadow-sm d-flex align-items-center gap-2">
|
||||
<i class="bi bi-check2-circle fs-6"></i>
|
||||
<span>{% translate "Créer et Enregistrer" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -234,9 +234,12 @@
|
|||
<!-- Carte MapLibre -->
|
||||
<div id="inspection-map"></div>
|
||||
|
||||
<!-- Actions Flottantes (Bouton GPS) -->
|
||||
<!-- Actions Flottantes (Bouton Ajout Panneau & GPS) -->
|
||||
<div class="map-floating-actions">
|
||||
<button id="btn-gps-locate" class="btn btn-white btn-lg shadow rounded-circle border" title="{% translate 'Ma position GPS' %}">
|
||||
<button id="btn-add-quick-sign" class="btn btn-primary btn-lg shadow rounded-circle d-none align-items-center justify-content-center border-0" title="{% translate 'Ajouter un panneau de signalisation' %}" style="width: 52px; height: 52px;">
|
||||
<i class="bi bi-plus-lg fs-4"></i>
|
||||
</button>
|
||||
<button id="btn-gps-locate" class="btn btn-white btn-lg shadow rounded-circle border" title="{% translate 'Ma position GPS' %}" style="width: 52px; height: 52px;">
|
||||
<i class="bi bi-crosshair text-primary fs-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -454,6 +457,7 @@
|
|||
<!-- Inclure les Modales d'inspection -->
|
||||
{% include "inspections/components/inspection_form_modal.html" %}
|
||||
{% include "inspections/components/edit_inspection_modal.html" %}
|
||||
{% include "inspections/components/quick_add_sign_modal.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -866,5 +866,70 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
|
|||
self.assertIn(insp_water, qs)
|
||||
self.assertIn(insp_struct_b, qs)
|
||||
|
||||
def test_signpanel_inspection_creation_and_asset_sync(self):
|
||||
from assets.models import SignPanel, SignPanelType, SignPole, SignStreet
|
||||
from inspections.models import SignPanelInspection
|
||||
|
||||
street = SignStreet.objects.create(code='STR-TEST-1', name_fr='Rue Test Signalisation')
|
||||
type_c1, _ = SignPanelType.objects.get_or_create(code='C1', defaults={'name_fr': 'Sens interdit'})
|
||||
type_d7, _ = SignPanelType.objects.get_or_create(code='D7', defaults={'name_fr': 'Piste cyclable'})
|
||||
|
||||
pole = SignPole.objects.create(code='P-TEST-1', street=street, status='active')
|
||||
panel = SignPanel.objects.create(
|
||||
pole=pole,
|
||||
code='PAN-TEST-1',
|
||||
signpanel_type=type_c1,
|
||||
signpanel_text='Sauf bus',
|
||||
inspection_status='compliant',
|
||||
status='active'
|
||||
)
|
||||
panel_ct = ContentType.objects.get_for_model(SignPanel)
|
||||
|
||||
# 1. Inspection avec modification du type et de la propreté (dégradé -> is_compliant=False)
|
||||
insp = SignPanelInspection.objects.create(
|
||||
asset_content_type=panel_ct,
|
||||
asset_object_id=panel.id,
|
||||
inspector=self.internal_user,
|
||||
result_status='damaged',
|
||||
verified_signpanel_type=type_d7,
|
||||
verified_signpanel_text='Sauf vélos',
|
||||
verified_cleanliness='vandalized',
|
||||
ai_detected_code='D7',
|
||||
ai_confidence=0.97,
|
||||
apply_sign_updates_to_asset=True
|
||||
)
|
||||
|
||||
panel.refresh_from_db()
|
||||
self.assertEqual(panel.signpanel_type, type_d7)
|
||||
self.assertEqual(panel.signpanel_text, 'Sauf vélos')
|
||||
self.assertFalse(panel.is_compliant)
|
||||
self.assertEqual(insp.detailed_inspection, insp)
|
||||
self.assertIn('Tagué', insp.details_summary)
|
||||
|
||||
# 2. Test API create_inspection_api pour SignPanel
|
||||
self.client.force_login(self.internal_user)
|
||||
response = self.client.post(reverse('inspections:create_inspection_api'), {
|
||||
'content_type_id': panel_ct.id,
|
||||
'object_id': panel.id,
|
||||
'result_status': 'missing',
|
||||
'verified_cleanliness': 'missing',
|
||||
'verified_signpanel_type_id': type_c1.id,
|
||||
'verified_signpanel_text': 'Texte test',
|
||||
'apply_changes_to_asset': 'true'
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
panel.refresh_from_db()
|
||||
self.assertEqual(panel.status, 'inactive')
|
||||
self.assertFalse(panel.is_compliant)
|
||||
|
||||
# 3. Test API get_inspection_detail_api
|
||||
created_insp = SignPanelInspection.objects.filter(asset_object_id=panel.id).order_by('-id').first()
|
||||
detail_res = self.client.get(reverse('inspections:get_inspection_detail_api', kwargs={'inspection_id': created_insp.id}))
|
||||
self.assertEqual(detail_res.status_code, 200)
|
||||
data = detail_res.json()
|
||||
self.assertTrue(data['is_signpanel'])
|
||||
self.assertEqual(data['verified_cleanliness'], 'missing')
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from common.models import Thematic
|
|||
from common.utils import get_short_name
|
||||
from assets.models import AssetCategory
|
||||
from .models import (
|
||||
Inspection, ParkingSpotInspection, GiepInspection, InspectionDocument,
|
||||
Inspection, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument,
|
||||
INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES
|
||||
)
|
||||
|
||||
|
|
@ -286,6 +286,7 @@ def create_inspection_api(request):
|
|||
|
||||
is_parking_spot = (content_type.model == 'parkingspot')
|
||||
is_giep = (content_type.model == 'naturerwiz')
|
||||
is_signpanel = (content_type.model == 'signpanel')
|
||||
|
||||
try:
|
||||
if is_parking_spot:
|
||||
|
|
@ -356,6 +357,37 @@ def create_inspection_api(request):
|
|||
data=parsed_giep_data,
|
||||
)
|
||||
inspection.save()
|
||||
elif is_signpanel:
|
||||
from assets.models import SignPanelType
|
||||
verified_type_id = request.POST.get('verified_signpanel_type_id') or None
|
||||
verified_type = SignPanelType.objects.filter(pk=verified_type_id).first() if verified_type_id else None
|
||||
verified_text = request.POST.get('verified_signpanel_text')
|
||||
verified_cleanliness = request.POST.get('verified_cleanliness', 'clean')
|
||||
ai_code = request.POST.get('ai_detected_code')
|
||||
ai_conf_raw = request.POST.get('ai_confidence')
|
||||
ai_conf = float(ai_conf_raw) if ai_conf_raw else None
|
||||
|
||||
inspection = SignPanelInspection(
|
||||
asset_content_type=content_type,
|
||||
asset_object_id=target_obj.pk,
|
||||
inspector=request.user,
|
||||
inspection_date=timezone.now(),
|
||||
result_status=result_status,
|
||||
nature=nature,
|
||||
inspector_role=inspector_role,
|
||||
mission_intervention_id=mission_intervention_id,
|
||||
checked_intervention_id=checked_intervention_id,
|
||||
verified_obsolescence=verified_obsolescence,
|
||||
notes=notes,
|
||||
apply_changes_to_asset=apply_changes,
|
||||
verified_signpanel_type=verified_type,
|
||||
verified_signpanel_text=verified_text,
|
||||
verified_cleanliness=verified_cleanliness,
|
||||
ai_detected_code=ai_code,
|
||||
ai_confidence=ai_conf,
|
||||
apply_sign_updates_to_asset=apply_changes,
|
||||
)
|
||||
inspection.save()
|
||||
else:
|
||||
inspection = Inspection.objects.create(
|
||||
asset_content_type=content_type,
|
||||
|
|
@ -482,12 +514,22 @@ def get_inspection_detail_api(request, inspection_id):
|
|||
data['apply_physical_counts_to_asset'] = leaf.apply_physical_counts_to_asset
|
||||
|
||||
elif isinstance(leaf, GiepInspection):
|
||||
|
||||
data['is_giep'] = True
|
||||
data['date_derniere_pluie'] = leaf.date_derniere_pluie or 'null'
|
||||
data['orage_moins_une_heure'] = leaf.orage_moins_une_heure
|
||||
data['giep_data'] = leaf.data or {}
|
||||
|
||||
elif isinstance(leaf, SignPanelInspection):
|
||||
data['is_signpanel'] = True
|
||||
data['verified_signpanel_type_id'] = leaf.verified_signpanel_type_id
|
||||
data['verified_signpanel_type_code'] = leaf.verified_signpanel_type.code if leaf.verified_signpanel_type else ''
|
||||
data['verified_signpanel_type_name'] = leaf.verified_signpanel_type.get_name() if leaf.verified_signpanel_type else ''
|
||||
data['verified_signpanel_text'] = leaf.verified_signpanel_text or ''
|
||||
data['verified_cleanliness'] = leaf.verified_cleanliness or 'clean'
|
||||
data['ai_detected_code'] = leaf.ai_detected_code or ''
|
||||
data['ai_confidence'] = leaf.ai_confidence
|
||||
data['apply_sign_updates_to_asset'] = leaf.apply_sign_updates_to_asset
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
|
|
@ -558,6 +600,17 @@ def update_inspection_api(request, inspection_id):
|
|||
inspection.data = json.loads(request.POST['giep_data'])
|
||||
except Exception:
|
||||
pass
|
||||
elif isinstance(inspection, SignPanelInspection):
|
||||
from assets.models import SignPanelType
|
||||
if 'verified_signpanel_type_id' in request.POST:
|
||||
v_type_id = request.POST.get('verified_signpanel_type_id') or None
|
||||
inspection.verified_signpanel_type = SignPanelType.objects.filter(pk=v_type_id).first() if v_type_id else None
|
||||
if 'verified_signpanel_text' in request.POST:
|
||||
inspection.verified_signpanel_text = request.POST.get('verified_signpanel_text')
|
||||
if 'verified_cleanliness' in request.POST:
|
||||
inspection.verified_cleanliness = request.POST.get('verified_cleanliness')
|
||||
if 'apply_sign_updates_to_asset' in request.POST:
|
||||
inspection.apply_sign_updates_to_asset = (request.POST.get('apply_sign_updates_to_asset') == 'true')
|
||||
|
||||
# Traitement des nouvelles pièces jointes / photos
|
||||
uploaded_files = request.FILES.getlist('photos') + request.FILES.getlist('documents') + request.FILES.getlist('files')
|
||||
|
|
@ -853,5 +906,9 @@ def get_asset_inspection_form_partial(request):
|
|||
return HttpResponse('')
|
||||
|
||||
context = {'asset': asset}
|
||||
if model_name == 'signpanel':
|
||||
from assets.models import SignPanelType
|
||||
context['panel_types'] = SignPanelType.objects.all().order_by('code')
|
||||
|
||||
return render(request, template_path, context)
|
||||
|
||||
|
|
|
|||
|
|
@ -486,6 +486,7 @@ class SignDetectionService:
|
|||
|
||||
return {
|
||||
"status": "success",
|
||||
"success": True,
|
||||
"image_dimensions": {"width": img_w, "height": img_h},
|
||||
"detected_count": len(detected_panels),
|
||||
"panels": detected_panels,
|
||||
|
|
|
|||
|
|
@ -188,3 +188,64 @@ class SignAIApiAndViewsTests(APITestCase):
|
|||
url = reverse("sign:api_detect")
|
||||
response = self.client.post(url, {}, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_quick_create_sign_with_ai(self):
|
||||
from assets.models import SignPanelType, SignStreet, SignPole, SignPanel
|
||||
from inspections.models import SignPanelInspection
|
||||
import json
|
||||
|
||||
SignStreet.objects.create(code="STR-AI-1", name_fr="Rue du Progrès")
|
||||
SignPanelType.objects.get_or_create(code="C1", defaults={"name_fr": "Sens interdit"})
|
||||
SignPanelType.objects.get_or_create(code="M2", defaults={"name_fr": "Panonceau vélo"})
|
||||
|
||||
url = reverse("sign:api_quick_create")
|
||||
file_obj = self._create_uploaded_image_file()
|
||||
|
||||
panels_data = [
|
||||
{
|
||||
"signpanel_type_code": "C1",
|
||||
"signpanel_text": "Sens unique",
|
||||
"cleanliness": "clean",
|
||||
"vertical_order": 1
|
||||
},
|
||||
{
|
||||
"signpanel_type_code": "M2",
|
||||
"signpanel_text": "Sauf cyclistes",
|
||||
"cleanliness": "dirty",
|
||||
"vertical_order": 2
|
||||
}
|
||||
]
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"lat": "50.8503",
|
||||
"lon": "4.3517",
|
||||
"panels": json.dumps(panels_data),
|
||||
"photos": file_obj
|
||||
},
|
||||
format="multipart"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["panels_created"], 2)
|
||||
|
||||
# Vérifier en BDD
|
||||
pole = SignPole.objects.get(id=data["pole_id"])
|
||||
self.assertIsNotNone(pole.geom)
|
||||
self.assertEqual(pole.signpanels.count(), 2)
|
||||
|
||||
p1 = pole.signpanels.get(vertical_order=1)
|
||||
self.assertEqual(p1.signpanel_type.code, "C1")
|
||||
self.assertTrue(p1.is_compliant)
|
||||
|
||||
p2 = pole.signpanels.get(vertical_order=2)
|
||||
self.assertEqual(p2.signpanel_type.code, "M2")
|
||||
self.assertFalse(p2.is_compliant) # Dirty -> non-conforme
|
||||
|
||||
# Vérifier l'inspection générée
|
||||
inspections = SignPanelInspection.objects.filter(asset_object_id__in=[p1.id, p2.id])
|
||||
self.assertEqual(inspections.count(), 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ urlpatterns = [
|
|||
path("", views.index, name="index"),
|
||||
path("ai/demo/", views_ai.SignAIDemoView.as_view(), name="ai_demo"),
|
||||
path("api/detect/", views_ai.DetectSignAPIView.as_view(), name="api_detect"),
|
||||
path("api/quick-create/", views_ai.QuickCreateSignWithAIView.as_view(), name="api_quick_create"),
|
||||
path("streets/", views.sign_streets_list, name="sign_streets_list"),
|
||||
path("streets/<int:street_id>/", views.sign_streets_detail, name="sign_streets_detail"),
|
||||
path("assets/<str:asset_model>/<int:asset_id>/", views.sign_assets_detail, name="sign_assets_detail"),
|
||||
|
|
|
|||
|
|
@ -110,3 +110,139 @@ class DetectSignAPIView(APIView):
|
|||
{"status": "error", "message": str(exc)},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class QuickCreateSignWithAIView(APIView):
|
||||
"""
|
||||
Endpoint pour créer un mât (SignPole) et un ou plusieurs panneaux (SignPanel)
|
||||
à partir des résultats de détection IA ou d'une saisie rapide sur le terrain,
|
||||
avec enregistrement de la 1ère inspection associée.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
from django.contrib.gis.geos import Point
|
||||
from django.contrib.gis.db.models.functions import Distance
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from assets.models import SignPole, SignPanel, SignPanelType, SignStreet
|
||||
from inspections.models import SignPanelInspection, InspectionDocument
|
||||
|
||||
lat = request.data.get('lat')
|
||||
lon = request.data.get('lon')
|
||||
if not lat or not lon:
|
||||
return Response({'success': False, 'message': str(_("Coordonnées GPS requises."))}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except (ValueError, TypeError):
|
||||
return Response({'success': False, 'message': str(_("Coordonnées GPS invalides."))}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 1. Recherche de la rue la plus proche
|
||||
point_4326 = Point(lon, lat, srid=4326)
|
||||
closest_street = SignStreet.objects.annotate(
|
||||
distance=Distance('geom', point_4326)
|
||||
).order_by('distance').first()
|
||||
|
||||
# 2. Création du Poteau / Support (SignPole)
|
||||
pole_geom = Point(lon, lat, srid=4326)
|
||||
pole = SignPole.objects.create(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
geom=pole_geom,
|
||||
geojson=pole_geom.geojson,
|
||||
street=closest_street,
|
||||
code=f"P-{int(timezone.now().timestamp())}",
|
||||
status='active'
|
||||
)
|
||||
|
||||
panels_data = request.data.get('panels', '[]')
|
||||
if isinstance(panels_data, str):
|
||||
try:
|
||||
panels_data = json.loads(panels_data)
|
||||
except Exception:
|
||||
panels_data = []
|
||||
|
||||
created_panels = []
|
||||
uploaded_photos = request.FILES.getlist('photos')
|
||||
|
||||
for idx, p_info in enumerate(panels_data):
|
||||
type_code = (p_info.get('signpanel_type_code') or p_info.get('code') or 'C1').strip().upper()
|
||||
ptype = SignPanelType.objects.filter(code__iexact=type_code).first()
|
||||
if not ptype:
|
||||
ptype = SignPanelType.objects.filter(code__iexact='C1').first()
|
||||
|
||||
panel_text = p_info.get('signpanel_text') or p_info.get('text') or ''
|
||||
cleanliness = p_info.get('cleanliness') or 'clean'
|
||||
vertical_order = p_info.get('vertical_order') or (idx + 1)
|
||||
ai_detected_code = p_info.get('ai_detected_code') or type_code
|
||||
ai_conf_val = p_info.get('ai_confidence', 0.95)
|
||||
try:
|
||||
ai_confidence = float(ai_conf_val)
|
||||
except (ValueError, TypeError):
|
||||
ai_confidence = 0.95
|
||||
|
||||
is_compliant = (cleanliness == 'clean')
|
||||
panel_status = 'inactive' if cleanliness == 'missing' else 'active'
|
||||
|
||||
panel = SignPanel.objects.create(
|
||||
pole=pole,
|
||||
street=closest_street,
|
||||
signpanel_type=ptype,
|
||||
signpanel_text=panel_text,
|
||||
vertical_order=vertical_order,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
geom=pole_geom,
|
||||
geojson=pole_geom.geojson,
|
||||
code=f"PAN-{pole.id}-{vertical_order}",
|
||||
status=panel_status,
|
||||
inspection_status='compliant' if is_compliant else 'non_compliant',
|
||||
last_inspection_date=timezone.now()
|
||||
)
|
||||
|
||||
# Création de l'inspection initiale
|
||||
result_status = 'compliant' if is_compliant else ('missing' if cleanliness == 'missing' else 'damaged')
|
||||
ct = ContentType.objects.get_for_model(SignPanel)
|
||||
inspection = SignPanelInspection.objects.create(
|
||||
asset_content_type=ct,
|
||||
asset_object_id=panel.pk,
|
||||
inspector=request.user,
|
||||
inspection_date=timezone.now(),
|
||||
result_status=result_status,
|
||||
nature='visual',
|
||||
inspector_role='observer',
|
||||
verified_signpanel_type=ptype,
|
||||
verified_signpanel_text=panel_text,
|
||||
verified_cleanliness=cleanliness,
|
||||
ai_detected_code=ai_detected_code,
|
||||
ai_confidence=ai_confidence,
|
||||
apply_sign_updates_to_asset=True,
|
||||
notes="Création initiale assistée par IA sur le terrain."
|
||||
)
|
||||
|
||||
for photo in uploaded_photos:
|
||||
InspectionDocument.objects.create(
|
||||
inspection=inspection,
|
||||
file=photo
|
||||
)
|
||||
|
||||
created_panels.append({
|
||||
'id': panel.id,
|
||||
'code': panel.code,
|
||||
'type_code': ptype.code if ptype else type_code,
|
||||
'text': panel_text,
|
||||
'vertical_order': vertical_order
|
||||
})
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'pole_id': pole.id,
|
||||
'pole_code': pole.code,
|
||||
'panels': created_panels,
|
||||
'panels_created': len(created_panels),
|
||||
'message': str(_("%(count)d panneau(x) créé(s) avec succès.") % {'count': len(created_panels)})
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue