feat: add filtering system to controls index with persistent user preferences
This commit is contained in:
parent
68d3e042dc
commit
5dc4d0615a
10 changed files with 2131 additions and 98 deletions
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -217,6 +217,18 @@ class UserConfig(models.Model):
|
||||||
verbose_name="Tri de la liste des projets",
|
verbose_name="Tri de la liste des projets",
|
||||||
help_text="Préférences de tri pour la vue 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 = [
|
ASSETS_LIST_LAYOUT_CHOICES = [
|
||||||
('both', 'Tableau et carte'),
|
('both', 'Tableau et carte'),
|
||||||
('table_only', 'Tableau seul'),
|
('table_only', 'Tableau seul'),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ urlpatterns = [
|
||||||
path("save-intervention-list-preferences/", views.save_intervention_list_preferences, name="save_intervention_list_preferences"),
|
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-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-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-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-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"),
|
path("save-project-list-preferences/", views.save_project_list_preferences, name="save_project_list_preferences"),
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,37 @@ def save_observation_list_preferences(request):
|
||||||
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
|
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
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def save_assets_index_preferences(request):
|
def save_assets_index_preferences(request):
|
||||||
|
|
|
||||||
208
loko/controls/static/controls/controls_index.css
Normal file
208
loko/controls/static/controls/controls_index.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
995
loko/controls/static/controls/controls_index.js
Normal file
995
loko/controls/static/controls/controls_index.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,62 +1,285 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load tz %}
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load short_name %}
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="{% static 'controls/controls_index.css' %}">
|
||||||
|
{% endblock head %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container mt-2 position-relative">
|
<!-- Modale de filtre des contrôles -->
|
||||||
<div class="position-absolute" style="top: 0px; right: 5px;">
|
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
|
||||||
{% if is_add_control_allowed == True %}
|
<div class="modal-dialog modal-lg">
|
||||||
<a href="{% url 'controls:controls_add' %}" class="btn btn-light">
|
<div class="modal-content">
|
||||||
<i class="bi bi-plus-circle"></i> {% translate "Nouveau contrôle" %}
|
<div class="modal-header">
|
||||||
</a>
|
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les contrôles" %}</h5>
|
||||||
{% endif %}
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
|
<div class="modal-body">
|
||||||
<li class="nav-item" role="presentation">
|
<form method="get" id="filter-form" class="mb-4">
|
||||||
<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>
|
<!-- Thématique -->
|
||||||
</ul>
|
<div class="col-12 filter-section">
|
||||||
<div class="tab-content my-2" id="tablesTabContent">
|
<div class="filter-section-header">
|
||||||
<div class="tab-pane fade show active" id="controls-content" role="tabpanel" aria-labelledby="controls-tab">
|
<label>{% translate "Thématique" %}</label>
|
||||||
<div id="controls-container">
|
<div class="filter-section-buttons">
|
||||||
<table id="controls-table" class="table"
|
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="thematic">{% translate "Tous" %}</button>
|
||||||
data-toggle="table"
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="thematic">{% translate "Aucun" %}</button>
|
||||||
data-pagination="true"
|
</div>
|
||||||
data-page-size="25"
|
</div>
|
||||||
data-click-to-select="true"
|
<div class="filter-options" data-field="thematic">
|
||||||
data-search="true">
|
{% for key, value, icon in thematic_choices %}
|
||||||
<thead>
|
<div class="filter-option">
|
||||||
<tr>
|
<input type="checkbox" name="thematic" id="thematic_{{ key }}" value="{{ key }}" {% if key in selected_thematics %}checked{% endif %}>
|
||||||
<th>{% translate "Code" %}</th>
|
<label for="thematic_{{ key }}">
|
||||||
<th>{% translate "Description" %}</th>
|
{% if icon %}<i class="bi {{ icon }} me-1"></i>{% endif %}{{ value }}
|
||||||
<th>{% translate "Auteur" %}</th>
|
</label>
|
||||||
<th>{% translate "Type" %}</th>
|
</div>
|
||||||
<th>{% translate "Mise à jour" %}</th>
|
{% endfor %}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
|
||||||
{% for control in controls %}
|
<!-- Catégorie -->
|
||||||
<tr>
|
<div class="col-12 filter-section">
|
||||||
<td><a href="{% url 'controls:controls_detail' control.id %}">{{ control.control_code }}</a></td>
|
<div class="filter-section-header">
|
||||||
<td>{{ control.description }}</td>
|
<label>{% translate "Catégorie" %}</label>
|
||||||
<td>{{ control.control_author|short_name }}</td>
|
<div class="filter-section-buttons">
|
||||||
<td><span class="badge bg-secondary">{{ control.get_control_type_display }}</span></td>
|
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="category">{% translate "Tous" %}</button>
|
||||||
<td>{{ control.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</td>
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="category">{% translate "Aucun" %}</button>
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</tbody>
|
<div class="filter-options" data-field="category">
|
||||||
</table>
|
{% 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>
|
||||||
</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 %}
|
{% 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 %}
|
{% endblock script %}
|
||||||
|
|
@ -1,3 +1,218 @@
|
||||||
|
import io
|
||||||
|
import json
|
||||||
from django.test import TestCase
|
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"])
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ app_name = "controls"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.controls_index, name="controls_index"),
|
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("service-worker.js", views.controls_service_worker, name="controls_service_worker"),
|
||||||
path("offline/", views.controls_offline, name="controls_offline"),
|
path("offline/", views.controls_offline, name="controls_offline"),
|
||||||
path('<int:control_id>/', views.controls_detail, name='controls_detail'),
|
path('<int:control_id>/', views.controls_detail, name='controls_detail'),
|
||||||
|
|
|
||||||
|
|
@ -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.csrf import csrf_exempt
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
from django.http import JsonResponse, FileResponse, HttpResponse, HttpResponseForbidden
|
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.conf import settings
|
||||||
from django.contrib.staticfiles import finders as staticfiles_finders
|
from django.contrib.staticfiles import finders as staticfiles_finders
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.utils.timezone import now
|
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.db import transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
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 pdf2image import convert_from_path
|
||||||
from PIL import Image
|
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 .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 .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 .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.models import Project, ProjectUserAccess, ProjectPhase
|
||||||
from projects.permissions import filter_viewable_projects_for_user
|
from projects.permissions import filter_viewable_projects_for_user
|
||||||
from interventions.models import Intervention
|
from interventions.models import Intervention
|
||||||
from interventions.models import Intervention
|
from common.models import UserConfig, UserThematics, UserContractAccess, Thematic
|
||||||
from common.models import UserConfig, UserThematics, UserContractAccess
|
from assets.models import AssetCategory
|
||||||
from contracts.models import ContractPost, Contract
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -184,87 +189,405 @@ def _read_service_worker_content():
|
||||||
return 'console.error("Controls service worker introuvable");'
|
return 'console.error("Controls service worker introuvable");'
|
||||||
|
|
||||||
|
|
||||||
def controls_index(request):
|
_CONTROL_STATUS_BADGE_MAP = {
|
||||||
user = request.user
|
'in_progress': 'warning',
|
||||||
variant = request.GET.get('v', 'desktop')
|
'information': 'info',
|
||||||
if "/mobile/" in request.path:
|
'to_be_processed': 'danger',
|
||||||
variant = 'mobile'
|
'processed': 'success',
|
||||||
|
'validated': 'primary',
|
||||||
|
'canceled': 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
# Admin
|
_CONTROL_STATUS_COLOR_MAP = {
|
||||||
is_admin = bool(getattr(user, "config", None) and (user.config.has_role("admin") or user.config.has_role("top_manager")))
|
'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)
|
author_q = Q(control_author=user)
|
||||||
|
|
||||||
|
|
||||||
# Accès direct au projet
|
|
||||||
is_member_q = Q(Exists(
|
is_member_q = Q(Exists(
|
||||||
ProjectUserAccess.objects.filter(user=user, project=OuterRef('project'), can_view=True)
|
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:
|
if getattr(user, "config", None) and user.config.is_intern:
|
||||||
user_thematic_ids = UserThematics.objects.filter(
|
user_thematic_ids = UserThematics.objects.filter(
|
||||||
user_config=user.config,
|
user_config=user.config,
|
||||||
can_view_interventions=True
|
can_view_interventions=True
|
||||||
).values_list('thematic_id', flat=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)
|
thematic_access_q = Q(thematics__in=user_thematic_ids)
|
||||||
else:
|
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
|
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
|
is_intern = getattr(user, "config", None) and user.config.is_intern
|
||||||
if is_intern:
|
if is_intern:
|
||||||
not_intern_only_q = Q() # les internes peuvent voir les contrôles 'intern'
|
not_intern_only_q = Q()
|
||||||
else:
|
else:
|
||||||
not_intern_only_q = ~Q(visibility='intern') | author_q
|
not_intern_only_q = ~Q(visibility='intern') | author_q
|
||||||
|
|
||||||
if is_admin:
|
if is_admin:
|
||||||
controls_qs = Control.objects.all().distinct()
|
controls_qs = Control.objects.all()
|
||||||
else:
|
else:
|
||||||
controls_qs = Control.objects.filter(
|
controls_qs = Control.objects.filter(
|
||||||
(
|
(
|
||||||
author_q
|
author_q
|
||||||
| (
|
| (Q(project__isnull=False) & is_member_q)
|
||||||
# Membre du projet (si projet présent)
|
| thematic_access_q
|
||||||
Q(project__isnull=False) & is_member_q
|
|
||||||
)
|
|
||||||
| thematic_access_q # Accès thématique (avec ou sans projet)
|
|
||||||
)
|
)
|
||||||
& not_private_q
|
& not_private_q
|
||||||
& not_intern_only_q
|
& not_intern_only_q
|
||||||
).distinct()
|
)
|
||||||
|
|
||||||
controls_list = []
|
return controls_qs.distinct()
|
||||||
for control in controls_qs:
|
|
||||||
control_dict = {
|
|
||||||
'id': control.id,
|
def _apply_control_filters(qs, params, user):
|
||||||
'control_code': control.control_code,
|
"""Applique les filtres reçus en paramètres GET au queryset de contrôles."""
|
||||||
'description': control.description,
|
thematics = params.getlist('thematic')
|
||||||
'author': control.control_author.username if control.control_author else "",
|
if thematics:
|
||||||
'link': control.get_absolute_url(), # Use get_absolute_url for the link
|
qs = qs.filter(thematics__code__in=thematics)
|
||||||
#'coordinates': [project.geom_4326.x, project.geom_4326.y] # Extract coordinates from the transformed centroid
|
|
||||||
|
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 = {
|
context = {
|
||||||
'controls': controls_qs,
|
'thematic_choices': [(t.code, t.name, t.icon) for t in thematics],
|
||||||
'controls_list': controls_list, # for javascript usage
|
'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),
|
'is_add_control_allowed': user_can_add_control(request.user),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return render(request, 'controls/controls_index.html', context)
|
||||||
template_name = 'controls/controls_index_mobile.html' if variant == 'mobile' else 'controls/controls_index.html'
|
|
||||||
|
|
||||||
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
|
@control_add_required
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue