diff --git a/loko/common/migrations/0012_userconfig_control_list_filters_and_more.py b/loko/common/migrations/0012_userconfig_control_list_filters_and_more.py new file mode 100644 index 0000000..2b9d177 --- /dev/null +++ b/loko/common/migrations/0012_userconfig_control_list_filters_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-08-17 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0011_userconfig_preferred_language'), + ] + + operations = [ + migrations.AddField( + model_name='userconfig', + name='control_list_filters', + field=models.JSONField(blank=True, default=dict, help_text='Préférences de filtrage pour la vue liste des contrôles.', verbose_name='Filtres de la liste des contrôles'), + ), + migrations.AddField( + model_name='userconfig', + name='control_list_sort', + field=models.JSONField(blank=True, default=dict, help_text='Préférences de tri pour la vue liste des contrôles.', verbose_name='Tri de la liste des contrôles'), + ), + ] diff --git a/loko/common/models.py b/loko/common/models.py index 37f34ef..98ec490 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -217,6 +217,18 @@ class UserConfig(models.Model): verbose_name="Tri de la liste des projets", help_text="Préférences de tri pour la vue liste des projets.", ) + control_list_filters = models.JSONField( + default=dict, + blank=True, + verbose_name="Filtres de la liste des contrôles", + help_text="Préférences de filtrage pour la vue liste des contrôles.", + ) + control_list_sort = models.JSONField( + default=dict, + blank=True, + verbose_name="Tri de la liste des contrôles", + help_text="Préférences de tri pour la vue liste des contrôles.", + ) 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 13df3ac..f5a6271 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-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"), path("save-project-list-preferences/", views.save_project_list_preferences, name="save_project_list_preferences"), diff --git a/loko/common/views.py b/loko/common/views.py index 07f009b..9b48700 100644 --- a/loko/common/views.py +++ b/loko/common/views.py @@ -414,6 +414,37 @@ def save_observation_list_preferences(request): return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404) +@login_required +@require_POST +def save_control_list_preferences(request): + """API endpoint to save the user's preferred filters and sort for the controls 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.control_list_filters = filters + user_config.control_list_sort = sort + user_config.save(update_fields=['control_list_filters', 'control_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/controls/static/controls/controls_index.css b/loko/controls/static/controls/controls_index.css new file mode 100644 index 0000000..6c7c634 --- /dev/null +++ b/loko/controls/static/controls/controls_index.css @@ -0,0 +1,208 @@ +/* ========================================================================== + Controls Index CSS + ========================================================================== */ + +/* Supprime le contour bleu Bootstrap au focus sur l'input de recherche */ +#controls-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: #c69500; } +.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; color: #212529 !important; } +.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; +} + +/* Layout helpers */ +.controls-layout #controls-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)); +} + +.controls-map { + height: calc(100vh - 210px); + min-height: 360px; + position: relative; + width: 100%; + margin-top: 50px; +} + +.controls-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; + font-size: 0.85rem; +} + +.popup-scrollable .badge { + font-size: 0.75rem; +} + +@media (max-width: 991.98px) { + .controls-layout #filter-dropdown-wrapper { + position: static; + margin-bottom: 1rem; + } + + .controls-layout #controls-table-container { + padding-top: 0; + } + + .filter-dropdown { + width: 100%; + } +} diff --git a/loko/controls/static/controls/controls_index.js b/loko/controls/static/controls/controls_index.js new file mode 100644 index 0000000..128847c --- /dev/null +++ b/loko/controls/static/controls/controls_index.js @@ -0,0 +1,995 @@ +let controls_geojson = {}; +let allControls = []; +let visibleRows = []; + +const MAX_ITEMS = 5000; +const DESCRIPTION_MAX_LENGTH = 60; + +function truncateText(text, limit) { + if (!text || text.length <= limit) { + return text; + } + return `${text.substring(0, limit - 3).trim()}...`; +} + +const STATUS_METADATA = { + in_progress: { label: gettext('En cours'), badge: 'warning', color: '#ffc107' }, + information: { label: gettext('Information'), badge: 'info', color: '#0dcaf0' }, + to_be_processed: { label: gettext('À traiter'), badge: 'danger', color: '#dc3545' }, + processed: { label: gettext('Traité'), badge: 'success', color: '#198754' }, + validated: { label: gettext('Validé'), badge: 'primary', color: '#0d6efd' }, + canceled: { label: gettext('Annulé'), badge: 'secondary', color: '#6c757d' } +}; + +function getStatusMetadata(status) { + return STATUS_METADATA[status] || { label: status || '-', badge: 'secondary', color: '#888888' }; +} + +const CONTROL_TYPE_METADATA = { + control: { label: gettext('Contrôle'), badge: 'primary', icon: 'bi-clipboard-check' }, + site_report: { label: gettext('Rapport de chantier'), badge: 'warning', icon: 'bi-building-gear' }, + statement: { label: gettext('Constat'), badge: 'danger', icon: 'bi-exclamation-octagon' }, + meeting_report: { label: gettext('Rapport de réunion'), badge: 'info', icon: 'bi-people' }, +}; + +function getControlTypeMetadata(type) { + return CONTROL_TYPE_METADATA[type] || { label: type || '-', badge: 'secondary', icon: '' }; +} + +const controlsgeojsonloader = new GeojsonLoader(); +const controls_layer_defs = [ + { + id: 'controls', + type: 'circle', + source: 'controls', + minzoom: 0, + maxzoom: 24, + paint: { + 'circle-radius': [ + 'interpolate', + ['linear'], + ['zoom'], + 8, 4, + 14, 8, + 18, 14 + ], + 'circle-color': [ + 'match', + ['get', 'status'], + 'in_progress', STATUS_METADATA.in_progress.color, + 'information', STATUS_METADATA.information.color, + 'to_be_processed', STATUS_METADATA.to_be_processed.color, + 'processed', STATUS_METADATA.processed.color, + 'validated', STATUS_METADATA.validated.color, + 'canceled', STATUS_METADATA.canceled.color, + '#007cbf' + ], + '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, controlsgeojsonloader, 'controls', controls_layer_defs); + + initControlsTable(); + setupFilterForm(); + setupFilterZoneButton(); + setupLayoutButtons(); + + const query = new URLSearchParams(window.location.search).toString(); + const initialUrl = query ? `${controlsgeojsonUrl}?${query}` : controlsgeojsonUrl; + controlsgeojsonloader.loadGeojson(initialUrl); + + // Sync export button with initial URL filters + _updateExportExcelUrl(query); +}); + +controlsgeojsonloader.addEventListener('geojsonLoaded', (event) => { + const geojsonData = event.detail; + controls_geojson = geojsonData; + allControls = geojsonData.features || []; + visibleRows = [...allControls]; + + loadDataIntoControlsTable(allControls); + fitMapToControls(allControls); + updateFilterLimitAlert(allControls.length); +}); + +controlsgeojsonloader.addEventListener('geojsonError', () => { + document.getElementById('controls-spinner')?.classList.add('d-none'); + document.getElementById('controls-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: 'controls', + 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', 'controls', (e) => { + const feature = e.features?.[0]; + if (!feature) { + return; + } + + highlightMapFeature(mapInstance, 'controls', feature.id, highlightState); + + const props = feature.properties || {}; + const statusMeta = getStatusMetadata(props.status); + const typeMeta = getControlTypeMetadata(props.control_type); + const detailUrl = `${controlsIndexUrl}${props.id}/`; + + let popupContent = ` + + `; + + new maplibregl.Popup({ closeButton: true }) + .setLngLat(e.lngLat) + .setHTML(popupContent) + .addTo(mapInstance); + + highlightRow('#controls-table', 'id', props.id); + }); + + mapInstance.on('mouseenter', 'controls', () => { + mapInstance.getCanvas().style.cursor = 'pointer'; + }); + + mapInstance.on('mouseleave', 'controls', () => { + mapInstance.getCanvas().style.cursor = ''; + }); +} + +function initControlsTable() { + const $table = $('#controls-table'); + if (!$table.length) { + return; + } + + function calculateTableHeight() { + const windowHeight = $(window).height(); + const offset = 260; + return Math.max(360, windowHeight - offset); + } + + // Prepare initial sort from saved preferences + const initialSort = {}; + if (typeof SAVED_SORT !== 'undefined' && SAVED_SORT.sortName) { + initialSort.sortName = SAVED_SORT.sortName; + initialSort.sortOrder = SAVED_SORT.sortOrder || 'desc'; + } else { + initialSort.sortName = 'updated_at'; + initialSort.sortOrder = 'desc'; + } + + $table.bootstrapTable({ + columns: [ + { field: 'actions', title: '', formatter: 'actionsFormatter', class: 'text-nowrap' }, + { field: 'control_code', title: gettext('Code'), formatter: 'codeFormatter', class: 'text-nowrap', sortable: true }, + { field: 'thematic_names', title: gettext('Thématiques'), formatter: 'thematicFormatter', class: 'text-nowrap', sortable: true }, + { field: 'category_names', title: gettext('Catégories'), sortable: true }, + { field: 'control_type_label', title: gettext('Type'), formatter: 'controlTypeFormatter', class: 'text-nowrap', sortable: true }, + { field: 'status_label', title: gettext('Statut'), formatter: 'statusFormatter', class: 'text-nowrap', sortable: true }, + { field: 'project_name', title: gettext('Projet'), formatter: 'projectFormatter', sortable: true }, + { field: 'location', title: gettext('Localisation'), sortable: true }, + { field: 'author', title: gettext('Auteur'), class: 'text-nowrap', sortable: true }, + { field: 'remarks_count', title: gettext('Remarques'), formatter: 'remarksFormatter', class: 'text-center text-nowrap', sortable: true }, + { field: 'created_at', title: gettext('Création'), formatter: 'dateFormatter', class: 'text-nowrap', sortable: true }, + { field: 'updated_at', title: gettext('Mise à jour'), formatter: 'dateFormatter', class: 'text-nowrap', sortable: true }, + { field: 'description', title: gettext('Description'), formatter: 'descriptionFormatter', sortable: true } + ], + height: calculateTableHeight(), + ...initialSort + }); + + // Restore saved column visibility + _restoreColumnVisibility($table); + + // Save column visibility when toggled + $table.on('column-switch.bs.table', function () { + setTimeout(function () { + _saveColumnVisibility($table); + $table.bootstrapTable('resetView'); + }, 0); + }); + + // Save sort preferences when sort changes + $table.on('sort.bs.table', function () { + setTimeout(savePreferencesFromUrl, 500); + }); + + registerZoomAction('#controls-table', '.zoom-btn', map, 16); + + $table.on('post-body.bs.table', () => { + visibleRows = $('#controls-table').bootstrapTable('getData'); + syncMapWithTable(); + }); + + $(window).on('resize', () => { + $table.bootstrapTable('resetView', { height: calculateTableHeight() }); + }); +} + +function loadDataIntoControlsTable(features) { + const tableData = features.map((feature) => { + const props = feature.properties || {}; + const coords = feature.geometry?.coordinates || []; + const statusMeta = getStatusMetadata(props.status); + const typeMeta = getControlTypeMetadata(props.control_type); + + return { + actions: '', + id: props.id || feature.id, + control_code: props.control_code || '', + description: props.description || '', + thematic_names: props.thematic_names || '-', + thematic_icon: props.thematic_icon || '', + category_names: props.category_names || '-', + control_type: props.control_type || '', + control_type_label: props.control_type_label || typeMeta.label, + control_type_badge: typeMeta.badge, + control_type_icon: typeMeta.icon, + status: props.status || '', + status_label: props.status_label || statusMeta.label, + status_badge: statusMeta.badge, + project_id: props.project_id || null, + project_name: props.project_name || '-', + phase_name: props.phase_name || '', + location: props.location || '-', + author: props.author || '-', + remarks_count: props.remarks_count || 0, + created_at: props.created_at || '', + updated_at: props.updated_at || '', + lon: coords[0] ?? null, + lat: coords[1] ?? null + }; + }); + + $('#controls-spinner').addClass('d-none'); + $('#controls-table-container').removeClass('d-none'); + $('#controls-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('controls-result-count'); + if (!target) { + return; + } + + if (!count) { + target.textContent = gettext('Aucun contrôle chargé'); + return; + } + + const msg = ngettext('%(count)s contrôle chargé', '%(count)s contrôles chargés', count); + target.textContent = interpolate(msg, { count }, true); +} + +function fitMapToControls(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, maxZoom: 17 }); + } +} + +function setupFilterForm() { + const form = document.getElementById('filter-form'); + if (!form) { + return; + } + + // Apply filters button + 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(); + }); + + // Reset filters button + document.getElementById('reset-filters')?.addEventListener('click', () => { + // Uncheck filter checkboxes + 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="type"]').forEach(cb => { cb.checked = false; }); + form.querySelectorAll('input[type="checkbox"][name="status"]').forEach(cb => { cb.checked = false; }); + form.querySelectorAll('input[type="checkbox"][name="project"]').forEach(cb => { cb.checked = false; }); + + // Reset dates and mine + 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; + + // Reset category view + 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); + + // Save empty preferences on reset + 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(); + }); + + // Select-all / deselect-all buttons + 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(); + } + } + }); + }); + + // Thematic → category chain + 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; + } + }); + } + } + }); + }); + + // Subcategory groups initialization + 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'; + + // (aucune sous-catégorie) chip + 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'; + + // Controls Tous/Aucun + 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 ? `${controlsgeojsonUrl}?${queryString}` : controlsgeojsonUrl; + + window.history.pushState({}, '', queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname); + + _updateExportExcelUrl(queryString); + + $('#controls-spinner').removeClass('d-none'); + $('#controls-table-container').addClass('d-none'); + + handleGeojsonMapLoading(false, controlsgeojsonloader, 'controls'); + controlsgeojsonloader.loadGeojson(targetUrl); +} + +function _updateExportExcelUrl(queryString) { + const btn = document.getElementById('export-excel-btn'); + if (!btn || typeof controlsExportExcelUrl === 'undefined') return; + btn.href = queryString ? `${controlsExportExcelUrl}?${queryString}` : controlsExportExcelUrl; +} + +// === Search widget === + +function _applySearchToParams(params) { + const q = $('#controls-search-input').val().trim(); + if (q) { + params.set('q', q); + params.set('search_mode', document.querySelector('input[name="ctrl-search-mode"]:checked')?.value || 'and'); + } else { + params.delete('q'); + params.delete('search_mode'); + } +} + +function _buildCtrlSearchParams() { + const params = new URLSearchParams(window.location.search); + _applySearchToParams(params); + return params; +} + +function runControlSearch() { + const params = _buildCtrlSearchParams(); + const hasSearch = !!$('#controls-search-input').val().trim(); + $('#controls-search-clear').css('visibility', hasSearch ? 'visible' : 'hidden'); + refreshListing(params); + setTimeout(savePreferencesFromUrl, 500); +} + +$('#controls-search-btn').on('click', function () { + runControlSearch(); +}); + +$('#controls-search-input').on('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + runControlSearch(); + } else if (e.key === 'Escape') { + $(this).val(''); + runControlSearch(); + } +}); + +$('#controls-search-clear').on('click', function () { + $('#controls-search-input').val(''); + $('#controls-search-clear').css('visibility', 'hidden'); + runControlSearch(); +}); + +window.addEventListener('load', function () { + const _urlParams = new URLSearchParams(window.location.search); + const _urlQ = _urlParams.get('q') || ''; + if (_urlQ) { + $('#controls-search-input').val(_urlQ); + $('#controls-search-clear').css('visibility', 'visible'); + const _urlMode = _urlParams.get('search_mode') || 'and'; + const _radio = document.querySelector(`input[name="ctrl-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 || !controls_geojson.features) { + return; + } + filterTableByMapBounds(map, controls_geojson, loadDataIntoControlsTable); + }); +} + +function syncMapWithTable() { + if (!map || !map.getLayer('controls')) { + return; + } + + if (!visibleRows.length || visibleRows.length === allControls.length) { + map.setFilter('controls', null); + return; + } + + const ids = visibleRows.map((row) => row.id); + if (!ids.length) { + map.setFilter('controls', ['==', ['get', 'id'], '__none__']); + return; + } + + map.setFilter('controls', ['match', ['get', 'id'], ['literal', ids], true, false]); +} + +// Table column formatters + +function actionsFormatter(value, row) { + let html = ''; + if (row.lon && row.lat) { + html += ` + `; + } + return html; +} + +function codeFormatter(value, row) { + const label = value || '-'; + return `${label}`; +} + +function descriptionFormatter(value, row) { + if (!value || !value.trim()) return '-'; + const safeValue = value.trim(); + const truncated = truncateText(safeValue, DESCRIPTION_MAX_LENGTH); + return `${truncated}`; +} + +function thematicFormatter(value, row) { + if (!value || value === '-') { + return '-'; + } + const icon = row.thematic_icon ? `` : ''; + return `${icon}${value}`; +} + +function statusFormatter(value, row) { + if (!value) { + return '-'; + } + const meta = getStatusMetadata(row.status); + const label = row.status_label || meta.label; + return `${label}`; +} + +function controlTypeFormatter(value, row) { + const meta = getControlTypeMetadata(row.control_type); + const label = value || meta.label; + const icon = row.control_type_icon ? `` : ''; + return `${icon}${label}`; +} + +function projectFormatter(value, row) { + if (!value || value === '-') return '-'; + if (row.project_id && typeof projectsDetailBaseUrl !== 'undefined') { + return `${value}`; + } + return `${value}`; +} + +function remarksFormatter(value, row) { + const count = parseInt(value, 10) || 0; + if (count === 0) return '0'; + return `${count}`; +} + +window.actionsFormatter = actionsFormatter; +window.codeFormatter = codeFormatter; +window.descriptionFormatter = descriptionFormatter; +window.thematicFormatter = thematicFormatter; +window.statusFormatter = statusFormatter; +window.controlTypeFormatter = controlTypeFormatter; +window.projectFormatter = projectFormatter; +window.remarksFormatter = remarksFormatter; + +// 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 { + // both (default) + 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('controls_layout', layout); + + setTimeout(() => { + maploader.map?.resize(); + $('#controls-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('controls_layout') || 'both'; + setLayout(savedLayout); +} + +// 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 CTRL_COLUMNS_KEY = 'controls_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(CTRL_COLUMNS_KEY, JSON.stringify(hiddenFields)); + } catch (e) { + // ignore + } +} + +function _restoreColumnVisibility($table) { + try { + const raw = localStorage.getItem(CTRL_COLUMNS_KEY); + if (!raw) return; + const hiddenFields = JSON.parse(raw); + if (Array.isArray(hiddenFields)) { + hiddenFields.forEach((field) => { + $table.bootstrapTable('hideColumn', field); + }); + } + } catch (e) { + // ignore + } +} + +function savePreferencesFromUrl() { + if (typeof savePreferencesUrl === 'undefined') return; + + const urlParams = new URLSearchParams(window.location.search); + const filters = {}; + const filterParams = ['thematic', 'category', 'status', 'type', 'project', '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 = $('#controls-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 || 'desc' }; + } + } + + 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', 'status', 'type', 'project', '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 = $('#controls-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 || 'desc' }; + } + } + + try { + await fetch(savePreferencesUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') }, + body: JSON.stringify({ filters, sort }), + keepalive: true + }); + } catch (e) { + // ignore + } +} + +document.addEventListener('DOMContentLoaded', function () { + if (window.location.search) { + setTimeout(savePreferencesFromUrl, 1000); + } +}); diff --git a/loko/controls/templates/controls/controls_index.html b/loko/controls/templates/controls/controls_index.html index 00e507e..39df0f0 100644 --- a/loko/controls/templates/controls/controls_index.html +++ b/loko/controls/templates/controls/controls_index.html @@ -1,62 +1,285 @@ {% extends "base.html" %} {% load static %} -{% load tz %} {% load i18n %} -{% load short_name %} + +{% block head %} + +{% endblock head %} {% block content %} -
-
- {% if is_add_control_allowed == True %} - - {% translate "Nouveau contrôle" %} - - {% endif %} -
- -
-
-
- - - - - - - - - - - - {% for control in controls %} - - - - - - - - {% endfor %} - -
{% translate "Code" %}{% translate "Description" %}{% translate "Auteur" %}{% translate "Type" %}{% translate "Mise à jour" %}
{{ control.control_code }}{{ control.description }}{{ control.control_author|short_name }}{{ control.get_control_type_display }}{{ control.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}
+ + -{% endblock content%} +
+ +
+
+ + + +
+
+ +
+
+
+ + +
+ +
+ + + + +
+
+ +
+
+ +
+
+ + +
+
+
+
+ {% translate "Aucun contrôle chargé" %} +
+ + + {% translate "Contenu limité à 5 000 éléments." %} + +
+ +
+
+ {% translate "Chargement..." %} +
+
+ +
+
+ + + + +
+ + +
+
+
+
+
+ +
+
+ +
+
+
+
+ +{% endblock content %} {% block script %} - + + + + + {% endblock script %} \ No newline at end of file diff --git a/loko/controls/tests.py b/loko/controls/tests.py index 7ce503c..0a017cb 100644 --- a/loko/controls/tests.py +++ b/loko/controls/tests.py @@ -1,3 +1,218 @@ +import io +import json from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from openpyxl import load_workbook -# Create your tests here. +from common.models import Thematic, Role, UserConfig, UserThematics +from assets.models import AssetCategory +from projects.models import Project, ProjectUserAccess +from controls.models import Control, Remark + +User = get_user_model() + + +class ControlsIndexTests(TestCase): + def setUp(self): + # Thematics + self.thematic_a = Thematic.objects.create( + code="THEME_A", + name_fr="Voirie", + name_nl="Wegenis", + icon="bi-cone-striped" + ) + self.thematic_b = Thematic.objects.create( + code="THEME_B", + name_fr="Signalisation", + name_nl="Signalisatie", + icon="bi-signpost" + ) + + # Categories + self.cat_a = AssetCategory.objects.create( + thematic=self.thematic_a, + code="CAT_A", + name_fr="Trottoir", + name_nl="Voetpad" + ) + + # Users + self.admin_user = User.objects.create_user("admin_user", password="password123") + self.admin_config = UserConfig.objects.create(user=self.admin_user, is_intern=True) + self.admin_role, _ = Role.objects.get_or_create(name="admin") + self.admin_config.roles.add(self.admin_role) + + self.internal_user = User.objects.create_user("internal_user", password="password123") + self.internal_config = UserConfig.objects.create(user=self.internal_user, is_intern=True) + UserThematics.objects.create( + user_config=self.internal_config, + thematic=self.thematic_a, + can_view_interventions=True + ) + + self.other_user = User.objects.create_user("other_user", password="password123") + self.other_config = UserConfig.objects.create(user=self.other_user, is_intern=False) + + # Project + self.project = Project.objects.create( + name="Projet Test", + code="PRJ-001", + created_by=self.admin_user + ) + ProjectUserAccess.objects.create( + project=self.project, + user=self.internal_user, + can_view=True + ) + + # Controls + self.ctrl1 = Control.objects.create( + control_code="CTRL-0001", + description="Contrôle bordure rue Royale", + location="Rue Royale 10", + status="in_progress", + control_type="control", + control_author=self.internal_user, + project=self.project, + lon=4.36, + lat=50.85, + visibility="intern" + ) + self.ctrl1.thematics.add(self.thematic_a) + self.ctrl1.categories.add(self.cat_a) + Remark.objects.create(control=self.ctrl1, description="Remarque 1") + + self.ctrl2 = Control.objects.create( + control_code="CTRL-0002", + description="Constat marquage au sol", + location="Avenue Louise 20", + status="to_be_processed", + control_type="statement", + control_author=self.admin_user, + lon=4.37, + lat=50.84, + visibility="all" + ) + self.ctrl2.thematics.add(self.thematic_b) + + self.ctrl_private = Control.objects.create( + control_code="CTRL-0003", + description="Contrôle privé", + status="information", + control_type="meeting_report", + control_author=self.admin_user, + visibility="private" + ) + + def test_controls_index_page_loads(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_index")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "controls/controls_index.html") + self.assertIn("thematic_choices", response.context) + self.assertIn("status_choices", response.context) + self.assertIn("control_type_choices", response.context) + self.assertIn("category_choices", response.context) + self.assertIn("project_choices", response.context) + + def test_controls_index_saved_preferences_redirect(self): + self.admin_config.control_list_filters = {"thematic": ["THEME_A"]} + self.admin_config.save() + + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_index")) + self.assertEqual(response.status_code, 302) + self.assertIn("thematic=THEME_A", response.url) + + def test_controls_geojson_endpoint(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_geojson")) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["type"], "FeatureCollection") + self.assertTrue(len(data["features"]) >= 2) + + # Check properties on first feature + feature = next(f for f in data["features"] if f["id"] == self.ctrl1.id) + self.assertEqual(feature["properties"]["control_code"], "CTRL-0001") + self.assertEqual(feature["properties"]["status"], "in_progress") + self.assertEqual(feature["properties"]["control_type"], "control") + self.assertEqual(feature["properties"]["project_name"], "Projet Test") + self.assertEqual(feature["properties"]["remarks_count"], 1) + self.assertEqual(feature["geometry"]["coordinates"], [4.36, 50.85]) + + def test_controls_geojson_filter_by_thematic(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_geojson"), {"thematic": ["THEME_A"]}) + self.assertEqual(response.status_code, 200) + data = response.json() + codes = [f["properties"]["control_code"] for f in data["features"]] + self.assertIn("CTRL-0001", codes) + self.assertNotIn("CTRL-0002", codes) + + def test_controls_geojson_filter_by_status_and_type(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_geojson"), { + "status": ["to_be_processed"], + "type": ["statement"] + }) + self.assertEqual(response.status_code, 200) + data = response.json() + codes = [f["properties"]["control_code"] for f in data["features"]] + self.assertEqual(codes, ["CTRL-0002"]) + + def test_controls_geojson_filter_by_search_query(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_geojson"), {"q": "Royale"}) + self.assertEqual(response.status_code, 200) + data = response.json() + codes = [f["properties"]["control_code"] for f in data["features"]] + self.assertEqual(codes, ["CTRL-0001"]) + + def test_controls_geojson_filter_mine(self): + self.client.login(username="internal_user", password="password123") + response = self.client.get(reverse("controls:controls_geojson"), {"mine": "1"}) + self.assertEqual(response.status_code, 200) + data = response.json() + codes = [f["properties"]["control_code"] for f in data["features"]] + self.assertEqual(codes, ["CTRL-0001"]) + + def test_controls_geojson_permissions(self): + # Internal user without access to thematic B and not author of private control + self.client.login(username="internal_user", password="password123") + response = self.client.get(reverse("controls:controls_geojson")) + self.assertEqual(response.status_code, 200) + data = response.json() + codes = [f["properties"]["control_code"] for f in data["features"]] + self.assertIn("CTRL-0001", codes) + self.assertNotIn("CTRL-0003", codes) # private to admin_user + + def test_controls_export_excel(self): + self.client.login(username="admin_user", password="password123") + response = self.client.get(reverse("controls:controls_export_excel")) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response["Content-Type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + wb = load_workbook(filename=io.BytesIO(response.content)) + ws = wb.active + self.assertIn(ws.title, ["Contrôles", "Controls"]) + self.assertGreaterEqual(ws.max_row, 2) # Header + rows + + def test_save_control_list_preferences(self): + self.client.login(username="internal_user", password="password123") + payload = { + "filters": {"thematic": ["THEME_A"], "mine": "1"}, + "sort": {"sortName": "created_at", "sortOrder": "asc"} + } + response = self.client.post( + reverse("common:save_control_list_preferences"), + data=json.dumps(payload), + content_type="application/json" + ) + self.assertEqual(response.status_code, 200) + self.internal_config.refresh_from_db() + self.assertEqual(self.internal_config.control_list_filters, payload["filters"]) + self.assertEqual(self.internal_config.control_list_sort, payload["sort"]) diff --git a/loko/controls/urls.py b/loko/controls/urls.py index 5a0081e..a559ce7 100644 --- a/loko/controls/urls.py +++ b/loko/controls/urls.py @@ -6,6 +6,8 @@ app_name = "controls" urlpatterns = [ path("", views.controls_index, name="controls_index"), + path("geojson/", views.controls_geojson, name="controls_geojson"), + path("export-excel/", views.controls_export_excel, name="controls_export_excel"), path("service-worker.js", views.controls_service_worker, name="controls_service_worker"), path("offline/", views.controls_offline, name="controls_offline"), path('/', views.controls_detail, name='controls_detail'), diff --git a/loko/controls/views.py b/loko/controls/views.py index 86b9bff..157f7d9 100644 --- a/loko/controls/views.py +++ b/loko/controls/views.py @@ -9,19 +9,20 @@ from django.shortcuts import render, get_object_or_404, redirect from django.views.decorators.csrf import csrf_exempt from django.views.decorators.cache import never_cache from django.http import JsonResponse, FileResponse, HttpResponse, HttpResponseForbidden -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_POST, require_GET from django.conf import settings from django.contrib.staticfiles import finders as staticfiles_finders from pathlib import Path from django.contrib import messages from django.contrib.auth.decorators import login_required from django.utils.timezone import now -from django.db.models import Q, Exists, OuterRef, Subquery +from django.utils.translation import get_language +from django.db.models import Q, Exists, OuterRef, Subquery, Count from django.db import transaction from django.urls import reverse from django.utils.translation import gettext as _ -from django.contrib.gis.db.models.functions import Transform, Centroid +from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON from pdf2image import convert_from_path from PIL import Image @@ -30,18 +31,22 @@ from common.private_files.helpers import build_private_url from .views_reports_pdf import generate_control_pdf -from .models import Control, Remark, RemarkImage, RemarkHistory, RemarkTag, ControlReferencePlan, ControlReport, ControlReportTemplate, Category, ControlQuantity +from .models import ( + Control, Remark, RemarkImage, RemarkHistory, RemarkTag, + ControlReferencePlan, ControlReport, ControlReportTemplate, Category, ControlQuantity, + CONTROL_STATUS_CHOICES, CONTROL_TYPE_CHOICES +) from .forms import ControlForm, RemarkForm, ControlReferencePlanForm, ControlReportTemplateForm, ControlQuantityForm, QuantityFormSet from .permissions import control_view_required, control_add_required, user_can_add_control, control_change_required, user_can_change_control from projects.models import Project, ProjectUserAccess, ProjectPhase from projects.permissions import filter_viewable_projects_for_user from interventions.models import Intervention -from interventions.models import Intervention -from common.models import UserConfig, UserThematics, UserContractAccess +from common.models import UserConfig, UserThematics, UserContractAccess, Thematic +from assets.models import AssetCategory from contracts.models import ContractPost, Contract -from common.utils import redirect_with_variant +from common.utils import redirect_with_variant, get_short_name logger = logging.getLogger(__name__) @@ -184,87 +189,405 @@ def _read_service_worker_content(): return 'console.error("Controls service worker introuvable");' -def controls_index(request): - user = request.user - variant = request.GET.get('v', 'desktop') - if "/mobile/" in request.path: - variant = 'mobile' +_CONTROL_STATUS_BADGE_MAP = { + 'in_progress': 'warning', + 'information': 'info', + 'to_be_processed': 'danger', + 'processed': 'success', + 'validated': 'primary', + 'canceled': 'secondary', +} - # Admin - is_admin = bool(getattr(user, "config", None) and (user.config.has_role("admin") or user.config.has_role("top_manager"))) +_CONTROL_STATUS_COLOR_MAP = { + 'in_progress': '#ffc107', + 'information': '#0dcaf0', + 'to_be_processed': '#dc3545', + 'processed': '#198754', + 'validated': '#0d6efd', + 'canceled': '#6c757d', +} - # Auteur +_CONTROL_TYPE_BADGE_MAP = { + 'control': 'primary', + 'site_report': 'warning', + 'statement': 'danger', + 'meeting_report': 'info', +} + +_CONTROL_TYPE_ICON_MAP = { + 'control': 'bi-clipboard-check', + 'site_report': 'bi-building-gear', + 'statement': 'bi-exclamation-octagon', + 'meeting_report': 'bi-people', +} + + +def _get_viewable_controls_queryset(user): + """Retourne le queryset des contrôles accessibles pour un utilisateur selon les règles de permissions.""" + is_admin = bool( + getattr(user, "config", None) and + (user.config.has_role("admin") or user.config.has_role("top_manager")) + ) author_q = Q(control_author=user) - - - # Accès direct au projet is_member_q = Q(Exists( ProjectUserAccess.objects.filter(user=user, project=OuterRef('project'), can_view=True) )) - # Accès thématique (uniquement si user interne) : - # - contrôles sans projet : accès si thématique correspondante - # - contrôles avec projet + visibilité 'all' : accès thématique en fallback if getattr(user, "config", None) and user.config.is_intern: user_thematic_ids = UserThematics.objects.filter( user_config=user.config, can_view_interventions=True ).values_list('thematic_id', flat=True) - # Un interne peut voir tout contrôle avec thématique correspondante, - # qu'il soit lié à un projet ou non, dès lors qu'il n'est pas privé - # (les contrôles privés sont filtrés par not_private_q). thematic_access_q = Q(thematics__in=user_thematic_ids) else: - thematic_access_q = Q(pk__in=[]) # aucun accès thématique + thematic_access_q = Q(pk__in=[]) - # Visibilité : exclure les contrôles privés dont l'utilisateur n'est pas l'auteur not_private_q = ~Q(visibility='private') | author_q - # Visibilité : exclure les contrôles internes pour les utilisateurs externes is_intern = getattr(user, "config", None) and user.config.is_intern if is_intern: - not_intern_only_q = Q() # les internes peuvent voir les contrôles 'intern' + not_intern_only_q = Q() else: not_intern_only_q = ~Q(visibility='intern') | author_q if is_admin: - controls_qs = Control.objects.all().distinct() + controls_qs = Control.objects.all() else: controls_qs = Control.objects.filter( ( author_q - | ( - # Membre du projet (si projet présent) - Q(project__isnull=False) & is_member_q - ) - | thematic_access_q # Accès thématique (avec ou sans projet) + | (Q(project__isnull=False) & is_member_q) + | thematic_access_q ) & not_private_q & not_intern_only_q - ).distinct() + ) - controls_list = [] - for control in controls_qs: - control_dict = { - 'id': control.id, - 'control_code': control.control_code, - 'description': control.description, - 'author': control.control_author.username if control.control_author else "", - 'link': control.get_absolute_url(), # Use get_absolute_url for the link - #'coordinates': [project.geom_4326.x, project.geom_4326.y] # Extract coordinates from the transformed centroid + return controls_qs.distinct() + + +def _apply_control_filters(qs, params, user): + """Applique les filtres reçus en paramètres GET au queryset de contrôles.""" + thematics = params.getlist('thematic') + if thematics: + qs = qs.filter(thematics__code__in=thematics) + + categories = params.getlist('category') + if categories: + child_ids = list(AssetCategory.objects.filter(parent_id__in=categories).values_list('id', flat=True)) + expanded_categories = list(categories) + [str(c) for c in child_ids] + qs = qs.filter(categories__id__in=expanded_categories) + + types = params.getlist('type') + if types: + qs = qs.filter(control_type__in=types) + + statuses = params.getlist('status') + if statuses: + qs = qs.filter(status__in=statuses) + + projects = params.getlist('project') + if projects: + qs = qs.filter(project__id__in=projects) + + show_mine_only = params.get('mine') == '1' + if show_mine_only: + qs = qs.filter(control_author=user) + + date_from = (params.get('date_from') or '').strip() + date_to = (params.get('date_to') or '').strip() + if date_from: + qs = qs.filter(created_at__date__gte=date_from) + if date_to: + qs = qs.filter(created_at__date__lte=date_to) + + search_query = (params.get('q') or '').strip() + search_mode = (params.get('search_mode') or 'and').lower() + if search_mode not in ('and', 'or'): + search_mode = 'and' + if search_query: + words = search_query.split() + def _word_q(word): + return ( + Q(control_code__icontains=word) | + Q(description__icontains=word) | + Q(location__icontains=word) | + Q(project__name__icontains=word) + ) + combined = Q() + if search_mode == 'and': + for w in words: + combined &= _word_q(w) + else: + for w in words: + combined |= _word_q(w) + qs = qs.filter(combined) + + return qs.distinct() + + +@login_required +def controls_index(request): + """Page d'index des contrôles avec vue partagée tableau / carte et filtres.""" + user = request.user + variant = request.GET.get('v', 'desktop') + if "/mobile/" in request.path: + variant = 'mobile' + + lang = get_language() + order_field = f'name_{lang}' if lang == 'nl' else 'name_fr' + + user_config = getattr(request.user, 'config', None) + is_admin = bool(user_config and (user_config.has_role("admin") or user_config.has_role("top_manager"))) + + if is_admin: + thematics = Thematic.objects.all().order_by(order_field) + thematic_ids = list(thematics.values_list('id', flat=True)) + elif user_config and user_config.is_intern: + thematic_ids = list(UserThematics.objects.filter( + user_config=user_config, + can_view_interventions=True + ).values_list('thematic_id', flat=True)) + thematics = Thematic.objects.filter(id__in=thematic_ids).order_by(order_field) + else: + thematic_ids = [] + thematics = Thematic.objects.none() + + categories = AssetCategory.objects.filter(thematic__id__in=thematic_ids).order_by(order_field) if thematic_ids else AssetCategory.objects.none() + viewable_projects = filter_viewable_projects_for_user(request.user).order_by('name') + + saved_filters = (user_config.control_list_filters or {}) if user_config else {} + saved_sort = (user_config.control_list_sort or {}) if user_config else {} + + # Redirect on first visit with saved preferences (desktop only) + if variant != 'mobile' and 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 redirect(f"{request.path}?{query_params.urlencode()}") + + selected_thematics = request.GET.getlist('thematic') + selected_categories = request.GET.getlist('category') + selected_statuses = request.GET.getlist('status') + selected_types = request.GET.getlist('type') + selected_projects = request.GET.getlist('project') + search_query = request.GET.get('q', '') + show_mine_only = request.GET.get('mine') == '1' + date_from = request.GET.get('date_from', '') + date_to = request.GET.get('date_to', '') + + controls_qs = _get_viewable_controls_queryset(user) + + if variant == 'mobile': + filtered_qs = _apply_control_filters(controls_qs, request.GET, user).select_related( + 'control_author', 'project', 'phase' + ).prefetch_related('thematics', 'categories')[:100] + context = { + 'controls': filtered_qs, + 'is_add_control_allowed': user_can_add_control(request.user), } - controls_list.append(control_dict) + return render(request, 'controls/controls_index_mobile.html', context) context = { - 'controls': controls_qs, - 'controls_list': controls_list, # for javascript usage + 'thematic_choices': [(t.code, t.name, t.icon) for t in thematics], + 'selected_thematics': selected_thematics, + '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]) + ), + 'selected_categories': selected_categories, + 'status_choices': [(k, str(l), _CONTROL_STATUS_BADGE_MAP.get(k, 'secondary')) for k, l in CONTROL_STATUS_CHOICES.items()], + 'selected_statuses': selected_statuses, + 'control_type_choices': [(k, str(l), _CONTROL_TYPE_BADGE_MAP.get(k, 'secondary'), _CONTROL_TYPE_ICON_MAP.get(k, '')) for k, l in CONTROL_TYPE_CHOICES.items()], + 'selected_types': selected_types, + 'project_choices': [(p.id, p.name) for p in viewable_projects], + 'selected_projects': selected_projects, + 'search_query': search_query, + 'show_mine_only': show_mine_only, + 'date_from': date_from, + 'date_to': date_to, + 'saved_sort': json.dumps(saved_sort, ensure_ascii=False), 'is_add_control_allowed': user_can_add_control(request.user), } - - template_name = 'controls/controls_index_mobile.html' if variant == 'mobile' else 'controls/controls_index.html' + return render(request, 'controls/controls_index.html', context) - return render(request, template_name, context) + +@require_GET +@login_required +def controls_geojson(request): + """Retourne les contrôles sous forme de GeoJSON FeatureCollection pour la carte et le tableau.""" + user = request.user + qs = _get_viewable_controls_queryset(user).select_related( + 'control_author', 'project', 'phase' + ).prefetch_related( + 'thematics', 'categories', 'remarks' + ).annotate( + remarks_count=Count('remarks', distinct=True), + geom_geojson=AsGeoJSON(Transform('geom', 4326)) + ) + + qs = _apply_control_filters(qs, request.GET, user) + qs = qs.order_by('-updated_at')[:5000] + + features = [] + for ctrl in qs: + geometry = None + if ctrl.geom_geojson: + try: + geometry = json.loads(ctrl.geom_geojson) + except (json.JSONDecodeError, TypeError): + geometry = None + + if not geometry and ctrl.lon is not None and ctrl.lat is not None: + try: + geometry = { + "type": "Point", + "coordinates": [float(ctrl.lon), float(ctrl.lat)] + } + except (ValueError, TypeError): + geometry = None + + thematic_names = [t.name for t in ctrl.thematics.all()] + thematic_icons = [t.icon for t in ctrl.thematics.all() if t.icon] + category_names = [c.get_name() for c in ctrl.categories.all()] + + features.append({ + "type": "Feature", + "id": ctrl.id, + "geometry": geometry, + "properties": { + "id": ctrl.id, + "control_code": ctrl.control_code, + "description": ctrl.description or "", + "location": ctrl.location or "", + "status": ctrl.status, + "status_label": ctrl.get_status_display(), + "status_badge": _CONTROL_STATUS_BADGE_MAP.get(ctrl.status, 'secondary'), + "status_color": _CONTROL_STATUS_COLOR_MAP.get(ctrl.status, '#6c757d'), + "control_type": ctrl.control_type, + "control_type_label": ctrl.get_control_type_display(), + "control_type_badge": _CONTROL_TYPE_BADGE_MAP.get(ctrl.control_type, 'secondary'), + "thematics": thematic_names, + "thematic_names": ", ".join(thematic_names) if thematic_names else "", + "thematic_icon": thematic_icons[0] if thematic_icons else "", + "category_names": ", ".join(category_names) if category_names else "", + "project_id": ctrl.project_id, + "project_name": ctrl.project.name if ctrl.project else "", + "phase_name": ctrl.phase.name if ctrl.phase else "", + "author": get_short_name(ctrl.control_author) if ctrl.control_author else "", + "remarks_count": ctrl.remarks_count, + "created_at": ctrl.created_at.isoformat() if ctrl.created_at else "", + "updated_at": ctrl.updated_at.isoformat() if ctrl.updated_at else "", + "visibility": ctrl.visibility, + "visibility_label": ctrl.get_visibility_display(), + } + }) + + return JsonResponse({ + "type": "FeatureCollection", + "features": features + }, safe=False) + + +@require_GET +@login_required +def controls_export_excel(request): + """Exporte les contrôles filtrés vers un fichier Excel.""" + from openpyxl import Workbook + from openpyxl.styles import Font, Alignment, PatternFill + from openpyxl.utils import get_column_letter + + user = request.user + qs = _get_viewable_controls_queryset(user).select_related( + 'control_author', 'project', 'phase' + ).prefetch_related( + 'thematics', 'categories', 'remarks' + ).annotate( + remarks_count=Count('remarks', distinct=True) + ) + + qs = _apply_control_filters(qs, request.GET, user) + qs = qs.order_by('-updated_at')[:5000] + + wb = Workbook() + ws = wb.active + ws.title = str(_("Contrôles")) + + headers = [ + str(_("Code")), + str(_("Type")), + str(_("Statut")), + str(_("Description")), + str(_("Localisation")), + str(_("Projet")), + str(_("Phase")), + str(_("Thématiques")), + str(_("Catégories")), + str(_("Auteur")), + str(_("Nb remarques")), + str(_("Date de création")), + str(_("Dernière mise à jour")), + str(_("Longitude")), + str(_("Latitude")), + ] + + header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="2F3E8B", end_color="2F3E8B", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + 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 + + for ctrl in qs: + thematic_names = ", ".join(t.name for t in ctrl.thematics.all()) + category_names = ", ".join(c.get_name() for c in ctrl.categories.all()) + row = [ + ctrl.control_code, + ctrl.get_control_type_display(), + ctrl.get_status_display(), + ctrl.description or "", + ctrl.location or "", + ctrl.project.name if ctrl.project else "", + ctrl.phase.name if ctrl.phase else "", + thematic_names, + category_names, + get_short_name(ctrl.control_author) if ctrl.control_author else "", + ctrl.remarks_count, + ctrl.created_at.strftime("%d/%m/%Y %H:%M") if ctrl.created_at else "", + ctrl.updated_at.strftime("%d/%m/%Y %H:%M") if ctrl.updated_at else "", + ctrl.lon if ctrl.lon is not None else "", + ctrl.lat if ctrl.lat is not None else "", + ] + ws.append(row) + + # Ajustement de la largeur des colonnes + 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 '') + max_len = max(max_len, len(val_str)) + ws.column_dimensions[col_letter].width = min(max(max_len + 3, 10), 50) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + response = HttpResponse( + output.getvalue(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = 'attachment; filename="controles.xlsx"' + return response @control_add_required