diff --git a/streetup/assets/apps.py b/streetup/assets/apps.py index 29af7f2..f443662 100644 --- a/streetup/assets/apps.py +++ b/streetup/assets/apps.py @@ -47,3 +47,11 @@ class AssetsConfig(AppConfig): ).exists() register('assets', 'TrafficLightControllerLogFile', 'file', guard_tl_controller_log) + + # Guard for inspection documents (InspectionDocument) + def guard_inspection_document(obj, user): + if not user.is_authenticated: + return False + return True + + register('assets', 'InspectionDocument', 'file', guard_inspection_document) diff --git a/streetup/assets/models/inspection.py b/streetup/assets/models/inspection.py index 7cdd0c0..7d47827 100644 --- a/streetup/assets/models/inspection.py +++ b/streetup/assets/models/inspection.py @@ -316,5 +316,12 @@ class InspectionDocument(models.Model): import os return os.path.basename(self.file.name) if self.file else "" + @property + def file_url(self): + if not self.file: + return "" + from common.private_files.helpers import build_private_url + return build_private_url('assets', 'InspectionDocument', self.pk, 'file') + def __str__(self): return f"Document {self.filename} (Inspection #{self.inspection_id})" diff --git a/streetup/assets/static/assets/inspection_map.js b/streetup/assets/static/assets/inspection_map.js index cc658e1..3c39cef 100644 --- a/streetup/assets/static/assets/inspection_map.js +++ b/streetup/assets/static/assets/inspection_map.js @@ -134,6 +134,13 @@ // Clic sur un asset map.on('click', ['assets-polygons', 'assets-points'], function(e) { + // Si un marker d'inspection est présent à cet endroit, ne pas ouvrir la modale directement + if (map.getLayer('inspections-history-points')) { + const inspFeatures = map.queryRenderedFeatures(e.point, { layers: ['inspections-history-points'] }); + if (inspFeatures && inspFeatures.length > 0) { + return; + } + } if (e.features && e.features.length > 0) { const feature = e.features[0]; handleAssetClick(feature); @@ -148,8 +155,158 @@ map.getCanvas().style.cursor = ''; }); - // Charger les assets au premier affichage + // Source GeoJSON pour l'historique des inspections + map.addSource('inspections-history-source', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + + // Placer la couche d'inspections EN DESSOUS des couches d'assets + let beforeLayerId = undefined; + if (map.getLayer('assets-polygons')) { + beforeLayerId = 'assets-polygons'; + } else if (map.getLayer('assets-points')) { + beforeLayerId = 'assets-points'; + } + + // Couche Points pour les inspections réalisées sur la carte + map.addLayer({ + id: 'inspections-history-points', + type: 'circle', + source: 'inspections-history-source', + paint: { + 'circle-radius': 9, + 'circle-color': [ + 'match', + ['get', 'latest_result_status'], + 'compliant', '#198754', + 'non_compliant', '#fd7e14', + 'damaged', '#dc3545', + 'missing', '#212529', + 'updated', '#0dcaf0', + '#6c757d' + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.85 + } + }, beforeLayerId); + + // Clic sur un marker d'inspection de la carte + map.on('click', 'inspections-history-points', function(e) { + if (e.originalEvent) { + e.originalEvent.stopPropagation(); + if (e.originalEvent.preventDefault) e.originalEvent.preventDefault(); + } + if (!e.features || e.features.length === 0) return; + + const feat = e.features[0]; + let props = feat.properties; + let inspectionsList = []; + if (typeof props.inspections === 'string') { + try { inspectionsList = JSON.parse(props.inspections); } catch(err){} + } else if (Array.isArray(props.inspections)) { + inspectionsList = props.inspections; + } + + const targetDisplay = props.target_display || 'Équipement'; + const assetProps = { + content_type_id: props.content_type_id, + object_id: props.object_id, + model_name: props.model_name, + spot_type: props.spot_type, + target_display: targetDisplay + }; + + function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + let html = ` +
+
+ ${escapeHtml(targetDisplay)} + ${props.inspections_count || inspectionsList.length} +
+ + + +
+ Inspections (${inspectionsList.length}) : +
+
+ `; + + inspectionsList.forEach((insp, index) => { + html += ` +
+
+ ${escapeHtml(insp.result_status_display || '')} + + ${escapeHtml(insp.date || '')} + +
+ ${insp.details_summary ? `
${escapeHtml(insp.details_summary)}
` : ''} +
+ ${escapeHtml(insp.inspector_name || '-')} + ${insp.mobile_asset_url ? `Fiche asset` : ''} +
+
+ `; + }); + + html += ` +
+
+ `; + + const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: true }) + .setLngLat(e.lngLat) + .setHTML(html) + .addTo(map); + + setTimeout(function() { + const btnNew = document.getElementById('btn-popup-create-inspection'); + if (btnNew) { + btnNew.addEventListener('click', function(ev) { + if (ev) ev.stopPropagation(); + popup.remove(); + openInspectionModal(assetProps); + }); + } + + const dateLinks = document.querySelectorAll('.insp-date-modal-link'); + dateLinks.forEach(link => { + link.addEventListener('click', function(ev) { + if (ev) { + ev.preventDefault(); + ev.stopPropagation(); + } + const idx = parseInt(this.getAttribute('data-insp-index'), 10); + const inspData = inspectionsList[idx]; + if (inspData) { + popup.remove(); + showInspectionDetailModal(inspData); + } + }); + }); + }, 50); + }); + + map.on('mouseenter', 'inspections-history-points', function() { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'inspections-history-points', function() { + map.getCanvas().style.cursor = ''; + }); + + // Charger les assets et l'historique d'inspections au premier affichage loadAssets(); + loadInspectionsHistory(); setupOverlayResizeObserver(); }); @@ -216,6 +373,7 @@ if (moveendTimeout) clearTimeout(moveendTimeout); moveendTimeout = setTimeout(function() { loadAssets(); + loadInspectionsHistory(); }, 400); } @@ -346,6 +504,7 @@ updateUIElements(code, ''); loadAssets(); + loadInspectionsHistory(); }); }); @@ -360,6 +519,7 @@ updateUIElements(currentThematic, id || ''); loadAssets(); + loadInspectionsHistory(); }); }); @@ -370,6 +530,7 @@ updateUIElements(thematicSelect.value, ''); localStorage.removeItem(STORAGE_CATEGORY_KEY); loadAssets(); + loadInspectionsHistory(); }); } @@ -380,8 +541,114 @@ localStorage.setItem(STORAGE_CATEGORY_KEY, categorySelect.value); updateUIElements(thematic, categorySelect.value); loadAssets(); + loadInspectionsHistory(); }); } + + // Initialisation de la période d'historique des inspections + const periodItems = document.querySelectorAll('.insp-period-item'); + periodItems.forEach(item => { + item.addEventListener('click', function() { + const period = this.getAttribute('data-period') || '3m'; + localStorage.setItem(STORAGE_HISTORY_PERIOD_KEY, period); + updateHistoryUIElements(period); + loadInspectionsHistory(); + }); + }); + + const savedPeriod = localStorage.getItem(STORAGE_HISTORY_PERIOD_KEY) || '3m'; + updateHistoryUIElements(savedPeriod); + } + + const STORAGE_HISTORY_PERIOD_KEY = 'streetup_insp_history_period'; + + function getHistoryPeriodLabels() { + return { + 'disabled': 'Inspections (Désactivé)', + '1m': 'Inspections (1 mois)', + '3m': 'Inspections (3 mois)', + '6m': 'Inspections (6 mois)', + '1y': 'Inspections (1 an)', + 'all': 'Inspections (Tout)' + }; + } + + function updateHistoryUIElements(period) { + const periodItems = document.querySelectorAll('.insp-period-item'); + const labels = getHistoryPeriodLabels(); + const iconEl = document.getElementById('inspections-history-icon'); + const labelEl = document.getElementById('inspections-history-label'); + + periodItems.forEach(item => { + const p = item.getAttribute('data-period'); + const check = item.querySelector('.period-check'); + if (p === period) { + item.classList.add('active'); + if (check) check.classList.remove('d-none'); + } else { + item.classList.remove('active'); + if (check) check.classList.add('d-none'); + } + }); + + if (labelEl) labelEl.textContent = labels[period] || 'Inspections (3 mois)'; + if (iconEl) { + if (period === 'disabled') { + iconEl.className = 'bi bi-eye-slash text-muted me-1'; + } else { + iconEl.className = 'bi bi-clock-history text-primary me-1'; + } + } + } + + function loadInspectionsHistory() { + if (!map) return; + const period = localStorage.getItem(STORAGE_HISTORY_PERIOD_KEY) || '3m'; + + updateHistoryUIElements(period); + + const source = map.getSource('inspections-history-source'); + if (!source) return; + + if (period === 'disabled') { + source.setData({ type: 'FeatureCollection', features: [] }); + return; + } + + const currentZoom = (typeof map.getZoom === 'function') ? map.getZoom() : 0; + if (currentZoom < MIN_ZOOM_FOR_GEOJSON) { + source.setData({ type: 'FeatureCollection', features: [] }); + return; + } + + const thematicSelect = document.getElementById('filter-thematic'); + const thematic = thematicSelect ? thematicSelect.value : ''; + + const categorySelect = document.getElementById('filter-category'); + const categoryId = categorySelect ? categorySelect.value : ''; + + const payload = { + thematic: thematic, + category: categoryId, + period: period, + filter_geom: getMapBbox() + }; + + fetch('/assets/inspections/geojson/', { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + } + }) + .then(res => res.ok ? res.json() : null) + .then(data => { + if (data && data.features) { + source.setData(data); + } + }) + .catch(err => console.warn('Erreur chargement GeoJSON inspections:', err)); } const MIN_ZOOM_FOR_GEOJSON = 16; @@ -496,6 +763,183 @@ openInspectionModal(props); } + function openFullscreenPhotoCarousel(images, startIndex) { + const overlay = document.getElementById('fullscreenPhotoOverlay'); + const fsInner = document.getElementById('fullscreen-carousel-inner'); + const fsIndicators = document.getElementById('fullscreen-carousel-indicators'); + if (!overlay || !fsInner) return; + + fsInner.innerHTML = ''; + if (fsIndicators) fsIndicators.innerHTML = ''; + + images.forEach((doc, idx) => { + const activeClass = idx === startIndex ? 'active' : ''; + const item = document.createElement('div'); + item.className = `carousel-item ${activeClass} w-100 h-100`; + item.innerHTML = ` +
+ ${doc.filename} +
${doc.filename}
+
+ `; + fsInner.appendChild(item); + + if (fsIndicators && images.length > 1) { + const ind = document.createElement('button'); + ind.type = 'button'; + ind.setAttribute('data-bs-target', '#fullscreenPhotoCarousel'); + ind.setAttribute('data-bs-slide-to', idx.toString()); + if (idx === startIndex) { + ind.className = 'active'; + ind.setAttribute('aria-current', 'true'); + } + ind.setAttribute('aria-label', `Photo ${idx + 1}`); + fsIndicators.appendChild(ind); + } + }); + + overlay.classList.remove('d-none'); + + const btnClose = document.getElementById('btn-close-photo-overlay'); + if (btnClose) { + const closeHandler = function(e) { + if (e) e.preventDefault(); + overlay.classList.add('d-none'); + btnClose.removeEventListener('click', closeHandler); + }; + btnClose.addEventListener('click', closeHandler); + } + + const escHandler = function(e) { + if (e.key === 'Escape') { + overlay.classList.add('d-none'); + document.removeEventListener('keydown', escHandler); + } + }; + document.addEventListener('keydown', escHandler); + } + + function showInspectionDetailModal(insp) { + const modalEl = document.getElementById('mapInspectionDetailModal'); + if (!modalEl) return; + + const assetLink = document.getElementById('map-insp-asset-link'); + const assetLinkContainer = document.getElementById('map-insp-asset-link-container'); + if (insp.mobile_asset_url && assetLink) { + assetLink.href = insp.mobile_asset_url; + if (assetLinkContainer) assetLinkContainer.classList.remove('d-none'); + } else if (assetLinkContainer) { + assetLinkContainer.classList.add('d-none'); + } + + document.getElementById('map-insp-target-display').textContent = insp.target_display || '-'; + document.getElementById('map-insp-date').textContent = insp.date || '-'; + + const badgeEl = document.getElementById('map-insp-status-badge'); + if (badgeEl) { + badgeEl.className = 'badge ' + (insp.status_badge_class || 'bg-primary'); + badgeEl.textContent = insp.result_status_display || '-'; + } + + document.getElementById('map-insp-nature').textContent = insp.nature_display || '-'; + const inspText = (insp.inspector_name || '-') + (insp.inspector_role_display ? ' (' + insp.inspector_role_display + ')' : ''); + document.getElementById('map-insp-inspector').textContent = inspText; + + document.getElementById('map-insp-summary').textContent = insp.details_summary || 'Aucun constat spécifique.'; + const notesEl = document.getElementById('map-insp-notes'); + if (notesEl) { + if (insp.notes) { + notesEl.textContent = insp.notes; + notesEl.classList.remove('d-none'); + } else { + notesEl.classList.add('d-none'); + } + } + + const carouselContainer = document.getElementById('mapInspectionPhotosCarouselContainer'); + const carouselInner = document.getElementById('map-insp-carousel-inner'); + const carouselIndicators = document.getElementById('map-insp-carousel-indicators'); + const photosCount = document.getElementById('map-insp-photos-count'); + + const docsSection = document.getElementById('map-insp-docs-section'); + const docsContainer = document.getElementById('map-insp-docs-container'); + + if (insp.documents && insp.documents.length > 0) { + const images = insp.documents.filter(d => d.is_image); + const otherDocs = insp.documents.filter(d => !d.is_image); + + if (images.length > 0 && carouselContainer && carouselInner) { + carouselInner.innerHTML = ''; + if (carouselIndicators) carouselIndicators.innerHTML = ''; + if (photosCount) photosCount.textContent = images.length; + + images.forEach((doc, idx) => { + const activeClass = idx === 0 ? 'active' : ''; + const item = document.createElement('div'); + item.className = `carousel-item ${activeClass}`; + item.innerHTML = ` +
+ ${doc.filename} + +
+ `; + carouselInner.appendChild(item); + + if (carouselIndicators) { + const ind = document.createElement('button'); + ind.type = 'button'; + ind.setAttribute('data-bs-target', '#mapInspectionPhotosCarousel'); + ind.setAttribute('data-bs-slide-to', idx.toString()); + if (idx === 0) { + ind.className = 'active'; + ind.setAttribute('aria-current', 'true'); + } + ind.setAttribute('aria-label', `Photo ${idx + 1}`); + carouselIndicators.appendChild(ind); + } + }); + + setTimeout(function() { + const triggers = carouselInner.querySelectorAll('.insp-carousel-trigger, .insp-carousel-fullscreen-btn'); + triggers.forEach(trig => { + trig.addEventListener('click', function(e) { + if (e) e.preventDefault(); + const idx = parseInt(this.getAttribute('data-img-index'), 10) || 0; + openFullscreenPhotoCarousel(images, idx); + }); + }); + }, 50); + + carouselContainer.classList.remove('d-none'); + } else if (carouselContainer) { + carouselContainer.classList.add('d-none'); + } + + if (otherDocs.length > 0 && docsSection && docsContainer) { + docsContainer.innerHTML = ''; + otherDocs.forEach(doc => { + const btn = document.createElement('a'); + btn.href = doc.url; + btn.target = '_blank'; + btn.className = 'btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1'; + btn.innerHTML = `${doc.filename}`; + docsContainer.appendChild(btn); + }); + docsSection.classList.remove('d-none'); + } else if (docsSection) { + docsSection.classList.add('d-none'); + } + } else { + if (carouselContainer) carouselContainer.classList.add('d-none'); + if (docsSection) docsSection.classList.add('d-none'); + } + + const modal = new bootstrap.Modal(modalEl); + modal.show(); + } + window.openInspectionModal = function(props) { const modalEl = document.getElementById('quickInspectionModal'); if (!modalEl) return; @@ -504,13 +948,14 @@ if (!ctId && (props.model_name === 'parkingspot' || props.spot_type || props.asset_type === 'parkingspot' || props.thematic === 'parking')) { ctId = window.PARKING_SPOT_CT_ID; } + const objId = props.object_id || props.id || props.pk || ''; document.getElementById('insp_content_type_id').value = ctId || ''; - document.getElementById('insp_object_id').value = props.id || props.pk; + document.getElementById('insp_object_id').value = objId; document.getElementById('insp_target_type').textContent = props.asset_type_display || props.model_name || 'Asset'; - document.getElementById('insp_target_code').textContent = props.code || ('#' + (props.id || props.pk)); - document.getElementById('insp_target_name').textContent = props.name || props.name_fr || '-'; - document.getElementById('insp_target_status').textContent = props.status_display || props.status || '-'; + document.getElementById('insp_target_code').textContent = props.target_display || props.code || (objId ? ('#' + objId) : '-'); + document.getElementById('insp_target_name').textContent = props.name || props.name_fr || props.target_display || '-'; + document.getElementById('insp_target_status').textContent = props.latest_result_status_display || props.status_display || props.status || '-'; const parkingFields = document.getElementById('parkingSpotSpecificFields'); if (parkingFields) { diff --git a/streetup/assets/templates/assets/components/inspection_form_modal.html b/streetup/assets/templates/assets/components/inspection_form_modal.html index 77990d5..588aca6 100644 --- a/streetup/assets/templates/assets/components/inspection_form_modal.html +++ b/streetup/assets/templates/assets/components/inspection_form_modal.html @@ -82,6 +82,32 @@ + +
+
+ {% translate "Photos & Fichiers" %} +
+
+
+ + +
+ + + + + + +
+
+
+
{% translate "Contrôles spécifiques stationnement" %} @@ -120,32 +146,6 @@
- - -
-
- {% translate "Photos & Fichiers" %} -
-
-
- - -
- - - - - - -
-
-
{% endif %} - {% if insp.documents.all %} + {% if insp.documents.exists %}
- {% translate "Photos & Documents joints" %} ({{ insp.documents.count }}) + {% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
+ + {% with docs=insp.documents.all %} + + + +
- {% for doc in insp.documents.all %} - {% if doc.is_image %} - - {{ doc.filename }} - - {% else %} - + {% for doc in docs %} + {% if not doc.is_image %} + {{ doc.filename }} {% endif %} {% endfor %}
+ {% endwith %}
{% endif %} @@ -269,4 +296,102 @@ {% endif %} + + +
+ + + + + +
+ + {% endblock %} diff --git a/streetup/assets/templates/assets/quick_inspection_map.html b/streetup/assets/templates/assets/quick_inspection_map.html index 42a1334..d8da8a0 100644 --- a/streetup/assets/templates/assets/quick_inspection_map.html +++ b/streetup/assets/templates/assets/quick_inspection_map.html @@ -125,6 +125,27 @@ .filter-btn-pill:active { transform: scale(0.97); } + .maplibregl-popup { + max-width: 330px !important; + } + .maplibregl-popup-content { + border-radius: 0.75rem !important; + padding: 0.75rem !important; + box-shadow: 0 4px 18px rgba(0,0,0,0.18) !important; + } + #mapInspectionPhotosCarousel .carousel-control-prev, + #mapInspectionPhotosCarousel .carousel-control-next, + #fullscreenPhotoCarousel .carousel-control-prev, + #fullscreenPhotoCarousel .carousel-control-next { + width: 10% !important; + pointer-events: none; + } + #mapInspectionPhotosCarousel .carousel-control-prev-icon, + #mapInspectionPhotosCarousel .carousel-control-next-icon, + #fullscreenPhotoCarousel .carousel-control-prev-icon, + #fullscreenPhotoCarousel .carousel-control-next-icon { + pointer-events: auto; + } {% endblock %} @@ -154,6 +175,13 @@ {% translate "Catégorie" %} + + + @@ -175,7 +203,7 @@ @@ -251,6 +279,150 @@ + + + + + + + +
+ + + + + +
+ {% include "assets/components/inspection_form_modal.html" %} diff --git a/streetup/assets/tests_inspections_views.py b/streetup/assets/tests_inspections_views.py index 67ec5d5..da18b44 100644 --- a/streetup/assets/tests_inspections_views.py +++ b/streetup/assets/tests_inspections_views.py @@ -143,3 +143,27 @@ class InspectionViewsTestCase(TestCase): doc = inspection.documents.first() self.assertTrue(doc.is_image) self.assertTrue(doc.filename.startswith('test_inspection')) + + def test_inspections_geojson_view(self): + from django.contrib.gis.geos import Polygon + self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326) + self.spot.save() + + insp = AssetInspection.objects.create( + asset_content_type=self.spot_ct, + asset_object_id=self.spot.id, + inspector=self.user, + result_status='compliant', + nature='visual' + ) + + response = self.client.post( + reverse('assets:inspections_geojson'), + data={'thematic': 'parking', 'period': '3m'}, + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['type'], 'FeatureCollection') + self.assertEqual(data['features'][0]['properties']['inspections'][0]['id'], insp.id) + self.assertEqual(data['features'][0]['properties']['inspections_count'], 1) diff --git a/streetup/assets/urls.py b/streetup/assets/urls.py index 96d6abb..f96fb0d 100644 --- a/streetup/assets/urls.py +++ b/streetup/assets/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path("inspections/", inspections.inspections_list, name="inspections_list"), path("inspections/new/", inspections.quick_inspection_map, name="quick_inspection_map"), path("inspections/create/", inspections.create_inspection_api, name="create_inspection_api"), + path("inspections/geojson/", inspections.inspections_geojson, name="inspections_geojson"), path("parking/", parking.parking_index, name="parking_index"), path("parking/assets/", parking.parking_assets, name="parking_assets"), path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"), diff --git a/streetup/assets/views/inspections.py b/streetup/assets/views/inspections.py index feabe3f..4d8428c 100644 --- a/streetup/assets/views/inspections.py +++ b/streetup/assets/views/inspections.py @@ -226,3 +226,160 @@ def create_inspection_api(request): }) except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=500) + + +@login_required +@require_POST +def inspections_geojson(request): + """ + Retourne la collection GeoJSON des inspections existantes filtrées par thématique, catégorie, période et emprise cartographique. + Reprojection (ST_Transform), calcul de centroïde (ST_Centroid) et conversion GeoJSON (ST_AsGeoJSON) effectuées nativement par PostGIS. + """ + import json + from datetime import timedelta + from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON + from django.contrib.gis.geos import GEOSGeometry + + try: + data = json.loads(request.body) + except Exception: + data = {} + + thematic_code = data.get('thematic', '') + category_id = data.get('category') + if isinstance(category_id, list) and category_id: + category_id = category_id[0] + period = data.get('period', '3m') + + if period == 'disabled': + return JsonResponse({'type': 'FeatureCollection', 'features': []}) + + qs = AssetInspection.objects.all().select_related('asset_content_type', 'inspector').prefetch_related('documents') + + # Filtre par période temporelle + now = timezone.now() + if period == '1m': + qs = qs.filter(inspection_date__gte=now - timedelta(days=30)) + elif period == '3m': + qs = qs.filter(inspection_date__gte=now - timedelta(days=90)) + elif period == '6m': + qs = qs.filter(inspection_date__gte=now - timedelta(days=180)) + elif period == '1y': + qs = qs.filter(inspection_date__gte=now - timedelta(days=365)) + + # Récupération de la géométrie d'emprise BBOX 4326 + bbox_geom = None + filter_geom_data = data.get('filter_geom') + if filter_geom_data: + try: + geom_json = filter_geom_data.get('geometry', filter_geom_data) + bbox_geom = GEOSGeometry(json.dumps(geom_json), srid=4326) + except Exception: + pass + + # Identification des ContentTypes cibles + ct_ids = set() + if thematic_code: + try: + thematic = Thematic.objects.get(code=thematic_code) + categories = AssetCategory.objects.filter(thematic=thematic) + if category_id: + categories = categories.filter(id=category_id) + ct_ids = set(categories.values_list('allowed_models', flat=True).distinct()) + except Thematic.DoesNotExist: + pass + + if not ct_ids: + ct_ids = set(qs.values_list('asset_content_type_id', flat=True).distinct()) + + features = [] + + # Pour chaque ContentType d'asset, requêter PostGIS pour ST_Transform, ST_Centroid & ST_AsGeoJSON + for ct_id in ct_ids: + if not ct_id: + continue + try: + ct = ContentType.objects.get(id=ct_id) + ModelClass = ct.model_class() + if not ModelClass or not hasattr(ModelClass, 'geom'): + continue + + asset_qs = ModelClass.objects.filter(geom__isnull=False) + if bbox_geom: + asset_qs = asset_qs.filter(geom__intersects=bbox_geom) + + # Traitement natif par PostGIS: ST_AsGeoJSON(ST_Transform(ST_Centroid(geom), 4326)) + asset_qs = asset_qs.annotate( + wgs84_json=AsGeoJSON(Transform(Centroid('geom'), 4326)) + ) + + asset_geom_map = dict(asset_qs.values_list('id', 'wgs84_json')) + if not asset_geom_map: + continue + + ct_inspections = qs.filter( + asset_content_type=ct, + asset_object_id__in=asset_geom_map.keys() + ).order_by('-inspection_date') + + # Regroupement des inspections par asset (clé object_id) + asset_inspections_dict = {} + for insp in ct_inspections: + asset_inspections_dict.setdefault(insp.asset_object_id, []).append(insp) + + for asset_id, insp_list in asset_inspections_dict.items(): + geojson_str = asset_geom_map.get(asset_id) + if not geojson_str or not insp_list: + continue + + latest_insp = insp_list[0] + target_obj = latest_insp.asset + + inspections_data = [] + for insp in insp_list: + docs_data = [] + for doc in insp.documents.all(): + docs_data.append({ + 'id': doc.id, + 'url': doc.file_url if doc.file else '', + 'filename': doc.filename, + 'is_image': doc.is_image + }) + + inspections_data.append({ + 'id': insp.id, + 'date': insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '', + 'result_status': insp.result_status, + 'result_status_display': insp.get_result_status_display(), + 'status_badge_class': insp.status_badge_class, + 'nature_display': insp.get_nature_display(), + 'inspector_name': insp.inspector.get_full_name() if insp.inspector else '-', + 'inspector_role_display': insp.get_inspector_role_display() if insp.inspector_role else '', + 'details_summary': insp.details_summary, + 'notes': insp.notes or '', + 'target_display': insp.target_display, + 'mobile_asset_url': insp.mobile_asset_url, + 'documents': docs_data, + }) + + feature = { + 'type': 'Feature', + 'geometry': json.loads(geojson_str), + 'properties': { + 'content_type_id': ct.id, + 'object_id': asset_id, + 'model_name': ct.model, + 'spot_type': getattr(target_obj, 'spot_type', None) if target_obj else None, + 'target_display': latest_insp.target_display, + 'latest_result_status': latest_insp.result_status, + 'latest_result_status_display': latest_insp.get_result_status_display(), + 'latest_status_badge_class': latest_insp.status_badge_class, + 'inspections_count': len(inspections_data), + 'inspections': inspections_data, + } + } + features.append(feature) + except Exception: + continue + + return JsonResponse({'type': 'FeatureCollection', 'features': features[:1000]})