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'
|
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):
|
def get_replacement_chain(self):
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,103 @@
|
||||||
map.addControl(searchCtrl, 'top-left');
|
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() {
|
map.on('load', function() {
|
||||||
|
initRoadSignImagesLoader(map);
|
||||||
|
|
||||||
// Source GeoJSON pour les assets et localisations
|
// Source GeoJSON pour les assets et localisations
|
||||||
map.addSource('assets-source', {
|
map.addSource('assets-source', {
|
||||||
type: 'geojson',
|
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({
|
map.addLayer({
|
||||||
id: 'assets-points',
|
id: 'assets-points',
|
||||||
type: 'circle',
|
type: 'circle',
|
||||||
|
|
@ -231,21 +327,85 @@
|
||||||
['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false]
|
['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false]
|
||||||
],
|
],
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': 7,
|
'circle-radius': [
|
||||||
'circle-color': [
|
'case',
|
||||||
'match', ['coalesce', ['get', 'structure_type'], ''],
|
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'], 13,
|
||||||
['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63',
|
7
|
||||||
['jardin de pluie', 'noue', 'evc'], '#009688',
|
|
||||||
['bassin orage', 'citerne', 'ta'], '#0277bd',
|
|
||||||
'#0288d1'
|
|
||||||
],
|
],
|
||||||
'circle-stroke-width': 2,
|
'circle-color': [
|
||||||
'circle-stroke-color': '#ffffff'
|
'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-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 = [
|
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
|
// Clic Priorisé sur un Asset / Localisation
|
||||||
|
|
@ -362,6 +522,28 @@
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
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 = `
|
let html = `
|
||||||
<div class="p-1" style="width: 300px; max-width: 310px; font-size: 0.85rem;">
|
<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">
|
<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>
|
<span class="badge bg-secondary">${props.inspections_count || inspectionsList.length}</span>
|
||||||
</div>
|
</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">
|
<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>
|
<i class="bi bi-plus-circle-fill"></i>
|
||||||
<span>Nouvelle inspection</span>
|
<span>Nouvelle inspection</span>
|
||||||
|
|
@ -658,6 +842,17 @@
|
||||||
|
|
||||||
const catNameDisp = document.getElementById('category-name-display');
|
const catNameDisp = document.getElementById('category-name-display');
|
||||||
if (catNameDisp) catNameDisp.textContent = selectedCategoryName;
|
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() {
|
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() {
|
function initFormHandler() {
|
||||||
initInspectionPhotoHandler();
|
initInspectionPhotoHandler();
|
||||||
|
initQuickAddSignHandler();
|
||||||
|
|
||||||
const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm');
|
const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm');
|
||||||
if (!form) return;
|
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 = 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
|
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 = {
|
feature = {
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": obj.id,
|
"id": obj.id,
|
||||||
|
|
@ -529,11 +536,17 @@ def sign_assets_geojson(request):
|
||||||
"orientation": obj.orientation if hasattr(obj, 'orientation') else None,
|
"orientation": obj.orientation if hasattr(obj, 'orientation') else None,
|
||||||
"status": obj.status,
|
"status": obj.status,
|
||||||
"validation_status": obj.validation_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,
|
"lon": lon,
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"asset_type": asset_type,
|
"asset_type": asset_type,
|
||||||
|
"model_name": asset_type,
|
||||||
|
"content_type_id": ct_id,
|
||||||
"panel_icon_image": panel_icon_image,
|
"panel_icon_image": panel_icon_image,
|
||||||
"panel_icon_image_scaled": panel_icon_image_scaled,
|
"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,
|
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||||
"element_type": get_element_type_label(asset_type or ''),
|
"element_type": get_element_type_label(asset_type or ''),
|
||||||
"thematic": "sign",
|
"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
|
@property
|
||||||
def detailed_inspection(self):
|
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'):
|
if hasattr(self, 'parkingspotinspection'):
|
||||||
return self.parkingspotinspection
|
return self.parkingspotinspection
|
||||||
if hasattr(self, 'giepinspection'):
|
if hasattr(self, 'giepinspection'):
|
||||||
return self.giepinspection
|
return self.giepinspection
|
||||||
|
if hasattr(self, 'signpanelinspection'):
|
||||||
|
return self.signpanelinspection
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -354,6 +356,13 @@ class Inspection(models.Model):
|
||||||
val = marking_labels.get(leaf.verified_marking_quality, leaf.verified_marking_quality)
|
val = marking_labels.get(leaf.verified_marking_quality, leaf.verified_marking_quality)
|
||||||
parts.append(str(val))
|
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:
|
if hasattr(leaf, 'data') and isinstance(leaf.data, dict) and leaf.data:
|
||||||
ouvrages = leaf.data.get('ouvrages', [])
|
ouvrages = leaf.data.get('ouvrages', [])
|
||||||
if ouvrages:
|
if ouvrages:
|
||||||
|
|
@ -537,6 +546,83 @@ class GiepInspection(Inspection):
|
||||||
verbose_name_plural = _("Inspections GIEP / Eaux pluviales")
|
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):
|
def inspection_document_upload_path(instance, filename):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
year = now.strftime("%Y")
|
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'):
|
if model_name in ('naturerwiz', 'giepinspection'):
|
||||||
model_name = 'naturerwiz'
|
model_name = 'naturerwiz'
|
||||||
elif model_name == 'parkingspotinspection':
|
elif model_name in ('parkingspot', 'parkingspotinspection'):
|
||||||
model_name = 'parkingspot'
|
model_name = 'parkingspot'
|
||||||
|
elif model_name in ('signpanel', 'signpanelinspection'):
|
||||||
|
model_name = 'signpanel'
|
||||||
|
|
||||||
template_path = f"inspections/components/asset_details/_{model_name}.html"
|
template_path = f"inspections/components/asset_details/_{model_name}.html"
|
||||||
try:
|
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 -->
|
<!-- Carte MapLibre -->
|
||||||
<div id="inspection-map"></div>
|
<div id="inspection-map"></div>
|
||||||
|
|
||||||
<!-- Actions Flottantes (Bouton GPS) -->
|
<!-- Actions Flottantes (Bouton Ajout Panneau & GPS) -->
|
||||||
<div class="map-floating-actions">
|
<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>
|
<i class="bi bi-crosshair text-primary fs-5"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -454,6 +457,7 @@
|
||||||
<!-- Inclure les Modales d'inspection -->
|
<!-- Inclure les Modales d'inspection -->
|
||||||
{% include "inspections/components/inspection_form_modal.html" %}
|
{% include "inspections/components/inspection_form_modal.html" %}
|
||||||
{% include "inspections/components/edit_inspection_modal.html" %}
|
{% include "inspections/components/edit_inspection_modal.html" %}
|
||||||
|
{% include "inspections/components/quick_add_sign_modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -866,5 +866,70 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
|
||||||
self.assertIn(insp_water, qs)
|
self.assertIn(insp_water, qs)
|
||||||
self.assertIn(insp_struct_b, 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 common.utils import get_short_name
|
||||||
from assets.models import AssetCategory
|
from assets.models import AssetCategory
|
||||||
from .models import (
|
from .models import (
|
||||||
Inspection, ParkingSpotInspection, GiepInspection, InspectionDocument,
|
Inspection, ParkingSpotInspection, GiepInspection, SignPanelInspection, InspectionDocument,
|
||||||
INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES
|
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_parking_spot = (content_type.model == 'parkingspot')
|
||||||
is_giep = (content_type.model == 'naturerwiz')
|
is_giep = (content_type.model == 'naturerwiz')
|
||||||
|
is_signpanel = (content_type.model == 'signpanel')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if is_parking_spot:
|
if is_parking_spot:
|
||||||
|
|
@ -356,6 +357,37 @@ def create_inspection_api(request):
|
||||||
data=parsed_giep_data,
|
data=parsed_giep_data,
|
||||||
)
|
)
|
||||||
inspection.save()
|
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:
|
else:
|
||||||
inspection = Inspection.objects.create(
|
inspection = Inspection.objects.create(
|
||||||
asset_content_type=content_type,
|
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
|
data['apply_physical_counts_to_asset'] = leaf.apply_physical_counts_to_asset
|
||||||
|
|
||||||
elif isinstance(leaf, GiepInspection):
|
elif isinstance(leaf, GiepInspection):
|
||||||
|
|
||||||
data['is_giep'] = True
|
data['is_giep'] = True
|
||||||
data['date_derniere_pluie'] = leaf.date_derniere_pluie or 'null'
|
data['date_derniere_pluie'] = leaf.date_derniere_pluie or 'null'
|
||||||
data['orage_moins_une_heure'] = leaf.orage_moins_une_heure
|
data['orage_moins_une_heure'] = leaf.orage_moins_une_heure
|
||||||
data['giep_data'] = leaf.data or {}
|
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)
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -558,6 +600,17 @@ 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
|
||||||
|
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
|
# Traitement des nouvelles pièces jointes / photos
|
||||||
uploaded_files = request.FILES.getlist('photos') + request.FILES.getlist('documents') + request.FILES.getlist('files')
|
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('')
|
return HttpResponse('')
|
||||||
|
|
||||||
context = {'asset': asset}
|
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)
|
return render(request, template_path, context)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,7 @@ class SignDetectionService:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
"success": True,
|
||||||
"image_dimensions": {"width": img_w, "height": img_h},
|
"image_dimensions": {"width": img_w, "height": img_h},
|
||||||
"detected_count": len(detected_panels),
|
"detected_count": len(detected_panels),
|
||||||
"panels": detected_panels,
|
"panels": detected_panels,
|
||||||
|
|
|
||||||
|
|
@ -188,3 +188,64 @@ class SignAIApiAndViewsTests(APITestCase):
|
||||||
url = reverse("sign:api_detect")
|
url = reverse("sign:api_detect")
|
||||||
response = self.client.post(url, {}, format="json")
|
response = self.client.post(url, {}, format="json")
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
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("", views.index, name="index"),
|
||||||
path("ai/demo/", views_ai.SignAIDemoView.as_view(), name="ai_demo"),
|
path("ai/demo/", views_ai.SignAIDemoView.as_view(), name="ai_demo"),
|
||||||
path("api/detect/", views_ai.DetectSignAPIView.as_view(), name="api_detect"),
|
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/", views.sign_streets_list, name="sign_streets_list"),
|
||||||
path("streets/<int:street_id>/", views.sign_streets_detail, name="sign_streets_detail"),
|
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"),
|
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": "error", "message": str(exc)},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
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