diff --git a/loko/common/migrations/0014_userconfig_inspection_list_filters_and_more.py b/loko/common/migrations/0014_userconfig_inspection_list_filters_and_more.py
new file mode 100644
index 0000000..38b7b18
--- /dev/null
+++ b/loko/common/migrations/0014_userconfig_inspection_list_filters_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.7 on 2026-08-28 12:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('common', '0013_userconfig_exceptional_transport_can_edit_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='userconfig',
+ name='inspection_list_filters',
+ field=models.JSONField(blank=True, default=dict, help_text='Préférences de filtrage pour la vue liste des inspections.', verbose_name='Filtres de la liste des inspections'),
+ ),
+ migrations.AddField(
+ model_name='userconfig',
+ name='inspection_list_sort',
+ field=models.JSONField(blank=True, default=dict, help_text='Préférences de tri pour la vue liste des inspections.', verbose_name='Tri de la liste des inspections'),
+ ),
+ ]
diff --git a/loko/common/models.py b/loko/common/models.py
index e7c9656..8c8c8dd 100644
--- a/loko/common/models.py
+++ b/loko/common/models.py
@@ -229,6 +229,18 @@ class UserConfig(models.Model):
verbose_name="Tri de la liste des contrôles",
help_text="Préférences de tri pour la vue liste des contrôles.",
)
+ inspection_list_filters = models.JSONField(
+ default=dict,
+ blank=True,
+ verbose_name="Filtres de la liste des inspections",
+ help_text="Préférences de filtrage pour la vue liste des inspections.",
+ )
+ inspection_list_sort = models.JSONField(
+ default=dict,
+ blank=True,
+ verbose_name="Tri de la liste des inspections",
+ help_text="Préférences de tri pour la vue liste des inspections.",
+ )
ASSETS_LIST_LAYOUT_CHOICES = [
('both', 'Tableau et carte'),
('table_only', 'Tableau seul'),
diff --git a/loko/common/urls.py b/loko/common/urls.py
index f5a6271..287a395 100644
--- a/loko/common/urls.py
+++ b/loko/common/urls.py
@@ -14,6 +14,7 @@ urlpatterns = [
path("save-intervention-list-preferences/", views.save_intervention_list_preferences, name="save_intervention_list_preferences"),
path("save-assets-list-layout/", views.save_assets_list_layout, name="save_assets_list_layout"),
path("save-observation-list-preferences/", views.save_observation_list_preferences, name="save_observation_list_preferences"),
+ path("save-inspection-list-preferences/", views.save_inspection_list_preferences, name="save_inspection_list_preferences"),
path("save-control-list-preferences/", views.save_control_list_preferences, name="save_control_list_preferences"),
path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"),
path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"),
diff --git a/loko/common/views.py b/loko/common/views.py
index 9b48700..71a456b 100644
--- a/loko/common/views.py
+++ b/loko/common/views.py
@@ -445,6 +445,37 @@ def save_control_list_preferences(request):
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
+@login_required
+@require_POST
+def save_inspection_list_preferences(request):
+ """API endpoint to save the user's preferred filters and sort for the inspections list."""
+ import json
+
+ try:
+ data = json.loads(request.body)
+ filters = data.get('filters', {})
+ sort = data.get('sort', {})
+ except (json.JSONDecodeError, KeyError):
+ return JsonResponse({'error': _('Données invalides')}, status=400)
+
+ try:
+ user_config = request.user.config
+
+ if not isinstance(filters, dict):
+ return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
+
+ if not isinstance(sort, dict):
+ return JsonResponse({'error': _('Format de tri invalide')}, status=400)
+
+ user_config.inspection_list_filters = filters
+ user_config.inspection_list_sort = sort
+ user_config.save(update_fields=['inspection_list_filters', 'inspection_list_sort'])
+
+ return JsonResponse({'success': True, 'filters': filters, 'sort': sort})
+ except UserConfig.DoesNotExist:
+ return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
+
+
@login_required
@require_POST
def save_assets_index_preferences(request):
diff --git a/loko/inspections/static/inspections/inspections_index.css b/loko/inspections/static/inspections/inspections_index.css
new file mode 100644
index 0000000..403b328
--- /dev/null
+++ b/loko/inspections/static/inspections/inspections_index.css
@@ -0,0 +1,199 @@
+/* Layout helpers for Inspections desktop view */
+.inspections-layout #inspections-table-container {
+ position: relative;
+ min-height: 420px;
+}
+
+#filter-dropdown-wrapper {
+ position: absolute;
+ top: 0.5rem;
+ left: 0.5rem;
+ z-index: 10;
+}
+
+.filter-dropdown {
+ min-width: min(640px, calc(100vw - 4rem));
+}
+
+.inspections-map {
+ height: calc(100vh - 210px);
+ min-height: 360px;
+ position: relative;
+ width: 100%;
+ margin-top: 50px;
+}
+
+.inspections-map-filter {
+ position: absolute;
+ top: 0.75rem;
+ right: 2.75rem;
+ z-index: 5;
+ box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
+}
+
+.popup-scrollable {
+ max-height: 240px;
+ overflow-y: auto;
+}
+
+/* Supprime le contour bleu Bootstrap au focus sur l'input de recherche */
+#inspections-search-input:focus {
+ outline: none;
+ box-shadow: none;
+ border-color: #dee2e6;
+}
+
+/* Styles pour la modale de filtre */
+#filterModal .modal-dialog {
+ max-width: 95%;
+}
+#filterModal .modal-content {
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+}
+#filterModal .modal-body {
+ flex: 1;
+ overflow-y: auto;
+}
+#filterModal .modal-footer {
+ flex-shrink: 0;
+}
+
+.filter-section {
+ margin-bottom: 1rem;
+}
+
+/* Default badge styling */
+.filter-option label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+ font-weight: 500;
+ cursor: pointer;
+ background-color: #e7f1ff;
+ color: #0c5cc9;
+ border-radius: 1rem;
+ padding: 0.4rem 0.5rem;
+ font-size: 0.75rem;
+ border: 1px solid #b8d4f1;
+ transition: all 0.2s ease;
+ width: 140px;
+ flex: 1 1 auto;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Contextual color badges - inactive state */
+.filter-option label.context-badge {
+ border-color: currentColor;
+ color: inherit;
+ background-color: transparent;
+}
+
+.filter-option label.btn-outline-primary { border-color: #0c5cc9; color: #0c5cc9; }
+.filter-option label.btn-outline-danger { border-color: #dc3545; color: #dc3545; }
+.filter-option label.btn-outline-warning { border-color: #ffc107; color: #ffc107; }
+.filter-option label.btn-outline-success { border-color: #28a745; color: #28a745; }
+.filter-option label.btn-outline-info { border-color: #17a2b8; color: #17a2b8; }
+.filter-option label.btn-outline-dark { border-color: #343a40; color: #343a40; }
+.filter-option label.btn-outline-secondary { border-color: #6c757d; color: #6c757d; }
+
+/* Default badges - checked state */
+.filter-option input[type="checkbox"]:checked + label:not(.context-badge) {
+ background-color: #0c5cc9; color: white; border-color: #0c5cc9;
+}
+
+/* Contextual badges - checked state */
+.filter-option input[type="checkbox"]:checked + label.context-badge { color: white; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-primary { background-color: #0c5cc9; border-color: #0c5cc9; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-danger { background-color: #dc3545; border-color: #dc3545; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-warning { background-color: #ffc107; border-color: #ffc107; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-success { background-color: #28a745; border-color: #28a745; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-info { background-color: #17a2b8; border-color: #17a2b8; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-dark { background-color: #343a40; border-color: #343a40; }
+.filter-option input[type="checkbox"]:checked + label.btn-outline-secondary { background-color: #6c757d; border-color: #6c757d; }
+
+/* Default badges - hover */
+.filter-option input[type="checkbox"]:hover + label:not(.context-badge) { background-color: #cfe2f3; }
+
+.filter-option.subcategory-option label {
+ font-size: 0.875em;
+ color: #555;
+}
+.filter-option-break {
+ flex-basis: 100%;
+ height: 0;
+}
+.filter-subcat-controls {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.filter-section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+ gap: 1rem;
+}
+.filter-section-header label {
+ font-weight: 600;
+ color: #333;
+ margin: 0;
+ flex-shrink: 0;
+}
+.filter-section-buttons {
+ display: flex;
+ gap: 0.5rem;
+ margin-left: auto;
+ flex-shrink: 0;
+}
+.filter-section-buttons button {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+.filter-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ min-height: 2.5rem;
+ padding: 0.5rem;
+ background-color: #f8f9fa;
+ border-radius: 0.375rem;
+ border: 1px solid #dee2e6;
+}
+
+.filter-option {
+ display: flex;
+ align-items: center;
+}
+
+.filter-option input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ cursor: pointer;
+}
+
+@media (max-width: 991.98px) {
+ .inspections-layout #filter-dropdown-wrapper {
+ position: static;
+ margin-bottom: 1rem;
+ }
+
+ .inspections-layout #inspections-table-container {
+ padding-top: 0;
+ }
+
+ .filter-dropdown {
+ width: 100%;
+ }
+}
diff --git a/loko/inspections/static/inspections/inspections_index.js b/loko/inspections/static/inspections/inspections_index.js
new file mode 100644
index 0000000..66b28bc
--- /dev/null
+++ b/loko/inspections/static/inspections/inspections_index.js
@@ -0,0 +1,1285 @@
+let inspections_geojson = {};
+let allInspections = [];
+let visibleRows = [];
+
+const MAX_ITEMS = 5000;
+const NOTES_MAX_LENGTH = 60;
+function truncateText(text, limit) {
+ if (!text || text.length <= limit) {
+ return text;
+ }
+ return `${text.substring(0, limit - 3).trim()}...`;
+}
+
+const RESULT_STATUS_METADATA = {
+ compliant: { label: gettext('Conforme'), badge: 'bg-success', color: '#198754', icon: 'bi-check-circle' },
+ non_compliant: { label: gettext('Non conforme'), badge: 'bg-warning text-dark', color: '#ffc107', icon: 'bi-exclamation-circle' },
+ damaged: { label: gettext('Endommagé'), badge: 'bg-danger', color: '#dc3545', icon: 'bi-x-circle' },
+ missing: { label: gettext('Disparu / Absent'), badge: 'bg-dark', color: '#212529', icon: 'bi-question-circle' },
+ cancelled: { label: gettext('Annulée'), badge: 'bg-secondary', color: '#6c757d', icon: 'bi-slash-circle' }
+};
+
+function getResultStatusMetadata(status) {
+ return RESULT_STATUS_METADATA[status] || { label: status || '-', badge: 'bg-secondary', color: '#888888', icon: 'bi-question-circle' };
+}
+
+const NATURE_METADATA = {
+ regular: { label: gettext('Régulière') },
+ occasional: { label: gettext('Occasionnelle') },
+ post_incident: { label: gettext('Post-incident') },
+ reception: { label: gettext('Réception') },
+ other: { label: gettext('Autre') }
+};
+
+function getNatureMetadata(nature) {
+ return NATURE_METADATA[nature] || { label: nature || '-' };
+}
+
+const inspectionsgeojsonloader = new GeojsonLoader();
+const inspections_layer_defs = [
+ {
+ id: 'inspections',
+ type: 'circle',
+ source: 'inspections',
+ minzoom: 0,
+ maxzoom: 24,
+ paint: {
+ 'circle-radius': [
+ 'interpolate',
+ ['linear'],
+ ['zoom'],
+ 8, 4,
+ 14, 8,
+ 18, 14
+ ],
+ 'circle-color': [
+ 'match',
+ ['get', 'result_status'],
+ 'compliant', RESULT_STATUS_METADATA.compliant.color,
+ 'non_compliant', RESULT_STATUS_METADATA.non_compliant.color,
+ 'damaged', RESULT_STATUS_METADATA.damaged.color,
+ 'missing', RESULT_STATUS_METADATA.missing.color,
+ 'cancelled', RESULT_STATUS_METADATA.cancelled.color,
+ '#0d6efd'
+ ],
+ 'circle-stroke-color': '#ffffff',
+ 'circle-stroke-width': 1.5,
+ 'circle-opacity': 0.85
+ }
+ }
+];
+
+const highlightState = { previousId: null };
+
+document.addEventListener('DOMContentLoaded', () => {
+ maploader.addEventListener('mapLoaded', registerMapInteractions, { once: true });
+ maploader.loadMap('map');
+
+ handleGeojsonMapLoading(true, inspectionsgeojsonloader, 'inspections', inspections_layer_defs);
+
+ initInspectionsTable();
+ setupFilterForm();
+ setupFilterZoneButton();
+ setupLayoutButtons();
+ setupPhotoOverlay();
+
+ const query = new URLSearchParams(window.location.search).toString();
+ const initialUrl = query ? `${inspectionsGeojsonUrl}?${query}` : inspectionsGeojsonUrl;
+ inspectionsgeojsonloader.loadGeojson(initialUrl);
+
+ _updateExportExcelUrl(query);
+});
+
+inspectionsgeojsonloader.addEventListener('geojsonLoaded', (event) => {
+ const geojsonData = event.detail;
+ inspections_geojson = geojsonData;
+ allInspections = geojsonData.features || [];
+ visibleRows = [...allInspections];
+
+ loadDataIntoInspectionsTable(allInspections);
+ fitMapToInspections(allInspections);
+ updateFilterLimitAlert(allInspections.length);
+});
+
+inspectionsgeojsonloader.addEventListener('geojsonError', () => {
+ document.getElementById('inspections-spinner')?.classList.add('d-none');
+ document.getElementById('inspections-table-container')?.classList.remove('d-none');
+ updateResultCount(0);
+});
+
+function registerMapInteractions(event) {
+ const mapInstance = event.detail;
+ mapInstance.addControl(new maplibregl.NavigationControl(), 'top-right');
+
+ // Contrôle photo aérienne
+ if (typeof LayerSwitcherControl !== 'undefined') {
+ const layerSwitcher = new LayerSwitcherControl({
+ layers: [
+ {
+ id: 'ortho',
+ name: 'Orthophotos',
+ icon: 'satellite',
+ group: 'ortho',
+ beforeLayer: 'inspections',
+ layer: {
+ type: 'raster',
+ paint: { 'raster-opacity': 1 }
+ },
+ sublayers: [
+ {
+ id: 'urbis',
+ name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
+ default: true,
+ source: {
+ type: 'raster',
+ tiles: [
+ window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
+ ],
+ tileSize: 256,
+ attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS'
+ }
+ },
+ {
+ id: 'vlaanderen',
+ name: 'Vlaanderen 2023',
+ source: {
+ type: 'raster',
+ tiles: [
+ 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
+ ],
+ tileSize: 256,
+ attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023'
+ }
+ }
+ ]
+ }
+ ]
+ });
+ mapInstance.addControl(layerSwitcher, 'top-left');
+ }
+
+ mapInstance.on('click', 'inspections', (e) => {
+ const feature = e.features?.[0];
+ if (!feature) {
+ return;
+ }
+
+ highlightMapFeature(mapInstance, 'inspections', feature.id, highlightState);
+
+ const props = feature.properties || {};
+ const statusMeta = getResultStatusMetadata(props.result_status);
+ const popupContent = `
+
+ `;
+
+ new maplibregl.Popup({ closeButton: true, maxWidth: '320px' })
+ .setLngLat(e.lngLat)
+ .setHTML(popupContent)
+ .addTo(mapInstance);
+ });
+
+ mapInstance.on('mouseenter', 'inspections', () => {
+ mapInstance.getCanvas().style.cursor = 'pointer';
+ });
+
+ mapInstance.on('mouseleave', 'inspections', () => {
+ mapInstance.getCanvas().style.cursor = '';
+ });
+}
+
+function initInspectionsTable() {
+ const $table = $('#inspections-table');
+ if (!$table.length) {
+ return;
+ }
+
+ function calculateTableHeight() {
+ const windowHeight = $(window).height();
+ const offset = 260;
+ return Math.max(360, windowHeight - offset);
+ }
+
+ const initialSort = {};
+ if (typeof SAVED_SORT !== 'undefined' && SAVED_SORT.sortName) {
+ initialSort.sortName = SAVED_SORT.sortName;
+ initialSort.sortOrder = SAVED_SORT.sortOrder || 'asc';
+ } else {
+ initialSort.sortName = 'inspection_date';
+ initialSort.sortOrder = 'desc';
+ }
+
+ $table.bootstrapTable({
+ columns: [
+ { field: 'actions', title: '', formatter: 'inspActionsFormatter', class: 'text-nowrap' },
+ { field: 'code', title: gettext('Code'), formatter: 'inspCodeFormatter', class: 'text-nowrap', sortable: true },
+ { field: 'inspection_date', title: gettext('Date'), formatter: 'inspDateFormatter', class: 'text-nowrap', sortable: true },
+ { field: 'thematic', title: gettext('Thématique'), formatter: 'inspThematicFormatter', class: 'text-nowrap', sortable: true },
+ { field: 'category_name', title: gettext("Type d'asset"), sortable: true },
+ { field: 'target_display', title: gettext('Cible'), formatter: 'inspTargetFormatter', sortable: true },
+ { field: 'result_status_label', title: gettext('Résultat'), formatter: 'inspResultStatusFormatter', class: 'text-nowrap', sortable: true },
+ { field: 'nature_label', title: gettext('Nature'), formatter: 'inspNatureFormatter', class: 'text-nowrap', sortable: true },
+ { field: 'inspector', title: gettext('Inspecteur'), sortable: true },
+ { field: 'verified_obsolescence', title: gettext('Obsolescence'), sortable: true },
+ { field: 'details_summary', title: gettext('Constats spécifiques'), sortable: true },
+ { field: 'notes', title: gettext('Remarques'), formatter: 'inspNotesFormatter', sortable: true },
+ { field: 'documents_count', title: gettext('Docs / Photos'), formatter: 'inspDocsCountFormatter', class: 'text-center text-nowrap', sortable: true },
+ ],
+ height: calculateTableHeight(),
+ ...initialSort
+ });
+
+ _restoreColumnVisibility($table);
+
+ $table.on('column-switch.bs.table', function () {
+ setTimeout(function () {
+ _saveColumnVisibility($table);
+ $table.bootstrapTable('resetView');
+ }, 0);
+ });
+
+ $table.on('sort.bs.table', function () {
+ setTimeout(savePreferencesFromUrl, 500);
+ });
+
+ registerZoomAction('#inspections-table', '.zoom-btn', map, 16);
+
+ $table.on('post-body.bs.table', () => {
+ visibleRows = $('#inspections-table').bootstrapTable('getData');
+ syncMapWithTable();
+ });
+
+ $(window).on('resize', () => {
+ $table.bootstrapTable('resetView', { height: calculateTableHeight() });
+ });
+}
+
+function loadDataIntoInspectionsTable(features) {
+ const tableData = features.map((feature) => {
+ const props = feature.properties || {};
+ const coords = feature.geometry?.coordinates || [];
+ const statusMeta = getResultStatusMetadata(props.result_status);
+ return {
+ actions: '',
+ id: props.id || feature.id,
+ code: props.code || `INSP#${props.id}`,
+ target_display: props.target_display || '-',
+ asset_id: props.asset_id,
+ content_type_id: props.content_type_id,
+ asset_model: props.asset_model,
+ asset_url: props.asset_url || '',
+ thematic: props.thematic || '-',
+ thematic_code: props.thematic_code || '',
+ thematic_icon: props.thematic_icon || '',
+ category_name: props.category_name || '-',
+ result_status: props.result_status || '',
+ result_status_label: props.result_status_label || statusMeta.label,
+ result_status_badge: props.result_status_badge || statusMeta.badge,
+ result_status_color: props.result_status_color || statusMeta.color,
+ result_status_icon: props.result_status_icon || statusMeta.icon,
+ nature: props.nature || '',
+ nature_label: props.nature_label || '-',
+ inspector: props.inspector || '-',
+ inspection_date: props.inspection_date || '',
+ inspection_date_formatted: props.inspection_date_formatted || '',
+ notes: props.notes || '',
+ details_summary: props.details_summary || '-',
+ verified_obsolescence: props.verified_obsolescence || '',
+ is_cancelled: !!props.is_cancelled,
+ documents_count: props.documents_count || 0,
+ can_edit: !!props.can_edit,
+ lon: coords[0] ?? null,
+ lat: coords[1] ?? null
+ };
+ });
+
+ $('#inspections-spinner').addClass('d-none');
+ $('#inspections-table-container').removeClass('d-none');
+ $('#inspections-table').bootstrapTable('load', tableData);
+
+ visibleRows = tableData;
+ syncMapWithTable();
+ updateResultCount(tableData.length);
+}
+
+function updateFilterLimitAlert(count) {
+ const alertEl = document.getElementById('filter-limit-alert');
+ if (!alertEl) {
+ return;
+ }
+
+ const reached = count >= MAX_ITEMS;
+ alertEl.classList.toggle('d-none', !reached);
+ if (reached) {
+ alertEl.title = interpolate(gettext('Résultat tronqué à 5 000 éléments (%(count)s reçus).'), { count }, true);
+ } else {
+ alertEl.removeAttribute('title');
+ }
+}
+
+function updateResultCount(count) {
+ const target = document.getElementById('inspections-result-count');
+ if (!target) {
+ return;
+ }
+
+ if (!count) {
+ target.textContent = gettext('Aucune inspection chargée');
+ return;
+ }
+
+ const msg = ngettext('%(count)s inspection chargée', '%(count)s inspections chargées', count);
+ target.textContent = interpolate(msg, { count }, true);
+}
+
+function fitMapToInspections(features) {
+ if (!map || !features.length) {
+ return;
+ }
+
+ const bounds = new maplibregl.LngLatBounds();
+ features.forEach((feature) => {
+ const coords = feature.geometry?.coordinates;
+ if (coords && coords.length === 2) {
+ bounds.extend(coords);
+ }
+ });
+
+ if (!bounds.isEmpty()) {
+ map.fitBounds(bounds, { padding: 50 });
+ }
+}
+
+function setupFilterForm() {
+ const form = document.getElementById('filter-form');
+ if (!form) {
+ return;
+ }
+
+ document.getElementById('apply-filters')?.addEventListener('click', () => {
+ const params = buildFilterParams(new FormData(form));
+ _applySearchToParams(params);
+ savePreferencesFromForm(params);
+ refreshListing(params);
+
+ const filterModal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
+ if (filterModal) filterModal.hide();
+ });
+
+ document.getElementById('reset-filters')?.addEventListener('click', () => {
+ form.querySelectorAll('input[type="checkbox"][name="thematic"]').forEach(cb => { cb.checked = false; });
+ form.querySelectorAll('input[type="checkbox"][name="category"]').forEach(cb => { cb.checked = false; });
+ form.querySelectorAll('input[type="checkbox"][name="result_status"]').forEach(cb => { cb.checked = false; });
+ form.querySelectorAll('input[type="checkbox"][name="nature"]').forEach(cb => { cb.checked = false; });
+
+ const dateFrom = form.querySelector('input[name="date_from"]');
+ const dateTo = form.querySelector('input[name="date_to"]');
+ if (dateFrom) dateFrom.value = '';
+ if (dateTo) dateTo.value = '';
+ const mineCheck = form.querySelector('input[name="mine"]');
+ if (mineCheck) mineCheck.checked = false;
+
+ document.querySelectorAll('.filter-options[data-field="category"] .filter-option[data-parent-id]:not([data-parent-id=""])').forEach(ch => {
+ ch.style.display = 'none';
+ });
+ document.querySelectorAll('.filter-subcat-controls').forEach(c => { c.style.display = 'none'; });
+ filterCategoriesByThematics();
+
+ const params = buildFilterParams(new FormData(form));
+ _applySearchToParams(params);
+
+ if (typeof savePreferencesUrl !== 'undefined') {
+ fetch(savePreferencesUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
+ body: JSON.stringify({ filters: {}, sort: {} })
+ }).catch(() => {});
+ }
+
+ refreshListing(params);
+
+ const filterModal = bootstrap.Modal.getInstance(document.getElementById('filterModal'));
+ if (filterModal) filterModal.hide();
+ });
+
+ document.querySelectorAll('.select-all-btn').forEach(btn => {
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ const field = this.getAttribute('data-field');
+ const container = document.querySelector(`.filter-options[data-field="${field}"]`);
+ if (container) {
+ container.querySelectorAll('.filter-option:not([style*="display: none"]) input[type="checkbox"]').forEach(cb => {
+ cb.checked = true;
+ });
+ }
+ });
+ });
+
+ document.querySelectorAll('.deselect-all-btn').forEach(btn => {
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ const field = this.getAttribute('data-field');
+ const container = document.querySelector(`.filter-options[data-field="${field}"]`);
+ if (container) {
+ container.querySelectorAll('input[type="checkbox"]').forEach(cb => {
+ cb.checked = false;
+ });
+ if (field === 'thematic') {
+ filterCategoriesByThematics();
+ }
+ }
+ });
+ });
+
+ document.querySelectorAll('input[name="thematic"]').forEach(cb => {
+ cb.addEventListener('change', () => {
+ filterCategoriesByThematics();
+ });
+ });
+
+ document.querySelectorAll('input[name="category"]').forEach(cb => {
+ cb.addEventListener('change', function() {
+ const option = this.closest('.filter-option');
+ const catId = option ? option.getAttribute('data-category-id') : null;
+ const parentId = option ? option.getAttribute('data-parent-id') : null;
+ if (catId && !parentId) {
+ const container = document.querySelector('.filter-options[data-field="category"]');
+ if (container) {
+ const controls = container.querySelector(`.filter-subcat-controls[data-for-parent="${catId}"]`);
+ if (controls) controls.style.display = this.checked ? '' : 'none';
+ const noneDiv = container.querySelector(`.filter-option[data-category-id="${catId}_none"]`);
+ if (noneDiv) {
+ noneDiv.style.display = this.checked ? '' : 'none';
+ const noneCb = noneDiv.querySelector('input[type="checkbox"]');
+ if (noneCb) noneCb.checked = this.checked;
+ }
+ container.querySelectorAll(`.filter-option[data-parent-id="${catId}"]:not([data-category-id="${catId}_none"])`).forEach(childOption => {
+ if (this.checked) {
+ if (option.style.display !== 'none') {
+ childOption.style.display = '';
+ const childCb = childOption.querySelector('input[type="checkbox"]');
+ if (childCb) childCb.checked = true;
+ }
+ } else {
+ childOption.style.display = 'none';
+ const childCb = childOption.querySelector('input[type="checkbox"]');
+ if (childCb) childCb.checked = false;
+ }
+ });
+ }
+ }
+ });
+ });
+
+ const catContainer = document.querySelector('.filter-options[data-field="category"]');
+ if (catContainer) {
+ const parentIds = new Set();
+ catContainer.querySelectorAll('.filter-option[data-parent-id]:not([data-parent-id=""])').forEach(opt => {
+ parentIds.add(opt.getAttribute('data-parent-id'));
+ });
+
+ const urlCategories = new URLSearchParams(window.location.search).getAll('category');
+
+ parentIds.forEach(parentId => {
+ const allChildren = Array.from(catContainer.querySelectorAll(`.filter-option[data-parent-id="${parentId}"]`));
+ if (!allChildren.length) return;
+
+ const parentOption = catContainer.querySelector(`.filter-option[data-category-id="${parentId}"]`);
+ const parentCb = parentOption ? parentOption.querySelector('input[type="checkbox"]') : null;
+
+ if (parentCb) parentCb.name = 'category_toggle';
+
+ const noneId = `category_none_${parentId}`;
+ const noneDiv = document.createElement('div');
+ noneDiv.className = 'filter-option subcategory-option';
+ noneDiv.setAttribute('data-parent-id', parentId);
+ noneDiv.setAttribute('data-category-id', `${parentId}_none`);
+ noneDiv.setAttribute('data-thematic', parentOption ? parentOption.getAttribute('data-thematic') : '');
+ noneDiv.innerHTML = ` ` +
+ ` ${typeof subcatNoneLabel !== 'undefined' ? subcatNoneLabel : '(aucune sous-catégorie)'} `;
+ allChildren[0].insertAdjacentElement('beforebegin', noneDiv);
+
+ const anyChildSelected = allChildren.some(ch => {
+ const cb = ch.querySelector('input[type="checkbox"]');
+ return cb && urlCategories.includes(cb.value);
+ });
+ const parentDirectlySelected = urlCategories.includes(parentId);
+ const expand = anyChildSelected || parentDirectlySelected;
+
+ if (expand) {
+ if (parentCb) parentCb.checked = true;
+ noneDiv.style.display = '';
+ if (parentDirectlySelected) noneDiv.querySelector('input').checked = true;
+ allChildren.forEach(ch => { ch.style.display = ''; });
+ } else {
+ noneDiv.style.display = 'none';
+ allChildren.forEach(ch => {
+ ch.style.display = 'none';
+ const cb = ch.querySelector('input[type="checkbox"]');
+ if (cb) cb.checked = false;
+ });
+ }
+
+ if (allChildren[0]) allChildren[0].style.marginLeft = '0.5rem';
+
+ const controls = document.createElement('div');
+ controls.className = 'filter-subcat-controls';
+ controls.setAttribute('data-for-parent', parentId);
+ controls.style.display = expand ? '' : 'none';
+ controls.style.marginLeft = '0.5rem';
+ controls.innerHTML = `Tous ` +
+ `Aucun `;
+ allChildren[allChildren.length - 1].insertAdjacentElement('afterend', controls);
+
+ controls.querySelector('.subcat-select-all').addEventListener('click', function(e) {
+ e.preventDefault();
+ catContainer.querySelectorAll(`.filter-option[data-parent-id="${parentId}"]`).forEach(opt => {
+ if (opt.style.display !== 'none') {
+ const cb = opt.querySelector('input[type="checkbox"]');
+ if (cb) cb.checked = true;
+ }
+ });
+ });
+ controls.querySelector('.subcat-deselect-all').addEventListener('click', function(e) {
+ e.preventDefault();
+ catContainer.querySelectorAll(`.filter-option[data-parent-id="${parentId}"]`).forEach(opt => {
+ const cb = opt.querySelector('input[type="checkbox"]');
+ if (cb) cb.checked = false;
+ });
+ });
+ });
+ }
+
+ filterCategoriesByThematics();
+}
+
+function filterCategoriesByThematics() {
+ const checkedThematics = Array.from(document.querySelectorAll('input[name="thematic"]:checked')).map(cb => cb.value);
+ const categoryOptions = document.querySelectorAll('.filter-options[data-field="category"] .filter-option');
+
+ categoryOptions.forEach(option => {
+ const parentId = option.getAttribute('data-parent-id');
+ if (parentId) {
+ const parentOption = document.querySelector(`.filter-options[data-field="category"] .filter-option[data-category-id="${parentId}"]`);
+ const parentVisible = parentOption && parentOption.style.display !== 'none';
+ const parentChecked = parentOption && parentOption.querySelector('input[type="checkbox"]')?.checked;
+ if (!parentVisible || !parentChecked) {
+ option.style.display = 'none';
+ const cb = option.querySelector('input[type="checkbox"]');
+ if (cb) cb.checked = false;
+ }
+ return;
+ }
+ if (checkedThematics.length === 0) {
+ option.style.display = '';
+ return;
+ }
+ const optThematic = option.getAttribute('data-thematic');
+ if (optThematic && checkedThematics.includes(optThematic)) {
+ option.style.display = '';
+ } else if (optThematic) {
+ option.style.display = 'none';
+ const cb = option.querySelector('input[type="checkbox"]');
+ if (cb) cb.checked = false;
+ const catId = option.getAttribute('data-category-id');
+ if (catId) {
+ const controls = document.querySelector(`.filter-subcat-controls[data-for-parent="${catId}"]`);
+ if (controls) controls.style.display = 'none';
+ document.querySelectorAll(`.filter-options[data-field="category"] .filter-option[data-parent-id="${catId}"]`).forEach(child => {
+ child.style.display = 'none';
+ const childCb = child.querySelector('input[type="checkbox"]');
+ if (childCb) childCb.checked = false;
+ });
+ }
+ } else {
+ option.style.display = '';
+ }
+ });
+}
+
+function buildFilterParams(formData) {
+ const params = new URLSearchParams();
+ for (const [key, value] of formData.entries()) {
+ if (value) {
+ params.append(key, value);
+ }
+ }
+ return params;
+}
+
+function refreshListing(params) {
+ const queryString = params.toString();
+ const targetUrl = queryString ? `${inspectionsGeojsonUrl}?${queryString}` : inspectionsGeojsonUrl;
+
+ window.history.pushState({}, '', queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname);
+
+ _updateExportExcelUrl(queryString);
+
+ $('#inspections-spinner').removeClass('d-none');
+ $('#inspections-table-container').addClass('d-none');
+
+ handleGeojsonMapLoading(false, inspectionsgeojsonloader, 'inspections');
+ inspectionsgeojsonloader.loadGeojson(targetUrl);
+}
+
+function _updateExportExcelUrl(queryString) {
+ const btn = document.getElementById('export-excel-btn');
+ if (!btn || typeof inspectionsExportExcelUrl === 'undefined') return;
+ btn.href = queryString ? `${inspectionsExportExcelUrl}?${queryString}` : inspectionsExportExcelUrl;
+}
+
+// === Search widget ===
+
+function _applySearchToParams(params) {
+ const q = $('#inspections-search-input').val().trim();
+ if (q) {
+ params.set('q', q);
+ params.set('search_mode', document.querySelector('input[name="insp-search-mode"]:checked')?.value || 'and');
+ } else {
+ params.delete('q');
+ params.delete('search_mode');
+ }
+}
+
+function _buildInspSearchParams() {
+ const params = new URLSearchParams(window.location.search);
+ _applySearchToParams(params);
+ return params;
+}
+
+function runInspectionSearch() {
+ const params = _buildInspSearchParams();
+ const hasSearch = !!$('#inspections-search-input').val().trim();
+ $('#inspections-search-clear').css('visibility', hasSearch ? 'visible' : 'hidden');
+ refreshListing(params);
+ setTimeout(savePreferencesFromUrl, 500);
+}
+
+$('#inspections-search-btn').on('click', function () {
+ runInspectionSearch();
+});
+
+$('#inspections-search-input').on('keydown', function (e) {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ runInspectionSearch();
+ } else if (e.key === 'Escape') {
+ $(this).val('');
+ runInspectionSearch();
+ }
+});
+
+$('#inspections-search-clear').on('click', function () {
+ $('#inspections-search-input').val('');
+ $('#inspections-search-clear').css('visibility', 'hidden');
+ runInspectionSearch();
+});
+
+window.addEventListener('load', function () {
+ const _urlParams = new URLSearchParams(window.location.search);
+ const _urlQ = _urlParams.get('q') || '';
+ if (_urlQ) {
+ $('#inspections-search-input').val(_urlQ);
+ $('#inspections-search-clear').css('visibility', 'visible');
+ const _urlMode = _urlParams.get('search_mode') || 'and';
+ const _radio = document.querySelector(`input[name="insp-search-mode"][value="${_urlMode}"]`);
+ if (_radio) _radio.checked = true;
+ }
+});
+
+function setupFilterZoneButton() {
+ const button = document.getElementById('filterzone');
+ if (!button) {
+ return;
+ }
+
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ if (!map || !inspections_geojson.features) {
+ return;
+ }
+ filterTableByMapBounds(map, inspections_geojson, loadDataIntoInspectionsTable);
+ });
+}
+
+function syncMapWithTable() {
+ if (!map || !map.getLayer('inspections')) {
+ return;
+ }
+
+ if (!visibleRows.length || visibleRows.length === allInspections.length) {
+ map.setFilter('inspections', null);
+ return;
+ }
+
+ const ids = visibleRows.map((row) => row.id);
+ if (!ids.length) {
+ map.setFilter('inspections', ['==', ['get', 'id'], '__none__']);
+ return;
+ }
+
+ map.setFilter('inspections', ['match', ['get', 'id'], ['literal', ids], true, false]);
+}
+
+// Table Formatters
+function inspActionsFormatter(value, row) {
+ let html = '';
+
+ if (row.lon && row.lat) {
+ html += `
+
+
+ `;
+ }
+
+ html += `
+
+
+ `;
+
+ if (row.can_edit) {
+ html += `
+
+
+ `;
+ }
+
+ html += '
';
+ return html;
+}
+
+function inspCodeFormatter(value, row) {
+ const label = value || `INSP#${row.id}`;
+ return `${label} `;
+}
+
+function inspDateFormatter(value, row) {
+ return row.inspection_date_formatted || (value ? dateFormatter(value) : '-');
+}
+
+function inspThematicFormatter(value, row) {
+ if (!value || value === '-') {
+ return '-';
+ }
+ const icon = row.thematic_icon ? ` ` : '';
+ return `${icon}${value}`;
+}
+
+function inspTargetFormatter(value, row) {
+ const label = value || '-';
+ if (row.asset_url) {
+ return `${label} `;
+ }
+ return `${label} `;
+}
+
+function inspResultStatusFormatter(value, row) {
+ if (!value && !row.result_status) {
+ return '-';
+ }
+ const meta = getResultStatusMetadata(row.result_status);
+ const label = row.result_status_label || meta.label;
+ const icon = row.result_status_icon || meta.icon;
+ return ` ${label} `;
+}
+
+function inspNatureFormatter(value, row) {
+ const meta = getNatureMetadata(row.nature);
+ const label = row.nature_label || meta.label;
+ return `${label} `;
+}
+
+function inspNotesFormatter(value) {
+ if (!value || !value.trim()) {
+ return '-';
+ }
+ return truncateText(value.trim(), NOTES_MAX_LENGTH);
+}
+
+function inspDocsCountFormatter(value, row) {
+ const count = parseInt(value, 10) || 0;
+ if (count === 0) {
+ return '- ';
+ }
+ return ` ${count} `;
+}
+
+window.inspActionsFormatter = inspActionsFormatter;
+window.inspCodeFormatter = inspCodeFormatter;
+window.inspDateFormatter = inspDateFormatter;
+window.inspThematicFormatter = inspThematicFormatter;
+window.inspTargetFormatter = inspTargetFormatter;
+window.inspResultStatusFormatter = inspResultStatusFormatter;
+window.inspNatureFormatter = inspNatureFormatter;
+window.inspNotesFormatter = inspNotesFormatter;
+window.inspDocsCountFormatter = inspDocsCountFormatter;
+
+// Layout switcher
+function setupLayoutButtons() {
+ const btnTable = document.getElementById('layout-btn-table');
+ const btnBoth = document.getElementById('layout-btn-both');
+ const btnMap = document.getElementById('layout-btn-map');
+ const colLeft = document.getElementById('col-left');
+ const colRight = document.getElementById('col-right');
+
+ if (!btnTable || !btnBoth || !btnMap || !colLeft || !colRight) {
+ return;
+ }
+
+ function setLayout(layout) {
+ [btnTable, btnBoth, btnMap].forEach(btn => btn.classList.remove('text-primary'));
+
+ if (layout === 'table_only') {
+ btnTable.classList.add('text-primary');
+ colLeft.classList.remove('d-none', 'col-md-6');
+ colLeft.classList.add('col-12');
+ colRight.classList.add('d-none');
+ } else if (layout === 'map_only') {
+ btnMap.classList.add('text-primary');
+ colLeft.classList.add('d-none');
+ colRight.classList.remove('d-none', 'col-md-6');
+ colRight.classList.add('col-12');
+ } else {
+ btnBoth.classList.add('text-primary');
+ colLeft.classList.remove('d-none', 'col-12');
+ colLeft.classList.add('col-md-6');
+ colRight.classList.remove('d-none', 'col-12');
+ colRight.classList.add('col-md-6');
+ }
+
+ localStorage.setItem('inspections_layout', layout);
+
+ setTimeout(() => {
+ maploader.map?.resize();
+ $('#inspections-table').bootstrapTable('resetView');
+ }, 100);
+ }
+
+ btnTable.addEventListener('click', () => setLayout('table_only'));
+ btnBoth.addEventListener('click', () => setLayout('both'));
+ btnMap.addEventListener('click', () => setLayout('map_only'));
+
+ const savedLayout = localStorage.getItem('inspections_layout') || 'both';
+ setLayout(savedLayout);
+}
+
+// Detail Modal dynamic loader
+window.openInspectionDetailModal = function(inspectionId) {
+ const modalEl = document.getElementById('inspectionDetailModal');
+ if (!modalEl) return;
+
+ const spinner = document.getElementById('detail-modal-spinner');
+ const content = document.getElementById('detail-modal-content');
+ if (spinner) spinner.classList.remove('d-none');
+ if (content) content.classList.add('d-none');
+
+ const bsModal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
+ bsModal.show();
+
+ fetch(`/inspections/api/${inspectionId}/detail/`)
+ .then(res => res.json())
+ .then(data => {
+ if (!data.success) {
+ alert(data.error || gettext('Erreur lors du chargement des détails.'));
+ bsModal.hide();
+ return;
+ }
+
+ // Icon & Title
+ const iconEl = document.getElementById('detail-modal-icon');
+ if (iconEl) {
+ iconEl.className = `bi ${data.thematic_icon || 'bi-search'} text-primary me-2`;
+ }
+ const titleEl = document.getElementById('detail-modal-title');
+ if (titleEl) {
+ titleEl.textContent = `${gettext("Détail de l'inspection")} #${data.id}`;
+ }
+
+ // Cancelled alert
+ const cancelledAlert = document.getElementById('detail-cancelled-alert');
+ if (cancelledAlert) {
+ cancelledAlert.classList.toggle('d-none', !data.is_cancelled);
+ }
+
+ // Asset link
+ const assetBtnContainer = document.getElementById('detail-asset-btn-container');
+ const assetLink = document.getElementById('detail-asset-link');
+ if (assetBtnContainer && assetLink) {
+ if (data.asset_url) {
+ assetLink.href = data.asset_url;
+ assetBtnContainer.classList.remove('d-none');
+ } else {
+ assetBtnContainer.classList.add('d-none');
+ }
+ }
+
+ // Key fields
+ document.getElementById('detail-target-display').textContent = data.target_display || '-';
+ document.getElementById('detail-asset-type').textContent = data.asset_type_name || '-';
+ document.getElementById('detail-date').textContent = data.inspection_date || '-';
+
+ const resultBadge = document.getElementById('detail-result-badge');
+ if (resultBadge) {
+ resultBadge.className = `badge ${data.status_badge_class}`;
+ resultBadge.innerHTML = ` ${data.result_status_display}`;
+ }
+
+ document.getElementById('detail-nature').textContent = data.nature_display || '-';
+ document.getElementById('detail-inspector').textContent = data.inspector_name || '-';
+ document.getElementById('detail-updated-at').textContent = data.updated_at || data.created_at || '-';
+
+ // Specific HTML partial
+ const specContent = document.getElementById('detail-specific-content');
+ if (specContent) {
+ specContent.innerHTML = data.details_html || '';
+ }
+
+ // Notes
+ const notesCard = document.getElementById('detail-notes-card');
+ const notesText = document.getElementById('detail-notes-text');
+ if (notesCard && notesText) {
+ if (data.notes) {
+ notesText.textContent = data.notes;
+ notesCard.classList.remove('d-none');
+ } else {
+ notesCard.classList.add('d-none');
+ }
+ }
+
+ // Obsolescence
+ const obsCard = document.getElementById('detail-obsolescence-card');
+ const obsText = document.getElementById('detail-obsolescence-text');
+ if (obsCard && obsText) {
+ if (data.verified_obsolescence) {
+ obsText.textContent = data.verified_obsolescence;
+ obsCard.classList.remove('d-none');
+ } else {
+ obsCard.classList.add('d-none');
+ }
+ }
+
+ // Documents & Photos
+ const docsCard = document.getElementById('detail-documents-card');
+ const docsCount = document.getElementById('detail-documents-count');
+ const carouselContainer = document.getElementById('detail-carousel-container');
+ const carouselInner = document.getElementById('detail-carousel-inner');
+ const carouselIndicators = document.getElementById('detail-carousel-indicators');
+ const nonImageDocs = document.getElementById('detail-non-image-docs');
+
+ if (docsCard && data.documents && data.documents.length > 0) {
+ docsCard.classList.remove('d-none');
+ if (docsCount) docsCount.textContent = `(${data.documents.length})`;
+
+ const imageDocs = data.documents.filter(d => d.is_image);
+ const otherDocs = data.documents.filter(d => !d.is_image);
+
+ if (imageDocs.length > 0 && carouselContainer && carouselInner) {
+ carouselContainer.classList.remove('d-none');
+ carouselInner.innerHTML = '';
+ if (carouselIndicators) carouselIndicators.innerHTML = '';
+
+ imageDocs.forEach((doc, idx) => {
+ const activeClass = idx === 0 ? 'active' : '';
+ const item = document.createElement('div');
+ item.className = `carousel-item ${activeClass} h-100`;
+ item.innerHTML = `
+
+
+
+
+
+
+ `;
+ carouselInner.appendChild(item);
+
+ if (carouselIndicators && imageDocs.length > 1) {
+ const ind = document.createElement('button');
+ ind.type = 'button';
+ ind.setAttribute('data-bs-target', '#detail-carousel-container');
+ 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);
+ }
+ });
+
+ // Attach click handler on images to open fullscreen overlay
+ carouselInner.querySelectorAll('.detail-carousel-photo').forEach(el => {
+ el.addEventListener('click', function(e) {
+ e.preventDefault();
+ const idx = parseInt(this.getAttribute('data-img-index'), 10) || 0;
+ openFullscreenPhotos(imageDocs, idx);
+ });
+ });
+ } else if (carouselContainer) {
+ carouselContainer.classList.add('d-none');
+ }
+
+ if (nonImageDocs) {
+ nonImageDocs.innerHTML = '';
+ otherDocs.forEach(doc => {
+ const link = document.createElement('a');
+ link.href = doc.url;
+ link.target = '_blank';
+ link.className = 'btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1';
+ link.innerHTML = `${doc.filename} `;
+ nonImageDocs.appendChild(link);
+ });
+ }
+ } else if (docsCard) {
+ docsCard.classList.add('d-none');
+ }
+
+ // Action buttons (Cancel / Restore / Edit)
+ const cancelBtn = document.getElementById('detail-cancel-btn');
+ const restoreBtn = document.getElementById('detail-restore-btn');
+ const editBtn = document.getElementById('detail-edit-btn');
+
+ if (data.can_edit) {
+ if (cancelBtn) {
+ cancelBtn.classList.toggle('d-none', data.is_cancelled);
+ cancelBtn.onclick = () => handleToggleCancelInspection(data.id, 'cancel');
+ }
+ if (restoreBtn) {
+ restoreBtn.classList.toggle('d-none', !data.is_cancelled);
+ restoreBtn.onclick = () => handleToggleCancelInspection(data.id, 'restore');
+ }
+ if (editBtn) {
+ editBtn.classList.remove('d-none');
+ editBtn.onclick = () => {
+ bsModal.hide();
+ setTimeout(() => openEditInspectionModal(data.id), 250);
+ };
+ }
+ } else {
+ if (cancelBtn) cancelBtn.classList.add('d-none');
+ if (restoreBtn) restoreBtn.classList.add('d-none');
+ if (editBtn) editBtn.classList.add('d-none');
+ }
+
+ if (spinner) spinner.classList.add('d-none');
+ if (content) content.classList.remove('d-none');
+ })
+ .catch(err => {
+ console.error(err);
+ alert(gettext('Erreur de communication avec le serveur.'));
+ bsModal.hide();
+ });
+};
+
+function openFullscreenPhotos(imagesData, 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 = '';
+
+ 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 = `
+
+
+
${doc.filename}
+
+ `;
+ 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');
+}
+
+function setupPhotoOverlay() {
+ const overlay = document.getElementById('fullscreenPhotoOverlay');
+ const btnClose = document.getElementById('btn-close-photo-overlay');
+ if (btnClose && overlay) {
+ btnClose.addEventListener('click', (e) => {
+ e.preventDefault();
+ overlay.classList.add('d-none');
+ });
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && overlay && !overlay.classList.contains('d-none')) {
+ overlay.classList.add('d-none');
+ }
+ });
+}
+
+window.handleToggleCancelInspection = function(inspectionId, action) {
+ const confirmMsg = action === 'cancel'
+ ? gettext('Êtes-vous sûr de vouloir annuler cette inspection ?')
+ : gettext('Êtes-vous sûr de vouloir rétablir cette inspection ?');
+ if (!confirm(confirmMsg)) return;
+
+ const formData = new FormData();
+ formData.append('action', action);
+
+ fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
+ method: 'POST',
+ headers: {
+ 'X-CSRFToken': getCookie('csrftoken')
+ },
+ body: formData
+ })
+ .then(res => res.json())
+ .then(data => {
+ if (data.success) {
+ const detailModalEl = document.getElementById('inspectionDetailModal');
+ if (detailModalEl) {
+ const bsModal = bootstrap.Modal.getInstance(detailModalEl);
+ if (bsModal) bsModal.hide();
+ }
+ const params = _buildInspSearchParams();
+ refreshListing(params);
+ } else {
+ alert(data.error || gettext('Une erreur est survenue.'));
+ }
+ })
+ .catch(err => {
+ console.error(err);
+ alert(gettext('Erreur de communication avec le serveur.'));
+ });
+};
+
+// ==================== Preferences Management ====================
+
+function getCookie(name) {
+ let cookieValue = null;
+ if (document.cookie && document.cookie !== '') {
+ const cookies = document.cookie.split(';');
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i].trim();
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+ break;
+ }
+ }
+ }
+ return cookieValue;
+}
+
+const INSP_COLUMNS_KEY = 'inspections_columns_hidden';
+
+function _saveColumnVisibility($table) {
+ try {
+ const allCols = $table.bootstrapTable('getOptions').columns[0] || [];
+ const visibleFields = $table.bootstrapTable('getVisibleColumns').map((c) => c.field);
+ const hiddenFields = allCols
+ .filter((c) => c.field && c.field !== 'actions' && !visibleFields.includes(c.field))
+ .map((c) => c.field);
+ localStorage.setItem(INSP_COLUMNS_KEY, JSON.stringify(hiddenFields));
+ } catch (e) {}
+}
+
+function _restoreColumnVisibility($table) {
+ try {
+ const raw = localStorage.getItem(INSP_COLUMNS_KEY);
+ if (!raw) return;
+ const hiddenFields = JSON.parse(raw);
+ if (Array.isArray(hiddenFields)) {
+ hiddenFields.forEach((field) => {
+ $table.bootstrapTable('hideColumn', field);
+ });
+ }
+ } catch (e) {}
+}
+
+function savePreferencesFromUrl() {
+ if (typeof savePreferencesUrl === 'undefined') return;
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const filters = {};
+ const filterParams = ['thematic', 'category', 'result_status', 'nature', 'mine', 'date_from', 'date_to', 'q', 'search_mode'];
+ filterParams.forEach((param) => {
+ const values = urlParams.getAll(param);
+ if (values.length > 1) {
+ filters[param] = values;
+ } else if (values.length === 1 && values[0] !== '') {
+ filters[param] = values[0];
+ }
+ });
+
+ const $table = $('#inspections-table');
+ let sort = {};
+ if ($table.length && $table.data('bootstrap.table')) {
+ const options = $table.bootstrapTable('getOptions');
+ if (options.sortName) {
+ sort = { sortName: options.sortName, sortOrder: options.sortOrder || 'asc' };
+ }
+ }
+
+ fetch(savePreferencesUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
+ body: JSON.stringify({ filters, sort })
+ }).catch(() => {});
+}
+
+async function savePreferencesFromForm(urlParams) {
+ if (typeof savePreferencesUrl === 'undefined') return;
+
+ const filters = {};
+ const filterParams = ['thematic', 'category', 'result_status', 'nature', 'mine', 'date_from', 'date_to', 'q', 'search_mode'];
+ filterParams.forEach((param) => {
+ const values = urlParams.getAll(param);
+ if (values.length > 1) {
+ filters[param] = values;
+ } else if (values.length === 1 && values[0] !== '') {
+ filters[param] = values[0];
+ }
+ });
+
+ const $table = $('#inspections-table');
+ let sort = {};
+ if ($table.length && $table.data('bootstrap.table')) {
+ const options = $table.bootstrapTable('getOptions');
+ if (options.sortName) {
+ sort = { sortName: options.sortName, sortOrder: options.sortOrder || 'asc' };
+ }
+ }
+
+ try {
+ await fetch(savePreferencesUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') },
+ body: JSON.stringify({ filters, sort }),
+ keepalive: true
+ });
+ } catch (e) {}
+}
+
+document.addEventListener('DOMContentLoaded', function () {
+ if (window.location.search) {
+ setTimeout(savePreferencesFromUrl, 1000);
+ }
+});
diff --git a/loko/inspections/templates/inspections/inspections_index.html b/loko/inspections/templates/inspections/inspections_index.html
new file mode 100644
index 0000000..4001c1a
--- /dev/null
+++ b/loko/inspections/templates/inspections/inspections_index.html
@@ -0,0 +1,399 @@
+{% extends "base.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
+
+{% block head %}
+
+{% endblock head %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% translate "Inspections" %}
+
+
+
+
+
+
+
+
+
+ {% translate "Aucune inspection chargée" %}
+
+
+
+ {% translate "Contenu limité à 5 000 éléments." %}
+
+
+
+
+
+ {% translate "Chargement..." %}
+
+
+
+
+
+
+ {% translate "Filtre" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% translate "Filtrer sur cette zone" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% translate "Chargement..." %}
+
+
+
+
+
+
+ {% translate "Inspection annulée" %} — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
+
+
+
+
+
+
+
+ {% translate "Cible" %}
+ -
+
+
+ {% translate "Type d'asset" %}
+ -
+
+
+ {% translate "Date & Heure" %}
+ -
+
+
+ {% translate "Résultat du contrôle" %}
+ -
+
+
+ {% translate "Nature" %}
+ -
+
+
+ {% translate "Inspecteur" %}
+ -
+
+
+ {% translate "Date de modification" %}
+ -
+
+
+
+
+
+
+
+
+
{% translate "Remarques" %}
+
+
+
+
+
+
{% translate "Obsolescence" %}
+
+
+
+
+
+
+ {% translate "Photos & Documents joints" %}
+
+
+
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+{% include "inspections/components/edit_inspection_modal.html" %}
+
+{% endblock content %}
+
+{% block script %}
+
+
+
+
+
+
+{% endblock script %}
diff --git a/loko/inspections/templates/inspections/inspections_list_mobile.html b/loko/inspections/templates/inspections/inspections_list_mobile.html
new file mode 100644
index 0000000..f711579
--- /dev/null
+++ b/loko/inspections/templates/inspections/inspections_list_mobile.html
@@ -0,0 +1,751 @@
+{% extends base_template|default:"base.html" %}
+{% load i18n static l10n short_name inspections_tags %}
+
+{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
+
+{% block extra_css %}
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% translate "Inspections" %}
+
+
{% translate "Suivi et historique des inspections et vérifications sur le terrain" %}
+
+ {% if can_inspect %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if inspections %}
+
+
+
+ {% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
+ ({% translate "limité aux 100 plus récentes" %})
+
+
+
+
+ {% for insp in inspections %}
+
+
+
+
+
+
+
+
+ {{ insp.asset_content_type.name }}
+
+
+ {{ insp.get_result_status_display }}
+
+
+
+
+
+ {{ insp.target_display|truncatechars:100 }}
+
+
+
+ {% if insp.details_summary and insp.details_summary != "-" %}
+
+ {{ insp.details_summary }}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if insp.is_cancelled %}
+
+
+
+ {% translate "Inspection annulée" %} — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
+
+
+ {% endif %}
+
+ {% if insp.asset %}
+
+ {% endif %}
+
+
+
+ {% translate "Cible" %}
+ {{ insp.target_display }}
+
+
+ {% translate "Type d'asset" %}
+ {{ insp.asset_content_type.name }}
+
+
+ {% translate "Date & Heure" %}
+ {{ insp.inspection_date|date:"d/m/Y H:i" }}
+
+
+ {% translate "Résultat du contrôle" %}
+ {{ insp.get_result_status_display }}
+
+
+ {% translate "Nature" %}
+ {{ insp.get_nature_display }}
+
+
+ {% translate "Inspecteur" %}
+ {{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}
+
+
+ {% translate "Date de modification" %}
+ {{ insp.updated_at|date:"d/m/Y H:i" }}
+
+
+
+ {% get_inspection_detail_template insp as detail_template %}
+ {% if detail_template %}
+
+ {% include detail_template with insp=insp %}
+
+ {% endif %}
+
+ {% if insp.notes %}
+
+
{% translate "Remarques" %}
+
{{ insp.notes }}
+
+ {% endif %}
+
+ {% if insp.verified_obsolescence %}
+
+
{% translate "Obsolescence" %}
+
{{ insp.verified_obsolescence }}
+
+ {% endif %}
+
+ {% if insp.documents.exists %}
+
+
+ {% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
+
+
+ {% with docs=insp.documents.all %}
+
+
+
+ {% for doc in docs %}
+ {% if doc.is_image %}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% for doc in docs %}
+ {% if doc.is_image %}
+
+
+
+
+
+
+
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+
+ {% endwith %}
+
+ {% endif %}
+
+
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+
+
+
{% translate "Aucune inspection trouvée" %}
+
{% translate "Aucune inspection ne correspond à vos critères de recherche." %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Précédent
+
+
+
+ Suivant
+
+
+
+
+
+
+
+
+
+
+
+{% include "inspections/components/edit_inspection_modal.html" %}
+{% endblock %}
diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py
index 8de14fe..ce385ff 100644
--- a/loko/inspections/tests.py
+++ b/loko/inspections/tests.py
@@ -397,22 +397,28 @@ class InspectionModelAndViewsTestCase(TestCase):
result_status='compliant'
)
- url = reverse('inspections:inspections_list')
-
- # Filter water thematic
- resp_water = self.client.get(url, {'thematic': 'water', 'tab': 'all'})
- self.assertEqual(resp_water.status_code, 200)
- inspections_water = list(resp_water.context['inspections'])
+ # Mobile list test
+ url_mobile = reverse('mobile:inspections_list_mobile')
+ resp_water_m = self.client.get(url_mobile, {'thematic': 'water', 'tab': 'all'})
+ self.assertEqual(resp_water_m.status_code, 200)
+ inspections_water = list(resp_water_m.context['inspections'])
self.assertIn(insp_water, inspections_water)
self.assertNotIn(insp_parking, inspections_water)
- # Filter parking thematic
- resp_parking = self.client.get(url, {'thematic': 'parking', 'tab': 'all'})
- self.assertEqual(resp_parking.status_code, 200)
- inspections_parking = list(resp_parking.context['inspections'])
+ resp_parking_m = self.client.get(url_mobile, {'thematic': 'parking', 'tab': 'all'})
+ self.assertEqual(resp_parking_m.status_code, 200)
+ inspections_parking = list(resp_parking_m.context['inspections'])
self.assertIn(insp_parking, inspections_parking)
self.assertNotIn(insp_water, inspections_parking)
+ # Desktop GeoJSON test
+ url_geojson = reverse('inspections:inspections_geojson_get')
+ resp_water_g = self.client.get(url_geojson, {'thematic': 'water'})
+ self.assertEqual(resp_water_g.status_code, 200)
+ feat_ids_water = [f['properties']['id'] for f in resp_water_g.json()['features']]
+ self.assertIn(insp_water.id, feat_ids_water)
+ self.assertNotIn(insp_parking.id, feat_ids_water)
+
def test_toggle_cancel_inspection_api(self):
self.client.login(username='inspector_user', password='password123')
insp = Inspection.objects.create(
@@ -529,6 +535,87 @@ class InspectionModelAndViewsTestCase(TestCase):
self.assertEqual(self.spot.inspection_status, 'unknown')
self.assertIsNone(self.spot.last_inspection_date)
+ def test_desktop_inspections_list_view(self):
+ self.client.login(username='inspector_user', password='password123')
+ url = reverse('inspections:inspections_list')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'inspections/inspections_index.html')
+ self.assertIn('thematic_choices', response.context)
+ self.assertIn('result_status_choices', response.context)
+ self.assertIn('saved_sort', response.context)
+
+ def test_mobile_inspections_list_view(self):
+ self.client.login(username='inspector_user', password='password123')
+ response = self.client.get(reverse('mobile:inspections_list_mobile'))
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'inspections/inspections_list_mobile.html')
+
+ def test_inspections_geojson_get_endpoint(self):
+ self.client.login(username='inspector_user', password='password123')
+ insp1 = Inspection.objects.create(
+ asset_content_type=self.spot_ct,
+ asset_object_id=self.spot.id,
+ inspector=self.user,
+ result_status='compliant',
+ notes='Test note search query'
+ )
+ url = reverse('inspections:inspections_geojson_get')
+
+ # Test basic GET
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ data = resp.json()
+ self.assertEqual(data['type'], 'FeatureCollection')
+ self.assertTrue(any(f['properties']['id'] == insp1.id for f in data['features']))
+
+ # Test thematic filter
+ resp_water = self.client.get(url, {'thematic': 'water'})
+ self.assertEqual(resp_water.status_code, 200)
+ data_water = resp_water.json()
+ self.assertFalse(any(f['properties']['id'] == insp1.id for f in data_water['features']))
+
+ # Test search filter (q)
+ resp_q = self.client.get(url, {'q': 'search query'})
+ self.assertEqual(resp_q.status_code, 200)
+ data_q = resp_q.json()
+ self.assertTrue(any(f['properties']['id'] == insp1.id for f in data_q['features']))
+
+ def test_inspections_export_excel(self):
+ self.client.login(username='inspector_user', password='password123')
+ Inspection.objects.create(
+ asset_content_type=self.spot_ct,
+ asset_object_id=self.spot.id,
+ inspector=self.user,
+ result_status='compliant',
+ notes='Test Excel Export Note'
+ )
+ url = reverse('inspections:inspections_export_excel')
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(
+ resp['Content-Type'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ )
+ self.assertTrue(resp.has_header('Content-Disposition'))
+ self.assertIn('attachment; filename="inspections_export_', resp['Content-Disposition'])
+
+ def test_save_inspection_list_preferences(self):
+ self.client.login(username='inspector_user', password='password123')
+ url = reverse('common:save_inspection_list_preferences')
+ import json
+ payload = {
+ 'filters': {'thematic': ['parking'], 'mine': '1'},
+ 'sort': {'sortName': 'inspection_date', 'sortOrder': 'asc'}
+ }
+ resp = self.client.post(url, json.dumps(payload), content_type='application/json')
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(resp.json()['success'])
+
+ self.user.config.refresh_from_db()
+ self.assertEqual(self.user.config.inspection_list_filters, payload['filters'])
+ self.assertEqual(self.user.config.inspection_list_sort, payload['sort'])
+
class ExternalUserInspectionVisibilityTestCase(TestCase):
def setUp(self):
@@ -773,14 +860,22 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
)
self.client.login(username='extern_user', password='password123')
- url = reverse('inspections:inspections_list')
- response = self.client.get(url, {'tab': 'all'})
+ # Test mobile
+ url_mobile = reverse('mobile:inspections_list_mobile')
+ response = self.client.get(url_mobile, {'tab': 'all'})
self.assertEqual(response.status_code, 200)
-
inspections = list(response.context['inspections'])
self.assertIn(insp_parking, inspections)
self.assertNotIn(insp_water, inspections)
+ # Test desktop GeoJSON
+ url_geojson = reverse('inspections:inspections_geojson_get')
+ resp_geojson = self.client.get(url_geojson)
+ self.assertEqual(resp_geojson.status_code, 200)
+ feat_ids = [f['properties']['id'] for f in resp_geojson.json()['features']]
+ self.assertIn(insp_parking.id, feat_ids)
+ self.assertNotIn(insp_water.id, feat_ids)
+
def test_external_user_inspection_detail_api(self):
insp_parking = Inspection.objects.create(
asset_content_type=self.spot_ct,
@@ -928,8 +1023,3 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
self.assertEqual(detail_res.status_code, 200)
data = detail_res.json()
self.assertTrue(data['is_signpanel'])
- self.assertEqual(data['verified_cleanliness'], 'missing')
-
-
-
-
diff --git a/loko/inspections/urls.py b/loko/inspections/urls.py
index c1502d3..3423828 100644
--- a/loko/inspections/urls.py
+++ b/loko/inspections/urls.py
@@ -5,6 +5,8 @@ app_name = 'inspections'
urlpatterns = [
path('', views.inspections_list, name='inspections_list'),
+ path('geojson/', views.inspections_geojson, name='inspections_geojson_get'),
+ path('export/excel/', views.inspections_export_excel, name='inspections_export_excel'),
path('quick-map/', views.quick_inspection_map, name='quick_inspection_map'),
path('api/create/', views.create_inspection_api, name='create_inspection_api'),
path('api//detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'),
diff --git a/loko/inspections/views.py b/loko/inspections/views.py
index c9b0e2c..7bae3fb 100644
--- a/loko/inspections/views.py
+++ b/loko/inspections/views.py
@@ -1,11 +1,12 @@
from django.shortcuts import render, get_object_or_404, redirect
-from django.http import JsonResponse, Http404, HttpResponseForbidden, HttpResponse
+from django.http import JsonResponse, Http404, HttpResponseForbidden, HttpResponse, HttpResponseRedirect
from django.contrib.auth.decorators import login_required
-from django.views.decorators.http import require_POST
+from django.views.decorators.http import require_POST, require_GET
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext as _, get_language
+import json
from common.models import Thematic
from common.utils import get_short_name
@@ -36,113 +37,316 @@ def get_user_viewable_thematics(user):
return None
-@login_required
-def inspections_list(request):
+def _filter_inspections_queryset(request):
"""
- Vue de liste et de gestion des inspections avec filtres par défaut, recherche, thématiques et missions d'intervention.
+ Construit et filtre le QuerySet des inspections selon les paramètres de requête GET.
"""
- filter_type = request.GET.get('tab', 'my') # 'my' ou 'all'
- thematic_code = request.GET.get('thematic', '')
- status_filter = request.GET.get('status', '')
- nature_filter = request.GET.get('nature', '')
- mission_id = request.GET.get('mission_id', '')
- checked_interv_id = request.GET.get('checked_interv_id', '')
- search_q = request.GET.get('q', '').strip()
-
from .permissions import filter_viewable_inspections_for_user
qs = filter_viewable_inspections_for_user(request.user)
qs = qs.select_related(
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention'
- ).prefetch_related('documents').order_by('-inspection_date')
+ ).prefetch_related('documents')
- if filter_type == 'my':
- qs = qs.filter(inspector=request.user)
+ thematics = request.GET.getlist('thematic')
+ if thematics:
+ thematic_objs = Thematic.objects.filter(code__in=thematics)
+ categories_for_thematics = AssetCategory.objects.filter(thematic__in=thematic_objs)
+ cts = set(ContentType.objects.filter(asset_categories__in=categories_for_thematics).distinct())
+ if 'water' in thematics:
+ from assets.models.nature import NatureRWIZ, NatureRWIAsset
+ cts.add(ContentType.objects.get_for_model(NatureRWIZ))
+ cts.add(ContentType.objects.get_for_model(NatureRWIAsset))
+ if 'sign' in thematics:
+ from assets.models import SignPanel, SignPole
+ cts.add(ContentType.objects.get_for_model(SignPanel))
+ cts.add(ContentType.objects.get_for_model(SignPole))
+ if 'parking' in thematics:
+ from assets.models import ParkingSpot, ParkingLocation
+ cts.add(ContentType.objects.get_for_model(ParkingSpot))
+ cts.add(ContentType.objects.get_for_model(ParkingLocation))
+ qs = qs.filter(asset_content_type__in=cts)
- if thematic_code:
- try:
- thematic = Thematic.objects.get(code=thematic_code)
- categories = AssetCategory.objects.filter(thematic=thematic)
- cts = set(ContentType.objects.filter(asset_categories__in=categories).distinct())
- if thematic_code == 'water':
- from assets.models.nature import NatureRWIZ, NatureRWIAsset
- cts.add(ContentType.objects.get_for_model(NatureRWIZ))
- cts.add(ContentType.objects.get_for_model(NatureRWIAsset))
- qs = qs.filter(asset_content_type__in=cts)
- except Thematic.DoesNotExist:
- pass
+ categories = request.GET.getlist('category')
+ if categories:
+ child_ids = list(AssetCategory.objects.filter(parent_id__in=categories).values_list('id', flat=True))
+ expanded_cat_ids = list(set(categories) | set(str(c) for c in child_ids))
+ category_cts = set(ContentType.objects.filter(asset_categories__id__in=expanded_cat_ids).distinct())
- if status_filter:
- qs = qs.filter(result_status=status_filter)
+ cat_asset_q = Q(asset_content_type__in=category_cts)
+ for ct in category_cts:
+ ModelClass = ct.model_class()
+ if ModelClass and hasattr(ModelClass, 'category'):
+ match_ids = list(ModelClass.objects.filter(category_id__in=expanded_cat_ids).values_list('pk', flat=True)[:1000])
+ if match_ids:
+ cat_asset_q |= Q(asset_content_type=ct, asset_object_id__in=match_ids)
+ qs = qs.filter(cat_asset_q)
- if nature_filter:
- qs = qs.filter(nature=nature_filter)
+ search_query = (request.GET.get('q') or '').strip()
+ search_mode = (request.GET.get('search_mode') or 'and').lower()
+ if search_mode not in ('and', 'or'):
+ search_mode = 'and'
- if mission_id:
- qs = qs.filter(mission_intervention_id=mission_id)
+ if search_query:
+ words = search_query.split()
- if checked_interv_id:
- qs = qs.filter(checked_intervention_id=checked_interv_id)
-
- if search_q:
- search_filter = (
- Q(notes__icontains=search_q) |
- Q(inspector__first_name__icontains=search_q) |
- Q(inspector__last_name__icontains=search_q) |
- Q(inspector__username__icontains=search_q) |
- Q(mission_intervention__title__icontains=search_q) |
- Q(checked_intervention__title__icontains=search_q)
- )
+ def _word_q(word):
+ return (
+ Q(notes__icontains=word) |
+ Q(inspector__first_name__icontains=word) |
+ Q(inspector__last_name__icontains=word) |
+ Q(inspector__username__icontains=word) |
+ Q(mission_intervention__title__icontains=word) |
+ Q(checked_intervention__title__icontains=word)
+ )
asset_q_conditions = Q()
- for ct in ContentType.objects.filter(app_label='assets'):
+ for ct in ContentType.objects.filter(app_label__in=['assets', 'interventions']):
ModelClass = ct.model_class()
if not ModelClass:
continue
model_fields = [f.name for f in ModelClass._meta.get_fields()]
asset_search = Q()
- if 'code' in model_fields:
- asset_search |= Q(code__icontains=search_q)
- if 'name' in model_fields:
- asset_search |= Q(name__icontains=search_q)
- if 'name_fr' in model_fields:
- asset_search |= Q(name_fr__icontains=search_q)
+ for word in words:
+ word_asset_search = Q()
+ if 'code' in model_fields:
+ word_asset_search |= Q(code__icontains=word)
+ if 'name' in model_fields:
+ word_asset_search |= Q(name__icontains=word)
+ if 'name_fr' in model_fields:
+ word_asset_search |= Q(name_fr__icontains=word)
+ if 'address' in model_fields:
+ word_asset_search |= Q(address__icontains=word)
+ if word_asset_search:
+ asset_search |= word_asset_search
if asset_search:
matching_ids = list(ModelClass.objects.filter(asset_search).values_list('pk', flat=True)[:500])
if matching_ids:
asset_q_conditions |= Q(asset_content_type=ct, asset_object_id__in=matching_ids)
+ combined = Q()
+ if search_mode == 'and':
+ for w in words:
+ combined &= _word_q(w)
+ else:
+ for w in words:
+ combined |= _word_q(w)
+
if asset_q_conditions:
- search_filter |= asset_q_conditions
+ combined |= asset_q_conditions
- qs = qs.filter(search_filter)
+ qs = qs.filter(combined)
- thematics = get_user_viewable_thematics(request.user)
+ statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
+ if statuses:
+ qs = qs.filter(result_status__in=statuses)
+
+ natures = request.GET.getlist('nature')
+ if natures:
+ qs = qs.filter(nature__in=natures)
+
+ show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
+ if show_mine_only:
+ qs = qs.filter(inspector=request.user)
+
+ date_from = request.GET.get('date_from', '').strip()
+ date_to = request.GET.get('date_to', '').strip()
+ if date_from:
+ qs = qs.filter(inspection_date__date__gte=date_from)
+ if date_to:
+ qs = qs.filter(inspection_date__date__lte=date_to)
+
+ mission_id = request.GET.get('mission_id')
+ if mission_id:
+ qs = qs.filter(mission_intervention_id=mission_id)
+
+ checked_interv_id = request.GET.get('checked_interv_id')
+ if checked_interv_id:
+ qs = qs.filter(checked_intervention_id=checked_interv_id)
+
+ return qs.order_by('-inspection_date')
+
+
+@login_required
+def inspections_list(request):
+ """
+ Vue de liste des inspections :
+ - Desktop : vue partagée Tableau interactif (Bootstrap Table) + Carte (MapLibre GL) avec filtres, recherche et export.
+ - Mobile : vue en cartes (inspections_list_mobile.html).
+ """
+ is_mobile = request.path.startswith('/mobile/')
+
+ thematics = get_user_viewable_thematics(request.user) or Thematic.objects.none()
insp_thematics = get_user_inspectable_thematics(request.user)
can_inspect = bool(insp_thematics and insp_thematics.exists())
- is_mobile = request.path.startswith('/mobile/')
- base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
+ if is_mobile:
+ filter_type = request.GET.get('tab', 'my')
+ thematic_code = request.GET.get('thematic', '')
+ status_filter = request.GET.get('status', '')
+ nature_filter = request.GET.get('nature', '')
+ mission_id = request.GET.get('mission_id', '')
+ checked_interv_id = request.GET.get('checked_interv_id', '')
+ search_q = request.GET.get('q', '').strip()
+
+ from .permissions import filter_viewable_inspections_for_user
+ qs = filter_viewable_inspections_for_user(request.user)
+ qs = qs.select_related(
+ 'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention'
+ ).prefetch_related('documents').order_by('-inspection_date')
+
+ if filter_type == 'my':
+ qs = qs.filter(inspector=request.user)
+
+ if thematic_code:
+ try:
+ thematic = Thematic.objects.get(code=thematic_code)
+ categories = AssetCategory.objects.filter(thematic=thematic)
+ cts = set(ContentType.objects.filter(asset_categories__in=categories).distinct())
+ if thematic_code == 'water':
+ from assets.models.nature import NatureRWIZ, NatureRWIAsset
+ cts.add(ContentType.objects.get_for_model(NatureRWIZ))
+ cts.add(ContentType.objects.get_for_model(NatureRWIAsset))
+ if thematic_code == 'sign':
+ from assets.models import SignPanel, SignPole
+ cts.add(ContentType.objects.get_for_model(SignPanel))
+ cts.add(ContentType.objects.get_for_model(SignPole))
+ if thematic_code == 'parking':
+ from assets.models import ParkingSpot, ParkingLocation
+ cts.add(ContentType.objects.get_for_model(ParkingSpot))
+ cts.add(ContentType.objects.get_for_model(ParkingLocation))
+ qs = qs.filter(asset_content_type__in=cts)
+ except Thematic.DoesNotExist:
+ pass
+
+ if status_filter:
+ qs = qs.filter(result_status=status_filter)
+
+ if nature_filter:
+ qs = qs.filter(nature=nature_filter)
+
+ if mission_id:
+ qs = qs.filter(mission_intervention_id=mission_id)
+
+ if checked_interv_id:
+ qs = qs.filter(checked_intervention_id=checked_interv_id)
+
+ if search_q:
+ search_filter = (
+ Q(notes__icontains=search_q) |
+ Q(inspector__first_name__icontains=search_q) |
+ Q(inspector__last_name__icontains=search_q) |
+ Q(inspector__username__icontains=search_q) |
+ Q(mission_intervention__title__icontains=search_q) |
+ Q(checked_intervention__title__icontains=search_q)
+ )
+
+ asset_q_conditions = Q()
+ for ct in ContentType.objects.filter(app_label='assets'):
+ ModelClass = ct.model_class()
+ if not ModelClass:
+ continue
+
+ model_fields = [f.name for f in ModelClass._meta.get_fields()]
+ asset_search = Q()
+ if 'code' in model_fields:
+ asset_search |= Q(code__icontains=search_q)
+ if 'name' in model_fields:
+ asset_search |= Q(name__icontains=search_q)
+ if 'name_fr' in model_fields:
+ asset_search |= Q(name_fr__icontains=search_q)
+
+ if asset_search:
+ matching_ids = list(ModelClass.objects.filter(asset_search).values_list('pk', flat=True)[:500])
+ if matching_ids:
+ asset_q_conditions |= Q(asset_content_type=ct, asset_object_id__in=matching_ids)
+
+ if asset_q_conditions:
+ search_filter |= asset_q_conditions
+
+ qs = qs.filter(search_filter)
+
+ context = {
+ 'base_template': "mobile/mobile_base.html",
+ 'is_mobile': True,
+ 'inspections': qs[:100],
+ 'total_count': qs.count(),
+ 'filter_type': filter_type,
+ 'selected_thematic': thematic_code,
+ 'selected_status': status_filter,
+ 'selected_nature': nature_filter,
+ 'selected_mission_id': mission_id,
+ 'selected_checked_interv_id': checked_interv_id,
+ 'search_q': search_q,
+ 'thematics': thematics,
+ 'can_inspect': can_inspect,
+ 'result_choices': INSPECTION_RESULT_CHOICES,
+ 'nature_choices': INSPECTION_NATURE_CHOICES,
+ 'INSPECTION_RESULT_CHOICES_CONFIG': Inspection.get_result_choices_config(),
+ }
+ return render(request, 'inspections/inspections_list_mobile.html', context)
+
+ # Desktop view
+ lang = get_language()
+ order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
+
+ user_config = getattr(request.user, 'config', None)
+ saved_filters = (user_config.inspection_list_filters or {}) if user_config else {}
+ saved_sort = (user_config.inspection_list_sort or {}) if user_config else {}
+
+ # Redirect on first visit with saved preferences
+ if not request.GET and saved_filters:
+ query_params = request.GET.copy()
+ for key, value in saved_filters.items():
+ if isinstance(value, list):
+ query_params.setlist(key, value)
+ elif value is not None:
+ query_params[key] = value
+ return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}")
+
+ categories = AssetCategory.objects.filter(thematic__in=thematics).order_by(order_field)
+
+ selected_thematics = request.GET.getlist('thematic')
+ selected_categories = request.GET.getlist('category')
+ selected_statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
+ selected_natures = request.GET.getlist('nature')
+ search_query = request.GET.get('q', '')
+ show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
+ date_from = request.GET.get('date_from', '')
+ date_to = request.GET.get('date_to', '')
+
+ category_choices = sorted(
+ [(c.id, c.get_name(), c.thematic.code if c.thematic else '', c.parent_id) for c in categories],
+ key=lambda t: (t[3] if t[3] is not None else t[0], 1 if t[3] is not None else 0, t[1])
+ )
+
+ result_choices_config = Inspection.get_result_choices_config()
+ result_status_choices = [
+ (cfg['code'], cfg['label'], cfg.get('badge_class', ''), cfg.get('color_hex', ''), cfg.get('icon', ''))
+ for cfg in result_choices_config
+ ]
context = {
- 'base_template': base_template,
- 'is_mobile': is_mobile,
- 'inspections': qs[:100],
- 'total_count': qs.count(),
- 'filter_type': filter_type,
- 'selected_thematic': thematic_code,
- 'selected_status': status_filter,
- 'selected_nature': nature_filter,
- 'selected_mission_id': mission_id,
- 'selected_checked_interv_id': checked_interv_id,
- 'search_q': search_q,
- 'thematics': thematics,
- 'can_inspect': can_inspect,
- 'result_choices': INSPECTION_RESULT_CHOICES,
+ 'thematic_choices': [(t.code, t.name, t.icon or 'bi-geo-alt') for t in thematics],
+ 'selected_thematics': selected_thematics,
+ 'category_choices': category_choices,
+ 'selected_categories': selected_categories,
+ 'result_choices': result_choices_config,
+ 'result_status_choices': result_status_choices,
+ 'selected_statuses': selected_statuses,
+ 'selected_result_statuses': selected_statuses,
'nature_choices': INSPECTION_NATURE_CHOICES,
+ 'selected_natures': selected_natures,
+ 'search_query': search_query,
+ 'show_mine_only': show_mine_only,
+ 'date_from': date_from,
+ 'date_to': date_to,
+ 'can_inspect': can_inspect,
+ 'saved_sort': json.dumps(saved_sort, ensure_ascii=False),
+ 'INSPECTION_RESULT_CHOICES_CONFIG': result_choices_config,
}
- return render(request, 'inspections/inspections_list.html', context)
+ return render(request, 'inspections/inspections_index.html', context)
def _get_asset_wgs84_lon_lat(obj):
@@ -517,17 +721,47 @@ def get_inspection_detail_api(request, inspection_id):
'is_image': doc.is_image,
})
+ from django.template.loader import render_to_string
+ from .registry import get_inspection_detail_partial_template
+ from assets.permissions import _get_asset_thematic
+
+ asset = leaf.asset
+ th = _get_asset_thematic(asset) if asset else None
+ thematic_name = th.name if th else ''
+ thematic_icon = th.icon if th and th.icon else 'bi-geo-alt'
+
+ detail_template = get_inspection_detail_partial_template(leaf)
+ rendered_detail_html = ''
+ if detail_template:
+ try:
+ rendered_detail_html = render_to_string(detail_template, {'insp': leaf}, request=request)
+ except Exception:
+ rendered_detail_html = ''
+
data = {
'success': True,
'id': leaf.id,
'content_type_id': leaf.asset_content_type_id,
'object_id': leaf.asset_object_id,
'target_display': leaf.target_display,
+ 'asset_url': getattr(asset, 'get_absolute_url', lambda: '')() if asset and hasattr(asset, 'get_absolute_url') else '',
+ 'mobile_asset_url': leaf.mobile_asset_url,
+ 'thematic_name': thematic_name,
+ 'thematic_icon': thematic_icon,
+ 'asset_type_name': leaf.asset_content_type.name if leaf.asset_content_type else '',
'result_status': leaf.result_status,
+ 'result_status_display': str(leaf.get_result_status_display()),
+ 'status_badge_class': leaf.status_badge_class,
+ 'status_icon': leaf.status_icon,
'nature': leaf.nature,
+ 'nature_display': str(leaf.get_nature_display()),
+ 'inspector_name': get_short_name(leaf.inspector) if leaf.inspector else '-',
'inspector_role': leaf.inspector_role,
+ 'inspector_role_display': str(leaf.get_inspector_role_display()) if leaf.inspector_role else '',
'notes': leaf.notes or '',
'verified_obsolescence': leaf.verified_obsolescence or '',
+ 'details_summary': leaf.details_summary,
+ 'details_html': rendered_detail_html,
'apply_changes_to_asset': leaf.apply_changes_to_asset,
'is_cancelled': leaf.is_cancelled,
'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '',
@@ -730,192 +964,426 @@ def toggle_cancel_inspection_api(request, inspection_id):
@login_required
-@require_POST
def inspections_geojson(request):
"""
Retourne la collection GeoJSON des inspections existantes filtrées.
+ Supporte :
+ - GET (utilisé par la vue desktop tableau/carte)
+ - POST avec JSON body (utilisé par quick_inspection_map et rétro-compatibilité)
"""
import json
from datetime import timedelta
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON
from django.contrib.gis.geos import GEOSGeometry
+ from assets.permissions import _get_asset_thematic
- 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')
- mission_id = data.get('mission_id')
-
- if period == 'disabled':
- return JsonResponse({'type': 'FeatureCollection', 'features': []})
-
- from .permissions import filter_viewable_inspections_for_user, get_contract_filtered_asset_qs
- qs = filter_viewable_inspections_for_user(request.user)
- qs = qs.select_related('asset_content_type', 'inspector', 'mission_intervention').prefetch_related('documents')
-
- if mission_id:
- qs = qs.filter(mission_intervention_id=mission_id)
-
- # 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))
-
- bbox_geom = None
- filter_geom_data = data.get('filter_geom')
- if filter_geom_data:
+ # Si c'est une requête POST avec payload JSON (format quick_inspection_map)
+ if request.method == 'POST' and (request.content_type == 'application/json' or request.body):
try:
- geom_json = filter_geom_data.get('geometry', filter_geom_data)
- bbox_geom = GEOSGeometry(json.dumps(geom_json), srid=4326)
+ data = json.loads(request.body)
except Exception:
- pass
+ data = {}
- 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 = {c for c in categories.values_list('allowed_models', flat=True).distinct() if c}
- if thematic_code == 'water':
- from assets.models.nature import NatureRWIZ, NatureRWIAsset
- ct_ids.add(ContentType.objects.get_for_model(NatureRWIZ).id)
- ct_ids.add(ContentType.objects.get_for_model(NatureRWIAsset).id)
- elif thematic_code == 'sign':
- from assets.models import SignPanel, SignPole
- ct_ids.add(ContentType.objects.get_for_model(SignPanel).id)
- ct_ids.add(ContentType.objects.get_for_model(SignPole).id)
- except Thematic.DoesNotExist:
- pass
+ if data.get('period') or data.get('filter_geom') or 'thematic' in 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')
+ mission_id = data.get('mission_id')
- if not ct_ids:
- ct_ids = {c for c in qs.values_list('asset_content_type_id', flat=True).distinct() if c}
+ if period == 'disabled':
+ return JsonResponse({'type': 'FeatureCollection', 'features': []})
- features = []
+ from .permissions import filter_viewable_inspections_for_user, get_contract_filtered_asset_qs
+ qs = filter_viewable_inspections_for_user(request.user)
+ qs = qs.select_related('asset_content_type', 'inspector', 'mission_intervention').prefetch_related('documents')
- user_config = getattr(request.user, 'config', None)
- is_external = (user_config is None) or (not user_config.is_intern)
- must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
- accessible_contract_ids = []
- if must_limit and user_config:
- from common.models import UserContractAccess
- accessible_contract_ids = list(
- UserContractAccess.objects.filter(
- user_config=user_config,
- can_view_assets=True
- ).values_list('contract_id', flat=True)
- )
+ if mission_id:
+ qs = qs.filter(mission_intervention_id=mission_id)
- 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
+ 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))
- asset_qs = ModelClass.objects.filter(geom__isnull=False)
- if must_limit:
- contract_filtered_qs = get_contract_filtered_asset_qs(ModelClass, accessible_contract_ids)
- if contract_filtered_qs is not None:
- asset_qs = asset_qs.filter(pk__in=contract_filtered_qs.values('pk'))
+ 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
- if bbox_geom:
- bbox_geom_3812 = bbox_geom.transform(3812, clone=True)
- asset_qs = asset_qs.filter(geom__intersects=bbox_geom_3812)
+ 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 = {c for c in categories.values_list('allowed_models', flat=True).distinct() if c}
+ if thematic_code == 'water':
+ from assets.models.nature import NatureRWIZ, NatureRWIAsset
+ ct_ids.add(ContentType.objects.get_for_model(NatureRWIZ).id)
+ ct_ids.add(ContentType.objects.get_for_model(NatureRWIAsset).id)
+ elif thematic_code == 'sign':
+ from assets.models import SignPanel, SignPole
+ ct_ids.add(ContentType.objects.get_for_model(SignPanel).id)
+ ct_ids.add(ContentType.objects.get_for_model(SignPole).id)
+ elif thematic_code == 'parking':
+ from assets.models import ParkingSpot, ParkingLocation
+ ct_ids.add(ContentType.objects.get_for_model(ParkingSpot))
+ ct_ids.add(ContentType.objects.get_for_model(ParkingLocation))
+ except Thematic.DoesNotExist:
+ pass
- asset_qs = asset_qs.annotate(
- wgs84_json=AsGeoJSON(Transform(Centroid('geom'), 4326))
- )
+ if not ct_ids:
+ ct_ids = {c for c in qs.values_list('asset_content_type_id', flat=True).distinct() if c}
- asset_geom_map = dict(asset_qs.values_list('id', 'wgs84_json'))
- if not asset_geom_map:
- continue
+ features = []
+ user_config = getattr(request.user, 'config', None)
+ is_external = (user_config is None) or (not user_config.is_intern)
+ must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
+ accessible_contract_ids = []
+ if must_limit and user_config:
+ from common.models import UserContractAccess
+ accessible_contract_ids = list(
+ UserContractAccess.objects.filter(
+ user_config=user_config,
+ can_view_assets=True
+ ).values_list('contract_id', flat=True)
+ )
- ct_inspections = qs.filter(
- asset_content_type=ct,
- asset_object_id__in=asset_geom_map.keys()
- ).order_by('-inspection_date')
+ 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_inspections_dict = {}
- for insp in ct_inspections:
- asset_inspections_dict.setdefault(insp.asset_object_id, []).append(insp)
+ asset_qs = ModelClass.objects.filter(geom__isnull=False)
+ if must_limit:
+ contract_filtered_qs = get_contract_filtered_asset_qs(ModelClass, accessible_contract_ids)
+ if contract_filtered_qs is not None:
+ asset_qs = asset_qs.filter(pk__in=contract_filtered_qs.values('pk'))
- 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:
+ if bbox_geom:
+ bbox_geom_3812 = bbox_geom.transform(3812, clone=True)
+ asset_qs = asset_qs.filter(geom__intersects=bbox_geom_3812)
+
+ 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')
+
+ 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
+
+ active_insps = [i for i in insp_list if i.result_status != 'cancelled']
+ top_insp = active_insps[0] if active_insps else insp_list[0]
+ target_obj = top_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,
+ 'status_icon': insp.status_icon,
+ 'is_cancelled': insp.is_cancelled,
+ 'nature_display': insp.get_nature_display(),
+ 'inspector_name': get_short_name(insp.inspector) 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,
+ 'can_edit': insp.detailed_inspection.can_edit(request.user),
+ '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': top_insp.target_display,
+ 'latest_result_status': top_insp.result_status,
+ 'latest_result_status_display': top_insp.get_result_status_display(),
+ 'latest_status_badge_class': top_insp.status_badge_class,
+ 'is_cancelled': top_insp.is_cancelled,
+ 'inspections_count': len(inspections_data),
+ 'inspections': inspections_data,
+ }
+ }
+ features.append(feature)
+ except Exception:
continue
- active_insps = [i for i in insp_list if i.result_status != 'cancelled']
- top_insp = active_insps[0] if active_insps else insp_list[0]
- target_obj = top_insp.asset
+ return JsonResponse({'type': 'FeatureCollection', 'features': features[:1000]})
- 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
- })
+ # GET / standard query request (desktop index / table / map)
+ qs = _filter_inspections_queryset(request)[:5000]
+ inspections_list = list(qs)
- 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,
- 'status_icon': insp.status_icon,
- 'is_cancelled': insp.is_cancelled,
- 'nature_display': insp.get_nature_display(),
- 'inspector_name': get_short_name(insp.inspector) 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,
- 'can_edit': insp.detailed_inspection.can_edit(request.user),
- 'documents': docs_data,
- })
+ ct_to_ids = {}
+ for insp in inspections_list:
+ ct_to_ids.setdefault(insp.asset_content_type_id, set()).add(insp.asset_object_id)
- 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': top_insp.target_display,
- 'latest_result_status': top_insp.result_status,
- 'latest_result_status_display': top_insp.get_result_status_display(),
- 'latest_status_badge_class': top_insp.status_badge_class,
- 'is_cancelled': top_insp.is_cancelled,
- 'inspections_count': len(inspections_data),
- 'inspections': inspections_data,
- }
- }
- features.append(feature)
+ assets_by_ct_and_id = {}
+ for ct_id, obj_ids in ct_to_ids.items():
+ try:
+ ct = ContentType.objects.get_for_id(ct_id)
+ ModelClass = ct.model_class()
+ if ModelClass:
+ asset_qs = ModelClass.objects.filter(id__in=obj_ids)
+ if hasattr(ModelClass, 'geom'):
+ asset_qs = asset_qs.annotate(
+ wgs84_geojson=AsGeoJSON(Transform(Centroid('geom'), 4326))
+ )
+ if hasattr(ModelClass, 'category'):
+ asset_qs = asset_qs.select_related('category')
+ for asset_obj in asset_qs:
+ assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
except Exception:
- continue
+ pass
- return JsonResponse({'type': 'FeatureCollection', 'features': features[:1000]})
+ features = []
+ for insp in inspections_list:
+ asset = assets_by_ct_and_id.get((insp.asset_content_type_id, insp.asset_object_id))
+
+ geometry = None
+ if asset:
+ if hasattr(asset, 'wgs84_geojson') and asset.wgs84_geojson:
+ try:
+ geometry = json.loads(asset.wgs84_geojson)
+ except Exception:
+ geometry = None
+ if not geometry:
+ lon, lat = _get_asset_wgs84_lon_lat(asset)
+ if lon is not None and lat is not None:
+ geometry = {"type": "Point", "coordinates": [lon, lat]}
+
+ th = _get_asset_thematic(asset) if asset else None
+ thematic_name = th.name if th else ''
+ thematic_code = th.code if th else ''
+ thematic_icon = th.icon if th and th.icon else 'bi-geo-alt'
+
+ cat_name = ''
+ if asset and hasattr(asset, 'category') and asset.category:
+ cat_name = asset.category.get_name()
+ elif asset and hasattr(asset, 'category_name'):
+ cat_name = asset.category_name
+
+ target_display = str(asset) if asset else f"Cible #{insp.asset_object_id}"
+ asset_url = getattr(asset, 'get_absolute_url', lambda: '')() if asset and hasattr(asset, 'get_absolute_url') else ''
+
+ features.append({
+ "type": "Feature",
+ "id": insp.id,
+ "geometry": geometry,
+ "properties": {
+ "id": insp.id,
+ "code": f"INSP#{insp.id:05d}",
+ "target_display": target_display,
+ "asset_id": insp.asset_object_id,
+ "content_type_id": insp.asset_content_type_id,
+ "asset_model": insp.asset_content_type.model if insp.asset_content_type else '',
+ "asset_model_name": insp.asset_content_type.name if insp.asset_content_type else '',
+ "asset_url": asset_url,
+ "thematic": thematic_name,
+ "thematic_code": thematic_code,
+ "thematic_icon": thematic_icon,
+ "category_name": cat_name,
+ "result_status": insp.result_status,
+ "result_status_label": str(insp.get_result_status_display()),
+ "result_status_badge": insp.status_badge_class,
+ "result_status_color": insp.status_color_hex,
+ "result_status_icon": insp.status_icon,
+ "nature": insp.nature,
+ "nature_label": str(insp.get_nature_display()),
+ "inspector": get_short_name(insp.inspector) if insp.inspector else '-',
+ "inspector_username": insp.inspector.username if insp.inspector else '',
+ "inspection_date": insp.inspection_date.isoformat() if insp.inspection_date else '',
+ "inspection_date_formatted": insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '',
+ "notes": insp.notes or '',
+ "details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
+ "verified_obsolescence": insp.verified_obsolescence or '',
+ "is_cancelled": insp.is_cancelled,
+ "documents_count": insp.documents.count(),
+ "can_edit": insp.detailed_inspection.can_edit(request.user),
+ }
+ })
+
+ return JsonResponse({
+ "type": "FeatureCollection",
+ "features": features
+ }, safe=False)
+
+
+@require_GET
+@login_required
+def inspections_export_excel(request):
+ """
+ Exporte les inspections filtrées au format Excel (.xlsx).
+ """
+ import io
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+ from openpyxl.utils import get_column_letter
+ from assets.permissions import _get_asset_thematic
+
+ qs = _filter_inspections_queryset(request)[:5000]
+ inspections_list = list(qs)
+
+ ct_to_ids = {}
+ for insp in inspections_list:
+ ct_to_ids.setdefault(insp.asset_content_type_id, set()).add(insp.asset_object_id)
+
+ assets_by_ct_and_id = {}
+ for ct_id, obj_ids in ct_to_ids.items():
+ try:
+ ct = ContentType.objects.get_for_id(ct_id)
+ ModelClass = ct.model_class()
+ if ModelClass:
+ asset_qs = ModelClass.objects.filter(id__in=obj_ids)
+ if hasattr(ModelClass, 'category'):
+ asset_qs = asset_qs.select_related('category')
+ for asset_obj in asset_qs:
+ assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
+ except Exception:
+ pass
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = str(_("Inspections"))
+
+ headers = [
+ str(_("ID")),
+ str(_("Date")),
+ str(_("Thématique")),
+ str(_("Type d'asset")),
+ str(_("Cible")),
+ str(_("Résultat du contrôle")),
+ str(_("Nature")),
+ str(_("Inspecteur")),
+ str(_("Obsolescence")),
+ str(_("Constats spécifiques")),
+ str(_("Remarques")),
+ str(_("Nb photos / docs")),
+ str(_("Annulée")),
+ ]
+
+ header_font = Font(name="Arial", size=11, bold=True, color="FFFFFF")
+ header_fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid")
+ header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+
+ thin_border = Border(
+ left=Side(style='thin', color='D3D3D3'),
+ right=Side(style='thin', color='D3D3D3'),
+ top=Side(style='thin', color='D3D3D3'),
+ bottom=Side(style='thin', color='D3D3D3')
+ )
+
+ ws.append(headers)
+ for col_num in range(1, len(headers) + 1):
+ cell = ws.cell(row=1, column=col_num)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = header_alignment
+ cell.border = thin_border
+ ws.row_dimensions[1].height = 28
+
+ data_font = Font(name="Arial", size=10)
+ data_alignment = Alignment(vertical="center")
+
+ for row_num, insp in enumerate(inspections_list, start=2):
+ asset = assets_by_ct_and_id.get((insp.asset_content_type_id, insp.asset_object_id))
+ th = _get_asset_thematic(asset) if asset else None
+ target_display = str(asset) if asset else f"Cible #{insp.asset_object_id}"
+
+ row_data = [
+ f"INSP#{insp.id:05d}",
+ insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '',
+ th.name if th else '',
+ insp.asset_content_type.name if insp.asset_content_type else '',
+ target_display,
+ str(insp.get_result_status_display()),
+ str(insp.get_nature_display()),
+ get_short_name(insp.inspector) if insp.inspector else '-',
+ insp.verified_obsolescence or '',
+ insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
+ insp.notes or '',
+ insp.documents.count(),
+ str(_("Oui")) if insp.is_cancelled else str(_("Non")),
+ ]
+
+ ws.append(row_data)
+ for col_num in range(1, len(headers) + 1):
+ cell = ws.cell(row=row_num, column=col_num)
+ cell.font = data_font
+ cell.alignment = data_alignment
+ cell.border = thin_border
+ ws.row_dimensions[row_num].height = 20
+
+ for col in ws.columns:
+ max_len = 0
+ col_letter = get_column_letter(col[0].column)
+ for cell in col:
+ val_str = str(cell.value or '')
+ if '\n' in val_str:
+ val_str = max(val_str.split('\n'), key=len)
+ max_len = max(max_len, len(val_str))
+ ws.column_dimensions[col_letter].width = max(max_len + 3, 12)
+
+ output = io.BytesIO()
+ wb.save(output)
+ output.seek(0)
+
+ filename = f"inspections_export_{timezone.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+ response = HttpResponse(
+ output.getvalue(),
+ content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ )
+ response['Content-Disposition'] = f'attachment; filename="{filename}"'
+ return response
@login_required