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 %}
-
-
-
- -
-
-
-
-
-
-
-
-
-
- | {% translate "Code" %} |
- {% translate "Description" %} |
- {% translate "Auteur" %} |
- {% translate "Type" %} |
- {% translate "Mise à jour" %} |
-
-
-
- {% for control in controls %}
-
- | {{ 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" }} |
-
- {% endfor %}
-
-
+
+
-{% endblock content%}
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+ {% if is_add_control_allowed %}
+
+ {% endif %}
+
+
+
+
+
+
+ {% 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