feat: add configurable map inspection history overlay and detail modal

This commit is contained in:
kdeterme 2026-08-07 18:54:39 +02:00
parent adaf19ab39
commit 8c85719e45
9 changed files with 986 additions and 47 deletions

View file

@ -47,3 +47,11 @@ class AssetsConfig(AppConfig):
).exists() ).exists()
register('assets', 'TrafficLightControllerLogFile', 'file', guard_tl_controller_log) 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)

View file

@ -316,5 +316,12 @@ class InspectionDocument(models.Model):
import os import os
return os.path.basename(self.file.name) if self.file else "" 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): def __str__(self):
return f"Document {self.filename} (Inspection #{self.inspection_id})" return f"Document {self.filename} (Inspection #{self.inspection_id})"

View file

@ -134,6 +134,13 @@
// Clic sur un asset // Clic sur un asset
map.on('click', ['assets-polygons', 'assets-points'], function(e) { 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) { if (e.features && e.features.length > 0) {
const feature = e.features[0]; const feature = e.features[0];
handleAssetClick(feature); handleAssetClick(feature);
@ -148,8 +155,158 @@
map.getCanvas().style.cursor = ''; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
let html = `
<div class="p-1" style="width: 300px; max-width: 310px; font-size: 0.85rem;">
<div class="fw-bold text-dark border-bottom pb-1 mb-2 d-flex align-items-center justify-content-between">
<span class="text-truncate me-2">${escapeHtml(targetDisplay)}</span>
<span class="badge bg-secondary">${props.inspections_count || inspectionsList.length}</span>
</div>
<button type="button" class="btn btn-sm btn-primary w-100 mb-2 py-1.5 fw-semibold d-flex align-items-center justify-content-center gap-1 shadow-sm" id="btn-popup-create-inspection">
<i class="bi bi-plus-circle-fill"></i>
<span>Nouvelle inspection</span>
</button>
<div class="fw-semibold text-muted mb-1 extra-small">
<i class="bi bi-clock-history me-1 text-primary"></i>Inspections (${inspectionsList.length}) :
</div>
<div class="list-group list-group-flush overflow-auto pe-1" style="max-height: 190px;">
`;
inspectionsList.forEach((insp, index) => {
html += `
<div class="list-group-item px-2 py-1.5 border-bottom bg-light rounded mb-1">
<div class="d-flex justify-content-between align-items-center mb-1 gap-2">
<span class="badge ${insp.status_badge_class || 'bg-primary'} extra-small text-truncate flex-shrink-1">${escapeHtml(insp.result_status_display || '')}</span>
<a href="#" class="insp-date-modal-link text-primary text-decoration-underline fw-bold extra-small text-nowrap flex-shrink-0" style="font-size:0.75rem;" data-insp-index="${index}">
<i class="bi bi-calendar-event me-1"></i>${escapeHtml(insp.date || '')}
</a>
</div>
${insp.details_summary ? `<div class="extra-small text-dark mb-1 fw-medium" style="font-size:0.75rem;">${escapeHtml(insp.details_summary)}</div>` : ''}
<div class="d-flex justify-content-between align-items-center extra-small text-muted" style="font-size:0.7rem;">
<span><i class="bi bi-person me-1"></i>${escapeHtml(insp.inspector_name || '-')}</span>
${insp.mobile_asset_url ? `<a href="${insp.mobile_asset_url}" class="text-primary text-decoration-none fw-semibold"><i class="bi bi-box-arrow-up-right me-1"></i>Fiche asset</a>` : ''}
</div>
</div>
`;
});
html += `
</div>
</div>
`;
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(); loadAssets();
loadInspectionsHistory();
setupOverlayResizeObserver(); setupOverlayResizeObserver();
}); });
@ -216,6 +373,7 @@
if (moveendTimeout) clearTimeout(moveendTimeout); if (moveendTimeout) clearTimeout(moveendTimeout);
moveendTimeout = setTimeout(function() { moveendTimeout = setTimeout(function() {
loadAssets(); loadAssets();
loadInspectionsHistory();
}, 400); }, 400);
} }
@ -346,6 +504,7 @@
updateUIElements(code, ''); updateUIElements(code, '');
loadAssets(); loadAssets();
loadInspectionsHistory();
}); });
}); });
@ -360,6 +519,7 @@
updateUIElements(currentThematic, id || ''); updateUIElements(currentThematic, id || '');
loadAssets(); loadAssets();
loadInspectionsHistory();
}); });
}); });
@ -370,6 +530,7 @@
updateUIElements(thematicSelect.value, ''); updateUIElements(thematicSelect.value, '');
localStorage.removeItem(STORAGE_CATEGORY_KEY); localStorage.removeItem(STORAGE_CATEGORY_KEY);
loadAssets(); loadAssets();
loadInspectionsHistory();
}); });
} }
@ -380,8 +541,114 @@
localStorage.setItem(STORAGE_CATEGORY_KEY, categorySelect.value); localStorage.setItem(STORAGE_CATEGORY_KEY, categorySelect.value);
updateUIElements(thematic, categorySelect.value); updateUIElements(thematic, categorySelect.value);
loadAssets(); 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; const MIN_ZOOM_FOR_GEOJSON = 16;
@ -496,6 +763,183 @@
openInspectionModal(props); 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 = `
<div class="w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3">
<img src="${doc.url}" class="img-fluid mh-100 mw-100 shadow-lg" style="object-fit: contain; max-height: 85vh;" alt="${doc.filename}">
<div class="text-white small mt-2 bg-dark bg-opacity-75 px-3 py-1 rounded-pill">${doc.filename}</div>
</div>
`;
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 = `
<div class="d-flex align-items-center justify-content-center bg-black position-relative" style="height: 280px;">
<img src="${doc.url}" class="d-block img-fluid mh-100 mw-100 insp-carousel-trigger" style="object-fit: contain; max-height: 280px; cursor: pointer;" alt="${doc.filename}" data-img-index="${idx}">
<button type="button" class="btn btn-sm btn-dark bg-opacity-75 text-white rounded-circle p-1.5 shadow insp-carousel-fullscreen-btn position-absolute bottom-0 end-0 m-2" style="z-index: 10; cursor: pointer;" data-img-index="${idx}" title="Plein écran">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
`;
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 = `<i class="bi bi-file-earmark-arrow-down text-primary"></i><span class="text-truncate" style="max-width: 180px;">${doc.filename}</span>`;
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) { window.openInspectionModal = function(props) {
const modalEl = document.getElementById('quickInspectionModal'); const modalEl = document.getElementById('quickInspectionModal');
if (!modalEl) return; if (!modalEl) return;
@ -504,13 +948,14 @@
if (!ctId && (props.model_name === 'parkingspot' || props.spot_type || props.asset_type === 'parkingspot' || props.thematic === 'parking')) { if (!ctId && (props.model_name === 'parkingspot' || props.spot_type || props.asset_type === 'parkingspot' || props.thematic === 'parking')) {
ctId = window.PARKING_SPOT_CT_ID; 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_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_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_code').textContent = props.target_display || props.code || (objId ? ('#' + objId) : '-');
document.getElementById('insp_target_name').textContent = props.name || props.name_fr || '-'; document.getElementById('insp_target_name').textContent = props.name || props.name_fr || props.target_display || '-';
document.getElementById('insp_target_status').textContent = props.status_display || props.status || '-'; document.getElementById('insp_target_status').textContent = props.latest_result_status_display || props.status_display || props.status || '-';
const parkingFields = document.getElementById('parkingSpotSpecificFields'); const parkingFields = document.getElementById('parkingSpotSpecificFields');
if (parkingFields) { if (parkingFields) {

View file

@ -82,6 +82,32 @@
</div> </div>
</div> </div>
<!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div>
<div class="card-body p-3">
<div class="d-grid gap-2">
<button type="button" id="insp_btn_continuous_camera" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-camera me-1"></i>
<span>{% translate "Prendre des photos (mode continu)" %}</span>
</button>
<button type="button" id="insp_btn_gallery_files" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-folder2-open me-1"></i>
<span>{% translate "Photos, vidéos, fichiers" %}</span>
</button>
</div>
<!-- Inputs masqués -->
<input type="file" id="insp_camera_input" accept="image/*" capture="environment" class="d-none">
<input type="file" id="insp_gallery_input" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" multiple class="d-none">
<!-- Grille de prévisualisation des photos/fichiers accumulés -->
<div id="insp_photos_preview_container" class="row g-2 mt-2"></div>
</div>
</div>
<!-- Champs spécifiques ParkingSpot (masqués par défaut) --> <!-- Champs spécifiques ParkingSpot (masqués par défaut) -->
<div id="parkingSpotSpecificFields" class="d-none border rounded p-2 mb-3 bg-white"> <div id="parkingSpotSpecificFields" class="d-none border rounded p-2 mb-3 bg-white">
<span class="small fw-bold text-secondary mb-2 d-block"><i class="bi bi-p-square me-1"></i>{% translate "Contrôles spécifiques stationnement" %}</span> <span class="small fw-bold text-secondary mb-2 d-block"><i class="bi bi-p-square me-1"></i>{% translate "Contrôles spécifiques stationnement" %}</span>
@ -120,32 +146,6 @@
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label> <label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea> <textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div> </div>
<!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div>
<div class="card-body p-3">
<div class="d-grid gap-2">
<button type="button" id="insp_btn_continuous_camera" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-camera me-1"></i>
<span>{% translate "Prendre des photos (mode continu)" %}</span>
</button>
<button type="button" id="insp_btn_gallery_files" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-folder2-open me-1"></i>
<span>{% translate "Photos, vidéos, fichiers" %}</span>
</button>
</div>
<!-- Inputs masqués -->
<input type="file" id="insp_camera_input" accept="image/*" capture="environment" class="d-none">
<input type="file" id="insp_gallery_input" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" multiple class="d-none">
<!-- Grille de prévisualisation des photos/fichiers accumulés -->
<div id="insp_photos_preview_container" class="row g-2 mt-2"></div>
</div>
</div>
</div> </div>
<div class="modal-footer bg-light"> <div class="modal-footer bg-light">

View file

@ -188,12 +188,6 @@
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span> <span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector.get_full_name|default:insp.inspector.username|default:"-" }}</span> <span class="fw-semibold">{{ insp.inspector.get_full_name|default:insp.inspector.username|default:"-" }}</span>
</li> </li>
{% if insp.inspector_role %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person-badge me-1"></i>{% translate "Rôle inspecteur" %}</span>
<span class="fw-semibold">{{ insp.get_inspector_role_display }}</span>
</li>
{% endif %}
{% with leaf=insp.detailed_inspection %} {% with leaf=insp.detailed_inspection %}
{% if leaf.verified_standard_stand_count is not None %} {% if leaf.verified_standard_stand_count is not None %}
@ -231,25 +225,58 @@
</div> </div>
{% endif %} {% endif %}
{% if insp.documents.all %} {% if insp.documents.exists %}
<div class="card bg-light border-0 p-3 mb-3"> <div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"> <h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-paperclip me-2"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}) <i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
</h6> </h6>
{% with docs=insp.documents.all %}
<!-- Carrousel Photos Bootstrap -->
<div id="carouselInsp{{ insp.id }}" class="carousel slide rounded overflow-hidden shadow-sm bg-black mb-3" data-bs-ride="false">
<div class="carousel-indicators">
{% for doc in docs %}
{% if doc.is_image %}
<button type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide-to="{{ forloop.counter0 }}" {% if forloop.first %}class="active" aria-current="true"{% endif %} aria-label="Slide {{ forloop.counter }}"></button>
{% endif %}
{% endfor %}
</div>
<div class="carousel-inner" style="max-height: 300px; min-height: 180px;">
{% for doc in docs %}
{% if doc.is_image %}
<div class="carousel-item {% if forloop.first %}active{% endif %} h-100">
<div class="d-flex align-items-center justify-content-center bg-black position-relative" style="height: 260px;">
<img src="{{ doc.file_url }}" class="d-block img-fluid mh-100 mw-100 list-insp-carousel-trigger" style="object-fit: contain; max-height: 260px; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" alt="{{ doc.filename }}">
<button type="button" class="btn btn-sm btn-dark bg-opacity-75 text-white rounded-circle p-1.5 shadow list-insp-carousel-trigger position-absolute bottom-0 end-0 m-2" style="z-index: 10; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" title="Plein écran">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="prev">
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="next">
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
<!-- Documents non-images -->
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
{% for doc in insp.documents.all %} {% for doc in docs %}
{% if doc.is_image %} {% if not doc.is_image %}
<a href="{{ doc.file.url }}" target="_blank" class="d-inline-block border rounded overflow-hidden shadow-sm position-relative" style="width: 80px; height: 80px;"> <a href="{{ doc.file_url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
<img src="{{ doc.file.url }}" alt="{{ doc.filename }}" style="width: 100%; height: 100%; object-fit: cover;">
</a>
{% else %}
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
<i class="bi bi-file-earmark-arrow-down text-primary"></i> <i class="bi bi-file-earmark-arrow-down text-primary"></i>
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span> <span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
</a> </a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% endwith %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
@ -269,4 +296,102 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Overlay Carrousel Plein Écran pour Photos (avec bouton fermer) -->
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
<!-- Bouton Fermer -->
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
<!-- Conteneur Carrousel -->
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const listTriggers = document.querySelectorAll('.list-insp-carousel-trigger');
listTriggers.forEach(trig => {
trig.addEventListener('click', function(e) {
e.preventDefault();
const inspId = this.getAttribute('data-insp-id');
const startIndex = parseInt(this.getAttribute('data-img-index'), 10) || 0;
const carouselEl = document.getElementById('carouselInsp' + inspId);
if (!carouselEl) return;
const imgs = carouselEl.querySelectorAll('img.list-insp-carousel-trigger');
const imagesData = [];
imgs.forEach(img => {
imagesData.push({
url: img.getAttribute('src'),
filename: img.getAttribute('alt') || 'Photo'
});
});
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 = '';
imagesData.forEach((doc, idx) => {
const activeClass = idx === startIndex ? 'active' : '';
const item = document.createElement('div');
item.className = `carousel-item ${activeClass} w-100 h-100`;
item.innerHTML = `
<div class="w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3">
<img src="${doc.url}" class="img-fluid mh-100 mw-100 shadow-lg" style="object-fit: contain; max-height: 85vh;" alt="${doc.filename}">
<div class="text-white small mt-2 bg-dark bg-opacity-75 px-3 py-1 rounded-pill">${doc.filename}</div>
</div>
`;
fsInner.appendChild(item);
if (fsIndicators && imagesData.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);
});
});
});
</script>
{% endblock %} {% endblock %}

View file

@ -125,6 +125,27 @@
.filter-btn-pill:active { .filter-btn-pill:active {
transform: scale(0.97); 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;
}
</style> </style>
{% endblock %} {% endblock %}
@ -154,6 +175,13 @@
<span id="category-name-display" class="fw-semibold">{% translate "Catégorie" %}</span> <span id="category-name-display" class="fw-semibold">{% translate "Catégorie" %}</span>
<i class="bi bi-chevron-down text-muted ms-1 small"></i> <i class="bi bi-chevron-down text-muted ms-1 small"></i>
</button> </button>
<!-- Bouton Déclencheur Historique Inspections -->
<button id="btn-toggle-inspections" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#inspectionsHistoryModal">
<i id="inspections-history-icon" class="bi bi-clock-history text-primary me-1"></i>
<span id="inspections-history-label" class="fw-semibold">{% translate "Inspections (3 mois)" %}</span>
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -175,7 +203,7 @@
<div id="map-warning-alert" class="alert alert-warning alert-dismissible fade show d-none shadow-sm py-2 px-3 mt-2 mb-0 small" role="alert"> <div id="map-warning-alert" class="alert alert-warning alert-dismissible fade show d-none shadow-sm py-2 px-3 mt-2 mb-0 small" role="alert">
<i class="bi bi-exclamation-triangle-fill me-1 text-warning"></i> <i class="bi bi-exclamation-triangle-fill me-1 text-warning"></i>
<span id="map-warning-text">{% translate "Veuillez zoomer sur la carte (zoom 16+) pour afficher les équipements." %}</span> <span id="map-warning-text">{% translate "Veuillez zoomer sur la carte pour afficher les équipements." %}</span>
<button type="button" class="btn-close py-2" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close py-2" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
</div> </div>
@ -251,6 +279,150 @@
</div> </div>
</div> </div>
<!-- Modal Choix Historique Inspections (Style Mobile Bottom Sheet) -->
<div class="modal fade modal-bottom-sheet" id="inspectionsHistoryModal" tabindex="-1" aria-labelledby="inspectionsHistoryModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content shadow-lg">
<div class="modal-header border-0 pb-1">
<h6 class="modal-title fw-bold" id="inspectionsHistoryModalLabel">
<i class="bi bi-clock-history text-primary me-1"></i>{% translate "Historique des inspections sur la carte" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body p-3">
<p class="text-muted small mb-3">
{% translate "Choisissez la durée d'affichage des contrôles et inspections effectués dans la zone." %}
</p>
<div class="list-group list-group-flush" id="inspections-period-options">
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 insp-period-item" data-period="disabled" data-bs-dismiss="modal">
<span class="fw-semibold small text-danger"><i class="bi bi-eye-slash me-2"></i>{% translate "Désactiver l'affichage" %}</span>
<i class="bi bi-check-lg text-primary period-check d-none"></i>
</button>
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 insp-period-item" data-period="1m" data-bs-dismiss="modal">
<span class="fw-semibold small text-dark"><i class="bi bi-calendar-check me-2 text-primary"></i>{% translate "1 mois" %}</span>
<i class="bi bi-check-lg text-primary period-check d-none"></i>
</button>
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 insp-period-item active" data-period="3m" data-bs-dismiss="modal">
<span class="fw-semibold small text-dark"><i class="bi bi-calendar-check me-2 text-primary"></i>{% translate "3 mois (par défaut)" %}</span>
<i class="bi bi-check-lg text-primary period-check"></i>
</button>
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 insp-period-item" data-period="6m" data-bs-dismiss="modal">
<span class="fw-semibold small text-dark"><i class="bi bi-calendar-check me-2 text-primary"></i>{% translate "6 mois" %}</span>
<i class="bi bi-check-lg text-primary period-check d-none"></i>
</button>
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 insp-period-item" data-period="1y" data-bs-dismiss="modal">
<span class="fw-semibold small text-dark"><i class="bi bi-calendar-check me-2 text-primary"></i>{% translate "1 an" %}</span>
<i class="bi bi-check-lg text-primary period-check d-none"></i>
</button>
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 insp-period-item" data-period="all" data-bs-dismiss="modal">
<span class="fw-semibold small text-dark"><i class="bi bi-calendar-range me-2 text-primary"></i>{% translate "Tout (historique complet)" %}</span>
<i class="bi bi-check-lg text-primary period-check d-none"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Détail d'une Inspection depuis la carte -->
<div class="modal fade" id="mapInspectionDetailModal" tabindex="-1" aria-labelledby="mapInspectionDetailModalLabel" aria-hidden="true" style="z-index: 1065;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="mapInspectionDetailModalLabel">
<i class="bi bi-clock-history me-2 text-primary"></i>{% translate "Détail de l'inspection" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
<!-- Lien vers la fiche de l'asset -->
<div class="mb-3" id="map-insp-asset-link-container">
<a href="#" id="map-insp-asset-link" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
<span class="fw-bold text-end ms-2" id="map-insp-target-display">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold" id="map-insp-date">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge bg-primary" id="map-insp-status-badge">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border" id="map-insp-nature">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold" id="map-insp-inspector">-</span>
</li>
</ul>
<!-- Remarques & Constats -->
<div class="card bg-light border-0 p-3 mb-3">
<div class="fw-semibold small text-dark mb-1"><i class="bi bi-card-text me-1 text-primary"></i>{% translate "Constats & Remarques" %}</div>
<div class="small text-muted" id="map-insp-summary">-</div>
<div class="small text-dark mt-2 d-none" id="map-insp-notes"></div>
</div>
<!-- Carrousel Photos si images présentés -->
<div id="mapInspectionPhotosCarouselContainer" class="d-none mb-3">
<div class="fw-semibold small text-dark mb-2">
<i class="bi bi-images me-1 text-primary"></i>{% translate "Galerie photos" %} (<span id="map-insp-photos-count">0</span>)
</div>
<div id="mapInspectionPhotosCarousel" class="carousel slide rounded overflow-hidden shadow-sm bg-black" data-bs-ride="false">
<div class="carousel-indicators mb-1" id="map-insp-carousel-indicators"></div>
<div class="carousel-inner" id="map-insp-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#mapInspectionPhotosCarousel" data-bs-slide="prev">
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#mapInspectionPhotosCarousel" data-bs-slide="next">
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
</div>
<!-- Section Documents non-images -->
<div id="map-insp-docs-section" class="d-none mb-2">
<div class="fw-semibold small text-dark mb-2">
<i class="bi bi-file-earmark-text me-1 text-primary"></i>{% translate "Documents joints" %}
</div>
<div class="d-flex flex-wrap gap-2" id="map-insp-docs-container"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Overlay Carrousel Plein Écran pour Photos (avec bouton fermer) -->
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
<!-- Bouton Fermer -->
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
<!-- Conteneur Carrousel -->
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
</div>
<!-- Inclure la Modale d'inspection --> <!-- Inclure la Modale d'inspection -->
{% include "assets/components/inspection_form_modal.html" %} {% include "assets/components/inspection_form_modal.html" %}

View file

@ -143,3 +143,27 @@ class InspectionViewsTestCase(TestCase):
doc = inspection.documents.first() doc = inspection.documents.first()
self.assertTrue(doc.is_image) self.assertTrue(doc.is_image)
self.assertTrue(doc.filename.startswith('test_inspection')) 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)

View file

@ -8,6 +8,7 @@ urlpatterns = [
path("inspections/", inspections.inspections_list, name="inspections_list"), path("inspections/", inspections.inspections_list, name="inspections_list"),
path("inspections/new/", inspections.quick_inspection_map, name="quick_inspection_map"), path("inspections/new/", inspections.quick_inspection_map, name="quick_inspection_map"),
path("inspections/create/", inspections.create_inspection_api, name="create_inspection_api"), 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/", parking.parking_index, name="parking_index"),
path("parking/assets/", parking.parking_assets, name="parking_assets"), path("parking/assets/", parking.parking_assets, name="parking_assets"),
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"), path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),

View file

@ -226,3 +226,160 @@ def create_inspection_api(request):
}) })
except Exception as e: except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500) 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]})