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}
+
+
+
+
+ Nouvelle inspection
+
+
+
+
+ `;
+
+ inspectionsList.forEach((insp, index) => {
+ html += `
+
+
+
+
+
+ ${insp.details_summary ? `` : ''}
+
+
+ `;
+ });
+
+ 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}
+
+ `;
+ 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 = `
+
+
+
+
+
+
+ `;
+ 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 "Prendre des photos (mode continu)" %}
+
+
+
+ {% translate "Photos, vidéos, fichiers" %}
+
+
+
+
+
+
+
+
+
+
+
+
{% translate "Contrôles spécifiques stationnement" %}
@@ -120,32 +146,6 @@
{% translate "Remarques / Remarques du constat" %}
-
-
-
-
-
-
-
-
- {% translate "Prendre des photos (mode continu)" %}
-
-
-
- {% translate "Photos, vidéos, 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 docs %}
+ {% if doc.is_image %}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% for doc in docs %}
+ {% if doc.is_image %}
+
+
+
+
+
+
+
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+ {% endwith %}
{% endif %}
@@ -269,4 +296,102 @@
{% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+
{% 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" %}
+
+
+
+
+ {% translate "Inspections (3 mois)" %}
+
+
@@ -175,7 +203,7 @@
- {% translate "Veuillez zoomer sur la carte (zoom 16+) pour afficher les équipements." %}
+ {% translate "Veuillez zoomer sur la carte pour afficher les équipements." %}
@@ -251,6 +279,150 @@
+
+
+
+
+
+
+
+ {% translate "Choisissez la durée d'affichage des contrôles et inspections effectués dans la zone." %}
+
+
+
+ {% translate "Désactiver l'affichage" %}
+
+
+
+ {% translate "1 mois" %}
+
+
+
+ {% translate "3 mois (par défaut)" %}
+
+
+
+ {% translate "6 mois" %}
+
+
+
+ {% translate "1 an" %}
+
+
+
+ {% translate "Tout (historique complet)" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% translate "Cible" %}
+ -
+
+
+ {% translate "Date & Heure" %}
+ -
+
+
+ {% translate "Résultat du contrôle" %}
+ -
+
+
+ {% translate "Nature" %}
+ -
+
+
+ {% translate "Inspecteur" %}
+ -
+
+
+
+
+
+
{% translate "Constats & Remarques" %}
+
-
+
+
+
+
+
+
+ {% translate "Galerie photos" %} (0 )
+
+
+
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+
+
+
+ {% translate "Documents joints" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
{% 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]})