feat: implement dynamic sign panel rendering with compliance status and icon support on inspection maps

This commit is contained in:
kdeterme 2026-08-22 18:08:12 +02:00
parent e561a77fc0
commit 814a04f1ac
16 changed files with 1517 additions and 17 deletions

View file

@ -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):

View file

@ -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-color': [
'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63',
['jardin de pluie', 'noue', 'evc'], '#009688',
['bassin orage', 'citerne', 'ta'], '#0277bd',
'#0288d1'
'circle-radius': [
'case',
['==', ['coalesce', ['get', 'model_name'], ''], 'signpanel'], 13,
7
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
'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-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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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;

View file

@ -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",

View 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',),
),
]

View file

@ -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")

View file

@ -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:

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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')

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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"),

View file

@ -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)})
})