From 814a04f1ac33cc44567c3e7864d935423f7ba9e9 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 22 Aug 2026 18:08:12 +0200 Subject: [PATCH] feat: implement dynamic sign panel rendering with compliance status and icon support on inspection maps --- loko/assets/models/core.py | 12 + loko/assets/static/assets/inspection_map.js | 473 +++++++++++++++++- loko/assets/views/sign.py | 13 + .../migrations/0008_signpanelinspection.py | 32 ++ loko/inspections/models.py | 88 +++- loko/inspections/registry.py | 4 +- .../components/asset_details/_signpanel.html | 93 ++++ .../components/asset_fields/_signpanel.html | 388 ++++++++++++++ .../components/quick_add_sign_modal.html | 98 ++++ .../inspections/quick_inspection_map.html | 8 +- loko/inspections/tests.py | 65 +++ loko/inspections/views.py | 61 ++- loko/sign/ai/detector.py | 1 + loko/sign/tests_ai.py | 61 +++ loko/sign/urls.py | 1 + loko/sign/views_ai.py | 136 +++++ 16 files changed, 1517 insertions(+), 17 deletions(-) create mode 100644 loko/inspections/migrations/0008_signpanelinspection.py create mode 100644 loko/inspections/templates/inspections/components/asset_details/_signpanel.html create mode 100644 loko/inspections/templates/inspections/components/asset_fields/_signpanel.html create mode 100644 loko/inspections/templates/inspections/components/quick_add_sign_modal.html diff --git a/loko/assets/models/core.py b/loko/assets/models/core.py index 7714df2..99b887d 100644 --- a/loko/assets/models/core.py +++ b/loko/assets/models/core.py @@ -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): diff --git a/loko/assets/static/assets/inspection_map.js b/loko/assets/static/assets/inspection_map.js index 1e61c50..02c4a3d 100644 --- a/loko/assets/static/assets/inspection_map.js +++ b/loko/assets/static/assets/inspection_map.js @@ -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, '&').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) ? 'Non-conforme' : 'Conforme'; + + signHeaderHtml = ` +
+
+ ${upperCode} +
+
+
${escapeHtml(signCode)}
+ ${signText ? `
« ${escapeHtml(signText)} »
` : ''} +
+ ${cleanBadge} +
+ `; + } + let html = `
@@ -369,6 +551,8 @@ ${props.inspections_count || inspectionsList.length}
+ ${signHeaderHtml} + +
+
+
+
+ ${upperCode} +
+
+
+
+ Code + +
+ +
+
+
+ Propreté : +
+ + + + + + + + +
+
+ `; + + 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 = ' 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 = ' Créer et Enregistrer'; + } + }); + } + } + function initFormHandler() { initInspectionPhotoHandler(); + initQuickAddSignHandler(); const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm'); if (!form) return; diff --git a/loko/assets/views/sign.py b/loko/assets/views/sign.py index 84fc228..2488c95 100644 --- a/loko/assets/views/sign.py +++ b/loko/assets/views/sign.py @@ -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", diff --git a/loko/inspections/migrations/0008_signpanelinspection.py b/loko/inspections/migrations/0008_signpanelinspection.py new file mode 100644 index 0000000..4624636 --- /dev/null +++ b/loko/inspections/migrations/0008_signpanelinspection.py @@ -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',), + ), + ] diff --git a/loko/inspections/models.py b/loko/inspections/models.py index cf1d3c2..3f745f9 100644 --- a/loko/inspections/models.py +++ b/loko/inspections/models.py @@ -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") diff --git a/loko/inspections/registry.py b/loko/inspections/registry.py index 7ca813f..66739ca 100644 --- a/loko/inspections/registry.py +++ b/loko/inspections/registry.py @@ -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: diff --git a/loko/inspections/templates/inspections/components/asset_details/_signpanel.html b/loko/inspections/templates/inspections/components/asset_details/_signpanel.html new file mode 100644 index 0000000..15fb638 --- /dev/null +++ b/loko/inspections/templates/inspections/components/asset_details/_signpanel.html @@ -0,0 +1,93 @@ +{% load i18n static %} + +{% with leaf=insp.detailed_inspection %} +{% if leaf %} +
+
+ {% translate "Constats spécifiques signalisation" %} +
+ + +
+
+ {% if leaf.verified_signpanel_type %}{{ leaf.verified_signpanel_type.code }}{% elif insp.asset.signpanel_type %}{{ insp.asset.signpanel_type.code }}{% else %}Panneau{% endif %} +
+
+ {% translate "Type de panneau" %} + + {% 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 %} + +
+
+ + +
+{% endif %} +{% endwith %} diff --git a/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html b/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html new file mode 100644 index 0000000..d66f380 --- /dev/null +++ b/loko/inspections/templates/inspections/components/asset_fields/_signpanel.html @@ -0,0 +1,388 @@ +{% load i18n static %} + + +
+
+ + + {% translate "Contrôle de signalisation routière" %} + + + {% if asset.code %}{{ asset.code }}{% elif asset.signpanel_type %}{{ asset.signpanel_type.code }}{% else %}Panneau{% endif %} + +
+ + +
+
+
+
+ + {% translate "Assistant IA Signalisation" %} + +
+ {% translate "Photographiez le panneau pour valider ou corriger automatiquement le type & texte" %} +
+
+ +
+ + + + + +
+
+
+ {% translate "Analyse visuelle & OCR en cours..." %} +
+
+
+
+
+ + + + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+
+ Panneau +
+
+
+ {% 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 %} +
+ + {% if asset.signpanel_type %}{{ asset.signpanel_type.name_nl|default:'' }}{% endif %} + +
+
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+
+ + diff --git a/loko/inspections/templates/inspections/components/quick_add_sign_modal.html b/loko/inspections/templates/inspections/components/quick_add_sign_modal.html new file mode 100644 index 0000000..eda49df --- /dev/null +++ b/loko/inspections/templates/inspections/components/quick_add_sign_modal.html @@ -0,0 +1,98 @@ +{% load i18n static %} + + + diff --git a/loko/inspections/templates/inspections/quick_inspection_map.html b/loko/inspections/templates/inspections/quick_inspection_map.html index 2cc482a..ef1ce5e 100644 --- a/loko/inspections/templates/inspections/quick_inspection_map.html +++ b/loko/inspections/templates/inspections/quick_inspection_map.html @@ -234,9 +234,12 @@
- +
- +
@@ -454,6 +457,7 @@ {% include "inspections/components/inspection_form_modal.html" %} {% include "inspections/components/edit_inspection_modal.html" %} +{% include "inspections/components/quick_add_sign_modal.html" %} {% endblock %} diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py index d3fb4ab..8de14fe 100644 --- a/loko/inspections/tests.py +++ b/loko/inspections/tests.py @@ -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') + + diff --git a/loko/inspections/views.py b/loko/inspections/views.py index c31059d..880a9fe 100644 --- a/loko/inspections/views.py +++ b/loko/inspections/views.py @@ -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) diff --git a/loko/sign/ai/detector.py b/loko/sign/ai/detector.py index 4f9cc2c..5edcba7 100644 --- a/loko/sign/ai/detector.py +++ b/loko/sign/ai/detector.py @@ -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, diff --git a/loko/sign/tests_ai.py b/loko/sign/tests_ai.py index c6101d7..e485544 100644 --- a/loko/sign/tests_ai.py +++ b/loko/sign/tests_ai.py @@ -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) + diff --git a/loko/sign/urls.py b/loko/sign/urls.py index 2bcfa03..7462c6f 100644 --- a/loko/sign/urls.py +++ b/loko/sign/urls.py @@ -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//", views.sign_streets_detail, name="sign_streets_detail"), path("assets///", views.sign_assets_detail, name="sign_assets_detail"), diff --git a/loko/sign/views_ai.py b/loko/sign/views_ai.py index 49e848f..76c0bdf 100644 --- a/loko/sign/views_ai.py +++ b/loko/sign/views_ai.py @@ -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)}) + }) + +