feat: add configurable map inspection history overlay and detail modal
This commit is contained in:
parent
adaf19ab39
commit
8c85719e45
9 changed files with 986 additions and 47 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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" %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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]})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue