From 0fc77df30652c43d3f826321412422ca041896d0 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 28 Aug 2026 14:29:42 +0200 Subject: [PATCH] feat: implement desktop view of inspection list view with filtering, export capabilities, and map integration --- ...config_inspection_list_filters_and_more.py | 23 + loko/common/models.py | 12 + loko/common/urls.py | 1 + loko/common/views.py | 31 + .../static/inspections/inspections_index.css | 199 +++ .../static/inspections/inspections_index.js | 1285 +++++++++++++++++ .../inspections/inspections_index.html | 399 +++++ .../inspections/inspections_list_mobile.html | 751 ++++++++++ loko/inspections/tests.py | 126 +- loko/inspections/urls.py | 2 + loko/inspections/views.py | 928 +++++++++--- 11 files changed, 3509 insertions(+), 248 deletions(-) create mode 100644 loko/common/migrations/0014_userconfig_inspection_list_filters_and_more.py create mode 100644 loko/inspections/static/inspections/inspections_index.css create mode 100644 loko/inspections/static/inspections/inspections_index.js create mode 100644 loko/inspections/templates/inspections/inspections_index.html create mode 100644 loko/inspections/templates/inspections/inspections_list_mobile.html 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 = `` + + ``; + 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 = `` + + ``; + 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 = ` +
+ ${doc.filename} + +
+ `; + 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} +
${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 "Aucune inspection chargée" %} +
+ + + {% translate "Contenu limité à 5 000 éléments." %} + +
+ +
+
+ {% translate "Chargement..." %} +
+
+ +
+
+ + + + +
+ + +
+
+
+
+
+ +
+
+ +
+
+
+
+ + + + + +
+ + +
+ +{% 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 %} +
+ + {% translate "Nouvelle inspection" %} + +
+ {% 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 %} +
+ + +
+ {{ insp.inspection_date|date:"d/m/Y H:i" }} +
+ {% if insp.documents.all %} + + {{ insp.documents.count }} + + {% endif %} + {% if insp.inspector %} + {{ insp.inspector|short_name|default:insp.inspector.username }} + {% endif %} +
+
+
+
+
+ + + + {% endfor %} +
+ {% else %} +
+ +
{% translate "Aucune inspection trouvée" %}
+

{% translate "Aucune inspection ne correspond à vos critères de recherche." %}

+
+ {% endif %} +
+ + +
+ + + + + +
+ + + + + + + + +{% 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