feat: add filtering system to controls index with persistent user preferences

This commit is contained in:
kdeterme 2026-08-17 11:43:31 +02:00
parent 68d3e042dc
commit 5dc4d0615a
10 changed files with 2131 additions and 98 deletions

View file

@ -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'),
),
]

View file

@ -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'),

View file

@ -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"),

View file

@ -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):

View file

@ -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%;
}
}

View file

@ -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 = `
<div class="popup-scrollable">
<div class="mb-1">
<strong><a href="${detailUrl}">${props.control_code || gettext('Contrôle')}</a></strong>
</div>
${props.description ? `<div class="mb-1 text-secondary">${truncateText(props.description, 80)}</div>` : ''}
${props.location ? `<div class="mb-1"><i class="bi bi-geo-alt me-1 text-muted"></i>${props.location}</div>` : ''}
${props.project_name ? `<div class="mb-1"><i class="bi bi-folder me-1 text-muted"></i>${props.project_name}</div>` : ''}
<div class="d-flex flex-wrap gap-1 my-1">
<span class="badge text-bg-${statusMeta.badge}">${props.status_label || statusMeta.label}</span>
<span class="badge text-bg-${typeMeta.badge}">${props.control_type_label || typeMeta.label}</span>
${props.remarks_count ? `<span class="badge bg-secondary"><i class="bi bi-chat-left-text me-1"></i>${props.remarks_count}</span>` : ''}
</div>
<div class="text-muted small mt-1">${props.author ? props.author + ' · ' : ''}${dateFormatter(props.created_at)}</div>
</div>
`;
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 = `<input type="checkbox" name="category" id="${noneId}" value="${parentId}">` +
`<label for="${noneId}"><i class="bi bi-slash-circle me-1 text-muted small"></i>${typeof subcatNoneLabel !== 'undefined' ? subcatNoneLabel : '(aucune sous-catégorie)'}</label>`;
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 = `<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 subcat-select-all" data-parent="${parentId}">Tous</button>` +
`<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 subcat-deselect-all" data-parent="${parentId}">Aucun</button>`;
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 += `
<button class="btn btn-sm btn-light zoom-btn" data-lon="${row.lon}" data-lat="${row.lat}" title="${gettext('Zoomer sur la carte')}">
<i class="bi bi-geo-alt"></i>
</button>`;
}
return html;
}
function codeFormatter(value, row) {
const label = value || '-';
return `<a href="${controlsIndexUrl}${row.id}/" class="fw-semibold text-decoration-none text-nowrap">${label}</a>`;
}
function descriptionFormatter(value, row) {
if (!value || !value.trim()) return '-';
const safeValue = value.trim();
const truncated = truncateText(safeValue, DESCRIPTION_MAX_LENGTH);
return `<span title="${safeValue}">${truncated}</span>`;
}
function thematicFormatter(value, row) {
if (!value || value === '-') {
return '-';
}
const icon = row.thematic_icon ? `<i class="bi ${row.thematic_icon} me-1"></i>` : '';
return `${icon}${value}`;
}
function statusFormatter(value, row) {
if (!value) {
return '-';
}
const meta = getStatusMetadata(row.status);
const label = row.status_label || meta.label;
return `<span class="badge text-bg-${row.status_badge || meta.badge}">${label}</span>`;
}
function controlTypeFormatter(value, row) {
const meta = getControlTypeMetadata(row.control_type);
const label = value || meta.label;
const icon = row.control_type_icon ? `<i class="bi ${row.control_type_icon} me-1"></i>` : '';
return `<span class="badge text-bg-${row.control_type_badge || meta.badge}">${icon}${label}</span>`;
}
function projectFormatter(value, row) {
if (!value || value === '-') return '-';
if (row.project_id && typeof projectsDetailBaseUrl !== 'undefined') {
return `<a href="${projectsDetailBaseUrl}${row.project_id}/" class="text-decoration-none"><i class="bi bi-folder me-1 text-muted"></i>${value}</a>`;
}
return `<i class="bi bi-folder me-1 text-muted"></i>${value}`;
}
function remarksFormatter(value, row) {
const count = parseInt(value, 10) || 0;
if (count === 0) return '<span class="text-muted">0</span>';
return `<span class="badge bg-secondary rounded-pill">${count}</span>`;
}
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);
}
});

View file

@ -1,62 +1,285 @@
{% extends "base.html" %}
{% load static %}
{% load tz %}
{% load i18n %}
{% load short_name %}
{% block head %}
<link rel="stylesheet" href="{% static 'controls/controls_index.css' %}">
{% endblock head %}
{% block content %}
<div class="container mt-2 position-relative">
<div class="position-absolute" style="top: 0px; right: 5px;">
{% if is_add_control_allowed == True %}
<a href="{% url 'controls:controls_add' %}" class="btn btn-light">
<i class="bi bi-plus-circle"></i> {% translate "Nouveau contrôle" %}
</a>
{% endif %}
</div>
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="controls-tab" data-bs-toggle="tab" data-bs-target="#controls-content" type="button" role="tab" aria-controls="controls" aria-selected="true">{% translate "Contrôles" %}</button>
</li>
</ul>
<div class="tab-content my-2" id="tablesTabContent">
<div class="tab-pane fade show active" id="controls-content" role="tabpanel" aria-labelledby="controls-tab">
<div id="controls-container">
<table id="controls-table" class="table"
data-toggle="table"
data-pagination="true"
data-page-size="25"
data-click-to-select="true"
data-search="true">
<thead>
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Description" %}</th>
<th>{% translate "Auteur" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Mise à jour" %}</th>
</tr>
</thead>
<tbody>
{% for control in controls %}
<tr>
<td><a href="{% url 'controls:controls_detail' control.id %}">{{ control.control_code }}</a></td>
<td>{{ control.description }}</td>
<td>{{ control.control_author|short_name }}</td>
<td><span class="badge bg-secondary">{{ control.get_control_type_display }}</span></td>
<td>{{ control.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Modale de filtre des contrôles -->
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les contrôles" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body">
<form method="get" id="filter-form" class="mb-4">
<!-- Thématique -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Thématique" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="thematic">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="thematic">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="thematic">
{% for key, value, icon in thematic_choices %}
<div class="filter-option">
<input type="checkbox" name="thematic" id="thematic_{{ key }}" value="{{ key }}" {% if key in selected_thematics %}checked{% endif %}>
<label for="thematic_{{ key }}">
{% if icon %}<i class="bi {{ icon }} me-1"></i>{% endif %}{{ value }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Catégorie -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Catégorie" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="category">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="category">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="category">
{% for id, name, thematic_code, parent_id in category_choices %}
{% if parent_id %}{% ifchanged parent_id %}<div class="filter-option-break"></div>{% endifchanged %}{% endif %}
<div class="filter-option{% if parent_id %} subcategory-option{% endif %}" data-thematic="{{ thematic_code }}" data-category-id="{{ id }}" data-parent-id="{{ parent_id|default:'' }}">
<input type="checkbox" name="category" id="category_{{ id }}" value="{{ id }}" {% if id|stringformat:"s" in selected_categories %}checked{% endif %}>
<label for="category_{{ id }}">{% if parent_id %}<i class="bi bi-arrow-return-right me-1 text-muted small"></i>{% endif %}{{ name }}</label>
</div>
{% endfor %}
</div>
</div>
<!-- Type de contrôle -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Type de contrôle" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="type">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="type">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="type">
{% for key, label, badge, icon in control_type_choices %}
<div class="filter-option">
<input type="checkbox" name="type" id="ctrl-type_{{ key }}" value="{{ key }}" {% if key in selected_types %}checked{% endif %}>
<label for="ctrl-type_{{ key }}" class="context-badge btn-outline-{{ badge }}">
{% if icon %}<i class="bi {{ icon }} me-1"></i>{% endif %}{{ label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Statut -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Statut" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="status">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="status">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="status">
{% for key, label, badge in status_choices %}
<div class="filter-option">
<input type="checkbox" name="status" id="ctrl-status_{{ key }}" value="{{ key }}" {% if key in selected_statuses %}checked{% endif %}>
<label for="ctrl-status_{{ key }}" class="context-badge btn-outline-{{ badge }}">{{ label }}</label>
</div>
{% endfor %}
</div>
</div>
<!-- Projets -->
{% if project_choices %}
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Projets" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="project">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="project">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="project">
{% for id, name in project_choices %}
<div class="filter-option">
<input type="checkbox" name="project" id="project_{{ id }}" value="{{ id }}" {% if id|stringformat:"s" in selected_projects %}checked{% endif %}>
<label for="project_{{ id }}"><i class="bi bi-folder me-1"></i>{{ name }}</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Période et mes contrôles -->
<div class="row mt-3">
<div class="col-md-6">
<label class="form-label">{% translate "Période (date de création)" %}</label>
<div class="d-flex gap-2 align-items-center">
<input type="date" name="date_from" id="date_from" class="form-control" value="{{ date_from }}" title="{% translate 'Du' %}">
<span class="text-muted small">–</span>
<input type="date" name="date_to" id="date_to" class="form-control" value="{{ date_to }}" title="{% translate 'Au' %}">
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check mt-3 mt-md-4">
<input class="form-check-input" type="checkbox" name="mine" value="1" id="mine" {% if show_mine_only %}checked{% endif %}>
<label class="form-check-label" for="mine">{% translate "Mes contrôles uniquement" %}</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button>
</div>
</div>
</div>
</div>
{% endblock content%}
<div class="position-relative">
<!-- Layout selection buttons -->
<div class="position-absolute top-0 end-0 mt-2" style="z-index: 10;">
<div class="btn-group" role="group" aria-label="{% translate 'Sélection de la vue' %}">
<button id="layout-btn-table" class="btn btn-light" type="button" title="{% translate 'Vue tableau uniquement' %}">
<i class="bi bi-table"></i>
</button>
<button id="layout-btn-both" class="btn btn-light text-primary" type="button" title="{% translate 'Vue partagée' %}">
<i class="bi bi-layout-split"></i>
</button>
<button id="layout-btn-map" class="btn btn-light" type="button" title="{% translate 'Vue carte uniquement' %}">
<i class="bi bi-globe-europe-africa"></i>
</button>
</div>
</div>
<!-- Widget de recherche — centré sur toute la largeur (tableau + carte) -->
<div id="controls-search-widget"
style="position:absolute; top:4px; left:50%; transform:translateX(-50%); z-index:10; pointer-events:none;">
<div class="input-group" style="pointer-events:auto; flex-wrap:nowrap;">
<div style="position:relative; display:flex;">
<input type="text" id="controls-search-input" class="form-control form-control-sm"
style="width:320px; border-radius:20px 0 0 20px; border-color:#dee2e6; padding-right:1.6rem;"
placeholder="{% translate 'Code, description, lieu, projet…' %}">
<button type="button" id="controls-search-clear"
style="position:absolute; right:4px; top:50%; transform:translateY(-50%);
background:none; border:none; padding:0 2px; line-height:1;
color:#adb5bd; cursor:pointer; visibility:hidden;"
title="{% translate 'Effacer la recherche' %}">
<i class="bi bi-x-lg" style="font-size:0.7rem;"></i>
</button>
</div>
<span class="input-group-text bg-white px-1" style="border-left:0; border-right:0; border-color:#dee2e6;">
<div class="btn-group btn-group-sm" role="group" aria-label="{% translate 'Mode' %}">
<input type="radio" class="btn-check" name="ctrl-search-mode" id="ctrl-search-mode-and" value="and" checked>
<label class="btn btn-outline-secondary py-0 px-2" for="ctrl-search-mode-and" title="{% translate 'Tous les mots doivent correspondre' %}">{% translate "AND" %}</label>
<input type="radio" class="btn-check" name="ctrl-search-mode" id="ctrl-search-mode-or" value="or">
<label class="btn btn-outline-secondary py-0 px-2" for="ctrl-search-mode-or" title="{% translate 'Au moins un mot doit correspondre' %}">{% translate "OR" %}</label>
</div>
</span>
<button class="btn btn-sm" type="button" id="controls-search-btn"
style="border-radius:0 20px 20px 0; border:1px solid #dee2e6; border-left:0; background:white; color:#495057;"
title="{% translate 'Rechercher' %}">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div class="row mt-2 controls-layout" style="height: calc(100vh - 130px);">
<div class="col-md-6" id="col-left" style="max-height: calc(100vh - 130px);">
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="controls-table-tab" data-bs-toggle="tab" data-bs-target="#controls-table-content" type="button" role="tab" aria-controls="controls-table" aria-selected="true">{% translate "Contrôles" %}</button>
</li>
{% if is_add_control_allowed %}
<div class="my-1 ms-2">
<a href="{% url 'controls:controls_add' %}" class="btn btn-light" title="{% translate 'Nouveau contrôle' %}">
<i class="bi bi-plus-circle"></i>
</a>
</div>
{% endif %}
</ul>
<div class="tab-content my-2" id="tablesTabContent">
<div class="tab-pane fade show active" id="controls-table-content" role="tabpanel" aria-labelledby="controls-table-tab">
<div class="d-flex justify-content-between flex-wrap align-items-center gap-2 mt-3">
<div class="text-muted small" id="controls-result-count">
{% translate "Aucun contrôle chargé" %}
</div>
<span id="filter-limit-alert" class="alert alert-info py-1 px-2 mb-0 d-none">
<i class="bi bi-info-circle me-1"></i>
{% translate "Contenu limité à 5 000 éléments." %}
</span>
</div>
<div id="controls-spinner" class="table-spinner py-5 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
<div id="controls-table-container" class="d-none">
<div id="filter-dropdown-wrapper" class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-light" data-bs-toggle="modal" data-bs-target="#filterModal">
<i class="bi bi-funnel me-1"></i>{% translate "Filtre" %}
</button>
<a id="export-excel-btn" href="{% url 'controls:controls_export_excel' %}" class="btn btn-light" title="{% translate 'Exporter en Excel' %}">
<i class="bi bi-file-earmark-spreadsheet"></i>
</a>
</div>
<table id="controls-table" class="table"
data-height="550"
data-pagination="true"
data-page-size="50"
data-show-columns="true"
data-unique-id="id"
data-sort-name="updated_at"
data-sort-order="desc">
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6" id="col-right">
<div id="map" class="rounded-2 controls-map">
<button id="filterzone" class="btn btn-light btn-sm controls-map-filter" type="button">
{% translate "Filtrer sur cette zone" %}
</button>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script>
const controlsgeojsonUrl = "{% url 'controls:controls_geojson' %}";
const controlsIndexUrl = "{% url 'controls:controls_index' %}";
const controlsExportExcelUrl = "{% url 'controls:controls_export_excel' %}";
const savePreferencesUrl = "{% url 'common:save_control_list_preferences' %}";
const projectsDetailBaseUrl = "{% url 'projects:projects_index' %}";
const SAVED_SORT = {{ saved_sort|default:"{}"|safe }};
const subcatNoneLabel = "{% translate '(aucune sous-catégorie)' %}";
</script>
<script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'controls/controls_index.js' %}"></script>
{% endblock script %}

View file

@ -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"])

View file

@ -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('<int:control_id>/', views.controls_detail, name='controls_detail'),

View file

@ -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